From 743ce222862e99e7cc31f0d1f8531975c0feacbc Mon Sep 17 00:00:00 2001 From: Github Actions Date: Tue, 28 Apr 2026 22:03:05 +0000 Subject: [PATCH 01/42] Version bump --- box.json | 2 +- changelog.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/box.json b/box.json index 1aedefe..e737226 100644 --- a/box.json +++ b/box.json @@ -1,6 +1,6 @@ { "name":"ColdBox CLI", - "version":"8.11.0", + "version":"8.12.0", "location":"https://downloads.ortussolutions.com/ortussolutions/commandbox-modules/coldbox-cli/@build.version@/coldbox-cli-@build.version@.zip", "slug":"coldbox-cli", "author":"Ortus Solutions, Corp", diff --git a/changelog.md b/changelog.md index 2bc1559..3eec158 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.11.0] - 2026-04-28 + ### Changed - **GitHub Copilot migrated to `AGENTS.md`** @@ -374,7 +376,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Eclipse support -[unreleased]: https://github.com/ColdBox/coldbox-cli/compare/v8.10.1...HEAD +[unreleased]: https://github.com/ColdBox/coldbox-cli/compare/v8.11.0...HEAD +[8.11.0]: https://github.com/ColdBox/coldbox-cli/compare/v8.10.1...v8.11.0 [8.10.1]: https://github.com/ColdBox/coldbox-cli/compare/v8.10.0...v8.10.1 [8.10.0]: https://github.com/ColdBox/coldbox-cli/compare/v8.9.0...v8.10.0 [8.9.0]: https://github.com/ColdBox/coldbox-cli/compare/v8.8.0...v8.9.0 From b9b2148e602e134ccd4df3c8e926938e1fb8ba2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 12:23:45 +0000 Subject: [PATCH 02/42] Bump taiki-e/create-gh-release-action from 1.9.3 to 1.11.0 Bumps [taiki-e/create-gh-release-action](https://github.com/taiki-e/create-gh-release-action) from 1.9.3 to 1.11.0. - [Release notes](https://github.com/taiki-e/create-gh-release-action/releases) - [Changelog](https://github.com/taiki-e/create-gh-release-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/create-gh-release-action/compare/v1.9.3...v1.11.0) --- updated-dependencies: - dependency-name: taiki-e/create-gh-release-action dependency-version: 1.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 263daaa..824f09d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,7 @@ jobs: box forgebox publish --force - name: Create Github Release - uses: taiki-e/create-gh-release-action@v1.9.3 + uses: taiki-e/create-gh-release-action@v1.11.0 continue-on-error: true if: env.SNAPSHOT == 'false' with: From c299f9c36deb96869db094f87dc28364eb886d7a Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Fri, 1 May 2026 12:01:53 -0400 Subject: [PATCH 03/42] more skills --- .../coldbox-docbox-annotations/SKILL.md | 278 +++++++++++++ .../skills/coldbox-docbox-generation/SKILL.md | 324 +++++++++++++++ .../skills/coldbox-testing-coverage/SKILL.md | 246 +++++++++++ .../skills/coldbox-testing-fixtures/SKILL.md | 364 ++++++++++++++++ .agents/skills/testbox-assertions/SKILL.md | 304 ++++++++++++++ .agents/skills/testbox-bdd/SKILL.md | 358 ++++++++++++++++ .agents/skills/testbox-cbmockdata/SKILL.md | 360 ++++++++++++++++ .agents/skills/testbox-expectations/SKILL.md | 332 +++++++++++++++ .agents/skills/testbox-listeners/SKILL.md | 298 +++++++++++++ .agents/skills/testbox-mockbox/SKILL.md | 312 ++++++++++++++ .agents/skills/testbox-reporters/SKILL.md | 343 +++++++++++++++ .agents/skills/testbox-runners/SKILL.md | 393 ++++++++++++++++++ .agents/skills/testbox-unit-xunit/SKILL.md | 318 ++++++++++++++ .claude/skills/coldbox-docbox-annotations | 1 + .claude/skills/coldbox-docbox-generation | 1 + skills-lock.json | 78 ++++ 16 files changed, 4310 insertions(+) create mode 100644 .agents/skills/coldbox-docbox-annotations/SKILL.md create mode 100644 .agents/skills/coldbox-docbox-generation/SKILL.md create mode 100644 .agents/skills/coldbox-testing-coverage/SKILL.md create mode 100644 .agents/skills/coldbox-testing-fixtures/SKILL.md create mode 100644 .agents/skills/testbox-assertions/SKILL.md create mode 100644 .agents/skills/testbox-bdd/SKILL.md create mode 100644 .agents/skills/testbox-cbmockdata/SKILL.md create mode 100644 .agents/skills/testbox-expectations/SKILL.md create mode 100644 .agents/skills/testbox-listeners/SKILL.md create mode 100644 .agents/skills/testbox-mockbox/SKILL.md create mode 100644 .agents/skills/testbox-reporters/SKILL.md create mode 100644 .agents/skills/testbox-runners/SKILL.md create mode 100644 .agents/skills/testbox-unit-xunit/SKILL.md create mode 120000 .claude/skills/coldbox-docbox-annotations create mode 120000 .claude/skills/coldbox-docbox-generation diff --git a/.agents/skills/coldbox-docbox-annotations/SKILL.md b/.agents/skills/coldbox-docbox-annotations/SKILL.md new file mode 100644 index 0000000..27f2f2f --- /dev/null +++ b/.agents/skills/coldbox-docbox-annotations/SKILL.md @@ -0,0 +1,278 @@ +--- +name: coldbox-docbox-annotations +description: "Use this skill when writing JavaDoc-style DocBox comments on BoxLang or CFML classes, properties, functions, and arguments; adding @author/@version/@since/@return/@throws/@deprecated block tags; using @doc.type for generic array/struct types; or preparing source code for API documentation generation with DocBox." +--- + +# DocBox Annotations + +## When to Use This Skill + +Use this skill when: +- Adding documentation comments to BoxLang or CFML components +- Annotating functions with parameter and return type documentation +- Marking deprecated APIs or specifying generic types +- Preparing source code so DocBox can generate API docs + +## Language Mode Reference + +Examples use **BoxLang (`.bx`)** syntax by default. Adapt for your target language: + +| Concept | BoxLang (`.bx`) | CFML (`.cfc`) | +|---------|-----------------|---------------| +| Class declaration | `class [extends="..."] {` | `component [extends="..."] {` | +| DI annotation | `@inject` above `property name="svc";` | `property name="svc" inject="svc";` | +| View templates | `.bxm` suffix | `.cfm` / `.cfml` suffix | +| Tag prefix | ``, ``, `` | ``, ``, `` | + +> **CFML Compat Mode**: With BoxLang + CFML Compat module, `.bx` and `.cfc` files coexist freely. BoxLang-native classes use `class {}` (`.bx` files); CFML-compat classes use `component {}` (`.cfc` files). + +## Comment Block Syntax + +DocBox parses `/** ... */` JavaDoc-style comments placed immediately above the element being documented. + +```js +/** + * Short description (first line/sentence is used as the summary). + * + * Longer description can span multiple lines and supports + * basic HTML for formatting when rendered in HTML output. + * + * @author Jane Smith + * @version 2.0 + * @since 1.0 + */ +``` + +## Annotating a Class / Component + +### BoxLang + +```js +/** + * Manages user authentication and session lifecycle. + * + * @author Luis Majano + * @version 1.5 + * @since 1.0 + */ +class extends="coldbox.system.EventHandler" { + // ... +} +``` + +### CFML + +```cfscript +/** + * Manages user authentication and session lifecycle. + * + * @author Luis Majano + * @version 1.5 + * @since 1.0 + */ +component extends="coldbox.system.EventHandler" { + // ... +} +``` + +## Annotating Properties + +Place the comment block directly above the property declaration. + +```js +/** + * The WireBox injector instance. + */ +property name="wirebox" inject="wirebox"; + +/** + * The underlying cache provider. + * @deprecated Use the new CacheBox provider instead + */ +property name="legacyCacheProvider" type="any"; + +/** + * List of allowed HTTP methods for this handler. + */ +property name="allowedMethods" type="array" default="[]"; +``` + +## Annotating Functions + +Place the comment block immediately before the function signature. Arguments are documented with `@argName` tags; the return value with `@return`; exceptions with `@throws`. + +```js +/** + * Creates and persists a new user account. + * + * @email The user's email address — must be unique + * @password Plain-text password; will be hashed before storage + * @displayName Optional display name shown in the UI + * @return Populated User entity after persistence + * @throws InvalidEmail When the email format is not valid + * @throws DuplicateEmail When the email already exists + */ +User function createUser( + required string email, + required string password, + string displayName = "" +) { ... } +``` + +### Argument Sub-Annotations (Dot Notation) + +Use dot notation to add metadata to a specific argument without affecting others. + +```js +/** + * Fetches a record by its primary key. + * + * @id The primary key value + * @id.since 2.0 + * @format Output format: "entity" | "struct" | "json" + * @format.deprecated Use the outputFormat argument instead + * @outputFormat New preferred argument: "entity" | "struct" | "json" + */ +any function findById( required numeric id, string format = "entity", string outputFormat = "entity" ) { ... } +``` + +## Core Block Tags Reference + +| Tag | Applies To | Purpose | +|-----|-----------|---------| +| `@author` | Class | Author name and optional contact | +| `@version` | Class | Current version number | +| `@since` | Class / Function | First version this element appeared | +| `@return` | Function | Description of the return value | +| `@throws` | Function | Exception types the function can throw (one per exception) | +| `@deprecated` | Any | Mark element as deprecated; add migration hint | +| `@{argName}` | Function | Document a specific argument | +| `@{argName}.{attr}` | Function | Sub-annotation for an argument (e.g., `.deprecated`, `.since`) | + +## Using `@doc.type` for Generic Types + +BoxLang/CFML has no generics, but `@doc.type` tells DocBox what kind of values an `array`, `struct`, or `any` typed element actually contains: + +```js +/** + * Returns all active orders for a customer. + * + * @return An array of Order model instances + * @doc.return.type Order + */ +array function getOrders() { ... } + +/** + * Map of role names to permission arrays. + * + * @doc.type struct> + */ +property name="rolePermissions" type="struct"; +``` + +## Inline HTML in Descriptions + +DocBox renders descriptions as HTML, so you can use basic markup: + +```js +/** + * Processes the event request. + * + *

This is the primary entry point called by the ColdBox framework + * for every HTTP request routed to this handler.

+ * + *
    + *
  • Validates the incoming request
  • + *
  • Delegates to the appropriate service
  • + *
  • Prepares view data
  • + *
+ * + * @event The RequestContext (rc/prc scope container) + */ +void function index( event, rc, prc ) { ... } +``` + +## Complete ColdBox Handler Example + +```js +/** + * Handles all user-related HTTP endpoints. + * + * Supports CRUD for the User entity, plus authentication + * and profile management operations. + * + * @author Dev Team + * @version 3.0 + * @since 1.0 + */ +class extends="coldbox.system.EventHandler" { + + /** + * @inject + */ + property name="userService" inject="UserService"; + + /** + * List all active users. Supports pagination via `page` and `pageSize` RC params. + * + * @event RequestContext + * @rc Request Collection + * @prc Private Request Collection + * @return void + */ + function index( event, rc, prc ) { + prc.users = userService.list( + page : rc.page ?: 1, + pageSize : rc.pageSize ?: 25 + ) + event.setView( "users/index" ) + } + + /** + * Display a single user profile. + * + * @event RequestContext + * @rc Request Collection - expects `rc.userId` + * @prc Private Request Collection + * @throws EntityNotFound When the userId does not match any user + */ + function show( event, rc, prc ) { + prc.user = userService.findOrFail( rc.userId ) + event.setView( "users/show" ) + } + + /** + * Create a new user account from posted form data. + * + * @event RequestContext + * @rc Request Collection - expects `rc.email`, `rc.password` + * @prc Private Request Collection + * @return void + */ + function create( event, rc, prc ) { + prc.user = userService.create( rc ) + relocate( "users.show", { userId: prc.user.getId() } ) + } + + /** + * Delete a user by ID. + * + * @event RequestContext + * @rc Request Collection - expects `rc.userId` + * @prc Private Request Collection + * @since 2.0 + * @deprecated Admin deletion moved to users.admin.delete + */ + function delete( event, rc, prc ) { ... } +} +``` + +## Common Mistakes to Avoid + +| Mistake | Correct Pattern | +|---------|----------------| +| `/* ... */` (single star) | `/** ... */` (double star — DocBox only parses `/**`) | +| Skipping `@return` on non-void functions | Always document the return type and what it contains | +| Using `@param` (Javadoc Java style) | Use `@argName` matching the actual argument name | +| Placing comment after the declaration | Comment must be **immediately above** the declaration | +| Empty comment blocks `/** */` | Add at least a one-sentence description | diff --git a/.agents/skills/coldbox-docbox-generation/SKILL.md b/.agents/skills/coldbox-docbox-generation/SKILL.md new file mode 100644 index 0000000..fc5e998 --- /dev/null +++ b/.agents/skills/coldbox-docbox-generation/SKILL.md @@ -0,0 +1,324 @@ +--- +name: coldbox-docbox-generation +description: "Use this skill when configuring DocBox to generate API documentation, choosing output strategies (HTML, JSON, UML, CommandBox), setting up single or multiple source directories, running DocBox from BoxLang CLI or CommandBox CLI, customizing HTML themes, excluding files/folders from output, or building custom output strategies." +--- + +# DocBox Documentation Generation + +## When to Use This Skill + +Use this skill when: +- Setting up DocBox to generate API docs for a BoxLang or CFML project +- Choosing and configuring HTML, JSON, UML, or CommandBox output strategies +- Running DocBox via code, CLI, or a build task +- Excluding test/build paths from generated docs +- Creating a custom output strategy + +## Language Mode Reference + +Examples use **BoxLang (`.bx`)** syntax by default. Adapt for your target language: + +| Concept | BoxLang (`.bx`) | CFML (`.cfc`) | +|---------|-----------------|---------------| +| Class declaration | `class [extends="..."] {` | `component [extends="..."] {` | +| DI annotation | `@inject` above `property name="svc";` | `property name="svc" inject="svc";` | +| View templates | `.bxm` suffix | `.cfm` / `.cfml` suffix | +| Tag prefix | ``, ``, `` | ``, ``, `` | + +> **CFML Compat Mode**: With BoxLang + CFML Compat module, `.bx` and `.cfc` files coexist freely. BoxLang-native classes use `class {}` (`.bx` files); CFML-compat classes use `component {}` (`.cfc` files). + +## Installation + +```bash +# BoxLang (recommended) +box install bx-docbox + +# CommandBox module (for CLI usage) +box install commandbox-docbox + +# CFML project dev dependency +box install docbox --saveDev +``` + +## Core API + +``` +DocBox.init( [strategy], [properties] ) → DocBox +DocBox.addStrategy( strategy, [properties] ) → DocBox (chainable) +DocBox.generate( source, mapping, [excludes], [throwOnError] ) → DocBox +DocBox.getStrategies() → array +``` + +## Basic Usage + +### Single HTML Strategy + +```js +new docbox.DocBox() + .addStrategy( "HTML", { + projectTitle : "My App API", + projectDescription : "Public API reference for My App", + outputDir : expandPath( "/docs/html" ), + theme : "default" // or "frames" + } ) + .generate( + source : expandPath( "/app" ), + mapping : "app", + excludes: "(tests|specs|build|\.git)" + ) +``` + +### Multiple Strategies in One Pass + +```js +new docbox.DocBox() + .addStrategy( "HTML", { + projectTitle : "My App API", + outputDir : expandPath( "/docs/html" ), + theme : "default" + } ) + .addStrategy( "JSON", { + projectTitle : "My App API", + outputDir : expandPath( "/docs/json" ) + } ) + .addStrategy( "UML", { + projectFile : expandPath( "/docs/uml/myapp.uml" ) + } ) + .generate( + source : expandPath( "/app" ), + mapping : "app", + excludes: "(tests|build)" + ) +``` + +### Multiple Source Directories + +```js +new docbox.DocBox() + .addStrategy( "HTML", { + projectTitle : "My App API", + outputDir : expandPath( "/docs" ) + } ) + .generate( + source : [ + { dir: expandPath( "/app/models" ), mapping: "app.models" }, + { dir: expandPath( "/app/services" ), mapping: "app.services" }, + { dir: expandPath( "/app/handlers" ), mapping: "app.handlers" } + ], + excludes: "(tests|specs)" + ) +``` + +## Output Strategies + +### HTML Strategy + +The most common output. Produces navigable HTML documentation. + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `outputDir` | string | ✅ | — | Absolute path to the output directory | +| `projectTitle` | string | No | "Untitled" | Browser title and page heading | +| `projectDescription` | string | No | "" | Subtitle/description displayed in header | +| `theme` | string | No | "default" | `"default"` or `"frames"` | + +**Default theme** — Modern Alpine.js single-page app: +- Dark/light mode toggle +- Real-time method search (Enter / Shift+Enter) +- Method tabs: All / Public / Private / Static / Abstract +- Bootstrap 5, fully responsive + +**Frames theme** — Classic 3-panel frameset layout: +- Package navigation sidebar (left frame) +- Member list frame (top right) +- Detail frame (bottom right) + +```js +// Default (SPA) +.addStrategy( "HTML", { + outputDir : expandPath( "/docs" ), + theme : "default" +} ) + +// Classic frames +.addStrategy( "HTML", { + outputDir : expandPath( "/docs" ), + theme : "frames" +} ) +``` + +### JSON Strategy + +Generates machine-readable JSON files for tooling integration. + +Output files: +- `overview-summary.json` — index of all packages and classes +- `{package}/package-summary.json` — all classes in a package +- `{package}/{ClassName}.json` — full documentation for one class + +```js +.addStrategy( "JSON", { + projectTitle : "My API", + outputDir : expandPath( "/docs/json" ) +} ) +``` + +### UML Strategy + +Generates an Eclipse UML2Tools-compatible `.uml` XML file. + +> **Note:** UML2Tools is no longer actively developed. This strategy is primarily for legacy tooling. + +```js +.addStrategy( "UML", { + projectFile : expandPath( "/docs/myapp.uml" ) +} ) +``` + +### CommandBox Strategy + +Specialized HTML output for CommandBox CLI modules — uses "namespace" / "command" terminology instead of "package" / "class". + +```js +new docbox.DocBox() + .addStrategy( "CommandBox", { + projectTitle : "My CommandBox Module", + outputDir : expandPath( "/docs/commands" ) + } ) + .generate( + source : expandPath( "/modules/my-module/commands/" ), + mapping : "my-module.commands" + ) +``` + +### Custom Strategy + +Extend `AbstractTemplateStrategy` and implement `IStrategy`: + +```js +// myapp/strategy/MyCustomStrategy.cfc +class extends="docbox.strategy.AbstractTemplateStrategy" { + + /** + * Execute documentation generation. + * @metadata Query of all component metadata + */ + IStrategy function run( required query metadata ) { + // metadata columns: package, name, metadata, type, extends, implements + for ( var row in metadata ) { + // generate your custom output for each component + } + return this + } +} +``` + +Register it by passing the instance directly: + +```js +new docbox.DocBox() + .addStrategy( new myapp.strategy.MyCustomStrategy( outputDir: expandPath("/docs/custom") ) ) + .generate( source: expandPath("/app"), mapping: "app" ) +``` + +## Run from BoxLang CLI + +```bash +# Minimal +boxlang module:docbox \ + --source=/path/to/code \ + --mapping=myapp + +# Full options +boxlang module:docbox \ + --source=/path/to/code \ + --mapping=myapp \ + --output-dir=/docs \ + --project-title="My API" \ + --theme=default \ + --excludes="(tests|build|\.git)" + +# Multiple sources (comma-separated) +boxlang module:docbox \ + --source="/app/models,/app/services" \ + --mapping=app \ + --output-dir=/docs +``` + +## Run from CommandBox + +```bash +# Install the CommandBox module once +box install commandbox-docbox + +# Basic run +box docbox generate source=/app mapping=app outputDir=/docs + +# With options +box docbox generate \ + source=/app \ + mapping=app \ + outputDir=/docs \ + projectTitle="My API" \ + excludes="(tests|build)" +``` + +## ColdBox Build Task Example + +```js +// build/GenerateDocs.cfc +class { + + function run() { + + print.line( "📚 Generating API documentation..." ) + + new docbox.DocBox() + .addStrategy( "HTML", { + projectTitle : "My ColdBox App", + projectDescription : "Internal API Reference", + outputDir : expandPath( "/docs" ), + theme : "default" + } ) + .generate( + source : expandPath( "/app" ), + mapping : "app", + excludes : "(tests|specs|build|node_modules|\.git)" + ) + + print.greenLine( "✅ Docs generated at /docs" ) + } +} +``` + +## `generate()` Parameters Reference + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `source` | string \| array | ✅ | Path to source dir **or** array of `{dir, mapping}` structs | +| `mapping` | string | ✅ (when source is string) | ColdFusion/BoxLang mapping for the source root | +| `excludes` | string | No | Java regex applied to relative file paths (e.g., `"(tests\|build)"`) | +| `throwOnError` | boolean | No (`false`) | Throw on parsing errors instead of silently skipping | + +## Excludes Pattern Examples + +```js +// Exclude test and build directories +excludes: "(tests|specs|build)" + +// Exclude dot folders and generated code +excludes: "(\.git|\.github|node_modules|generated)" + +// Exclude specific packages +excludes: "(tests|mocks|stubs|util\.internal)" +``` + +## Troubleshooting + +| Symptom | Likely Cause | Fix | +|---------|-------------|-----| +| No output generated | Wrong `source` path | Use `expandPath()` for absolute paths | +| Missing classes | Class inside excluded pattern | Review `excludes` regex | +| Blank descriptions | Comment uses `/* */` not `/** */` | Use double-star comment block | +| Strategy not found | Typo in strategy alias | Use exact strings: `"HTML"`, `"JSON"`, `"UML"`, `"CommandBox"` | +| Parse errors logged | Invalid CFML/BoxLang syntax | Set `throwOnError: true` to see details | diff --git a/.agents/skills/coldbox-testing-coverage/SKILL.md b/.agents/skills/coldbox-testing-coverage/SKILL.md new file mode 100644 index 0000000..c8b35fe --- /dev/null +++ b/.agents/skills/coldbox-testing-coverage/SKILL.md @@ -0,0 +1,246 @@ +--- +name: coldbox-testing-coverage +description: "Use this skill when setting up code coverage analysis for ColdBox/ColdFusion/BoxLang applications, configuring coverage reporting, integrating coverage with CI pipelines, using TestBox coverage options, interpreting coverage metrics, or improving test coverage of untested code paths." +--- + +# Code Coverage Testing in ColdBox + +## Overview + +Code coverage measures which parts of your code execute during tests. TestBox and CommandBox integrate with coverage tools to generate reports, identify untested code, and track coverage over time. + +## Language Mode Reference + +Examples use **BoxLang (`.bx`)** syntax by default. Adapt for your target language: + +| Concept | BoxLang (`.bx`) | CFML (`.cfc`) | +|---------|-----------------|---------------| +| Class declaration | `class [extends="..."] {` | `component [extends="..."] {` | +| DI annotation | `@inject` above `property name="svc";` | `property name="svc" inject="svc";` | +| View templates | `.bxm` suffix | `.cfm` / `.cfml` suffix | +| Tag prefix | ``, ``, `` | ``, ``, `` | + +> **CFML Compat Mode**: With BoxLang + CFML Compat module, `.bx` and `.cfc` files coexist freely. BoxLang-native classes use `class {}` (`.bx` files); CFML-compat classes use `component {}` (`.cfc` files). + +## Coverage Types + +- **Line Coverage**: Percentage of code lines executed +- **Branch Coverage**: Percentage of decision branches taken (if/else paths) +- **Function Coverage**: Percentage of functions called + +**Coverage Goals:** +- **80%+** — Good for most projects +- **90%+** — Excellent for critical systems + +## TestBox Coverage Configuration + +```boxlang +// box.json — configure TestBox coverage +{ + "testbox": { + "runner": "http://localhost:8080/tests/runner.cfm", + "coverage": { + "enabled": true, + "sonarQubeXML": "coverage/sonarqube.xml", + "pathMappings": { + "/app": "#expandPath( '/' )#" + }, + "excludes": [ + "tests/**", + "node_modules/**", + "*.json" + ], + "includes": [ + "models/**", + "handlers/**", + "modules/**" + ] + } + } +} +``` + +## Running Coverage via CommandBox + +```bash +# Run tests with coverage +box testbox run --coverage + +# Generate HTML coverage report +box testbox run --coverage --reporter=HtmlCoverage + +# Coverage with specific output directory +box testbox run coverage outputDir=./coverage reporter=HtmlCoverage + +# Run and generate Cobertura XML for CI +box testbox run --reporter=Cobertura --outputFile=coverage.xml +``` + +## Coverage in TestBox Runner + +```boxlang +// tests/runner.cfm + + testBox = new testbox.system.TestBox({ + runner: "http://localhost:8080/index.cfm", + options: { + coverage: { + enabled: true, + pathMappings: { + "/app": expandPath( "/" ) + }, + excludes: [ + "tests/**", + "node_modules/**" + ] + } + } + }) + + results = testBox.run( + reporter: "HTMLCoverage", + outputFile: expandPath( "/coverage/report.html" ) + ) + +``` + +## GitHub Actions with Coverage + +```yaml +# .github/workflows/coverage.yml +name: Test Coverage + +on: + push: + branches: [ main, development ] + pull_request: + branches: [ main ] + +jobs: + coverage: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup CommandBox + uses: ortus-solutions/setup-commandbox@v2 + + - name: Install Dependencies + run: box install + + - name: Start Server + run: box server start port=8080 + + - name: Run Tests with Coverage + run: box testbox run --coverage --reporter=Cobertura --outputFile=coverage.xml + + - name: Upload to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + flags: unittests + fail_ci_if_error: true + + - name: Stop Server + if: always() + run: box server stop +``` + +## Coverage in Application.cfc (FusionReactor) + +```boxlang +// Application.cfc — FusionReactor-based coverage +component { + + function onApplicationStart() { + if ( getSetting( "environment" ) == "testing" ) { + application.coverageEnabled = true + } + } +} +``` + +## Identifying Untested Code + +```boxlang +/** + * Pattern: Identify uncovered branches systematically + */ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "Coverage-driven testing", () => { + + // Add tests for each branch to hit 100% branch coverage + describe( "processOrder status handling", () => { + + it( "should handle pending orders", () => { + result = orderService.process( { status: "pending" } ) + expect( result.action ).toBe( "queued" ) + } ) + + it( "should handle active orders", () => { + result = orderService.process( { status: "active" } ) + expect( result.action ).toBe( "processing" ) + } ) + + it( "should handle cancelled orders", () => { + result = orderService.process( { status: "cancelled" } ) + expect( result.action ).toBe( "skipped" ) + } ) + + it( "should handle unknown status", () => { + expect( () => { + orderService.process( { status: "unknown" } ) + } ).toThrow( type: "InvalidStatusException" ) + } ) + } ) + } ) + } +} +``` + +## Coverage Badge in README + +```markdown +[![Coverage Status](https://codecov.io/gh/your-org/your-repo/branch/main/graph/badge.svg)](https://codecov.io/gh/your-org/your-repo) +``` + +## Excluding Code from Coverage + +```boxlang +/** + * Mark code explicitly excluded from coverage + * (Useful for generated code, dead code paths, etc.) + */ +component { + + /** + * @covignore + */ + function legacyMethod() { + // This method is excluded from coverage reports + } +} +``` + +## Coverage Configuration Reference + +| Setting | Description | Default | +|---------|-------------|---------| +| `enabled` | Toggle coverage on/off | `false` | +| `sonarQubeXML` | Path for SonarQube XML output | — | +| `pathMappings` | Map virtual paths to disk paths | — | +| `excludes` | Glob patterns to exclude from coverage | `[]` | +| `includes` | Glob patterns to include (if set, excludes all others) | `[]` | + +## Improving Coverage Checklist + +- [ ] Identify uncovered branches in if/else statements +- [ ] Add tests for exception paths (`toThrow()` assertions) +- [ ] Test null/empty input edge cases +- [ ] Test boundary values (min/max numbers, empty strings) +- [ ] Cover all `switch/case` branches +- [ ] Add integration tests for database error paths +- [ ] Test early-return conditions diff --git a/.agents/skills/coldbox-testing-fixtures/SKILL.md b/.agents/skills/coldbox-testing-fixtures/SKILL.md new file mode 100644 index 0000000..6878708 --- /dev/null +++ b/.agents/skills/coldbox-testing-fixtures/SKILL.md @@ -0,0 +1,364 @@ +--- +name: coldbox-testing-fixtures +description: "Use this skill when creating test fixtures, factory patterns, or test data builders in ColdBox/TestBox, setting up shared fixture files, creating user/model factories with overrides, using cbMockData for realistic fake data generation, or managing test data setup and teardown." +--- + +# Testing Fixtures in ColdBox + +## Overview + +Test fixtures provide the known, fixed state needed to run tests. They include static test data, factory classes that generate dynamic data, and fixture files for shared data across tests. Good fixture management keeps tests isolated, readable, and maintainable. + +## Language Mode Reference + +Examples use **BoxLang (`.bx`)** syntax by default. Adapt for your target language: + +| Concept | BoxLang (`.bx`) | CFML (`.cfc`) | +|---------|-----------------|---------------| +| Class declaration | `class [extends="..."] {` | `component [extends="..."] {` | +| DI annotation | `@inject` above `property name="svc";` | `property name="svc" inject="svc";` | +| View templates | `.bxm` suffix | `.cfm` / `.cfml` suffix | +| Tag prefix | ``, ``, `` | ``, ``, `` | + +> **CFML Compat Mode**: With BoxLang + CFML Compat module, `.bx` and `.cfc` files coexist freely. BoxLang-native classes use `class {}` (`.bx` files); CFML-compat classes use `component {}` (`.cfc` files). + +## Static Inline Fixtures + +```boxlang +describe( "User validation", () => { + + it( "should validate valid user data", () => { + validUser = { + name: "John Doe", + email: "john@example.com", + age: 30, + active: true + } + + result = userValidator.validate( validUser ) + expect( result.isValid ).toBeTrue() + } ) + + it( "should reject invalid email", () => { + invalidUser = { + name: "John Doe", + email: "not-an-email", + age: 30 + } + + result = userValidator.validate( invalidUser ) + expect( result.isValid ).toBeFalse() + expect( result.errors ).toHaveKey( "email" ) + } ) +} ) +``` + +## Shared Fixture Variables + +```boxlang +describe( "User operations", () => { + + // Define shared test data at describe scope + variables.validUserData = { + name: "John Doe", + email: "john@example.com", + password: "SecurePass123!", + age: 30 + } + + beforeEach( () => { + // Create a fresh copy for each test to prevent mutation + variables.testUser = duplicate( validUserData ) + } ) + + it( "should create user with valid data", () => { + user = userService.create( testUser ) + expect( user.id ).toBeNumeric() + } ) + + it( "should update user name", () => { + user = userService.create( testUser ) + testUser.name = "Jane Doe" + + updated = userService.update( user.id, testUser ) + expect( updated.name ).toBe( "Jane Doe" ) + } ) +} ) +``` + +## Fixture File Pattern + +```boxlang +/** + * tests/fixtures/UserFixtures.cfc + * Centralized test data for user-related tests + */ +component { + + function getValidUser() { + return { + name: "John Doe", + email: "john@example.com", + password: "SecurePass123!", + age: 30, + active: true + } + } + + function getAdminUser() { + return getValidUser().append( { + email: "admin@example.com", + role: "admin", + permissions: [ "read", "write", "delete" ] + }, true ) + } + + function getInvalidUsers() { + return [ + { name: "", email: "john@example.com", _errorField: "name" }, + { name: "John", email: "invalid-email", _errorField: "email" }, + { name: "John", email: "john@example.com", age: -5, _errorField: "age" } + ] + } + + function getUsers( required numeric count = 5 ) { + users = [] + + for ( i = 1; i <= count; i++ ) { + users.append( { + name: "User ##i##", + email: "user##i##@example.com", + age: 20 + i + } ) + } + + return users + } +} +``` + +Using fixture file: + +```boxlang +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "User service with fixture file", () => { + + beforeAll( () => { + variables.fixtures = new tests.fixtures.UserFixtures() + variables.userService = getInstance( "UserService" ) + } ) + + it( "should create valid user", () => { + userData = fixtures.getValidUser() + user = userService.create( userData ) + + expect( user.id ).toBeNumeric() + expect( user.name ).toBe( userData.name ) + } ) + + it( "should reject all invalid formats", () => { + for ( invalidUser in fixtures.getInvalidUsers() ) { + result = userService.validate( invalidUser ) + expect( result.isValid ).toBeFalse() + expect( result.errors ).toHaveKey( invalidUser._errorField ) + } + } ) + } ) + } +} +``` + +## Factory Pattern + +```boxlang +/** + * tests/factories/UserFactory.cfc + * Dynamic test data factory with sequence counter + */ +component singleton { + + variables.counter = 0 + + function create( struct overrides = {} ) { + variables.counter++ + + defaults = { + name: "Test User ##counter##", + email: "testuser##counter##@example.com", + password: "Password##counter##!", + age: 20 + counter, + active: true, + createdDate: now() + } + + return structAppend( defaults, overrides, true ) + } + + function createMany( required numeric count, struct overrides = {} ) { + return listToArray( repeatString( ",", count - 1 ) ) + .map( () => create( overrides ) ) + } + + function createAdmin( struct overrides = {} ) { + return create( { + role: "admin", + permissions: [ "read", "write", "delete" ] + }.append( overrides, true ) ) + } + + function createInactive( struct overrides = {} ) { + return create( { active: false }.append( overrides, true ) ) + } + + function reset() { + variables.counter = 0 + } +} +``` + +Using factory: + +```boxlang +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "With factory", () => { + + beforeAll( () => { + variables.factory = new tests.factories.UserFactory() + variables.userService = getInstance( "UserService" ) + } ) + + beforeEach( () => { + factory.reset() + } ) + + it( "should process multiple users", () => { + // Create 3 test users + users = factory.createMany( 3 ) + .map( u => userService.create( u ) ) + + expect( users ).toHaveLength( 3 ) + expect( users[1].name ).toBe( "Test User 1" ) + } ) + + it( "should grant admin privileges", () => { + admin = factory.createAdmin( { email: "customadmin@test.com" } ) + user = userService.create( admin ) + + expect( user.role ).toBe( "admin" ) + } ) + } ) + } +} +``` + +## Using cbMockData for Realistic Fake Data + +> For the full cbMockData type reference, see the `testbox-cbmockdata` skill. + +**WireBox ID**: `MockData@cbMockData` + +cbMockData is bundled with TestBox — no separate install required. + +```boxlang +component extends="testbox.system.BaseSpec" { + + property name="mockData" inject="MockData@cbMockData" + + function run() { + describe( "With cbMockData", () => { + + it( "should generate a realistic user", () => { + var userData = mockData.mock( + $returnType: "struct", + firstName: "fname", + lastName: "lname", + email: "email", + age: "age", + bio: "sentence" + ) + + expect( userData.firstName ).toBeString() + expect( userData.email ).toMatch( ".+@.+" ) + expect( userData.age ).toBeNumeric() + } ) + + it( "should generate a collection of users", () => { + var users = mockData.mock( + $num: 10, + id: "autoincrement", + name: "name", + email: "email", + status: "oneof:active:inactive:pending" + ) + + expect( users ).toHaveLength( 10 ) + expect( [ "active", "inactive", "pending" ] ).toInclude( users[ 1 ].status ) + } ) + + it( "should generate nested objects", () => { + var order = mockData.mock( + $returnType: "struct", + id: "autoincrement", + customerId: "uuid", + total: "rnd:50:500", + items: { + $num: "rnd:1:5", + sku: "uuid", + name: "words", + price: "rnd:5:100" + } + ) + + expect( order ).toHaveKey( "items" ) + expect( order.items ).toBeArray() + } ) + + } ) + } +} +``` + +## Database Seeder Pattern + +```boxlang +/** + * tests/fixtures/DatabaseSeeder.cfc + */ +component singleton { + + function seedUsers( required numeric count = 5 ) { + factory = new tests.factories.UserFactory() + users = [] + + for ( i = 1; i <= count; i++ ) { + data = factory.create() + qry = queryExecute( + "INSERT INTO users (name, email) VALUES (:name, :email)", + { name: data.name, email: data.email }, + { datasource: "testdb" } + ) + data.id = qry.generatedKey + users.append( data ) + } + + return users + } + + function cleanup() { + queryExecute( "DELETE FROM users WHERE email LIKE 'testuser%@example.com'", {}, { datasource: "testdb" } ) + } +} +``` + +## Best Practices + +1. **Always `duplicate()`** shared fixture data before mutating in tests to prevent cross-test contamination +2. **Use factories** for dynamic, unique data — avoids duplicate key violations +3. **Use fixture files** for complex, reusable test data sets +4. **Keep fixtures minimal** — only include fields the test actually needs +5. **Reset factory counters** in `beforeEach` for predictable sequences +6. **Namespace test emails** — use `testuser@example.com` pattern for easy cleanup diff --git a/.agents/skills/testbox-assertions/SKILL.md b/.agents/skills/testbox-assertions/SKILL.md new file mode 100644 index 0000000..b9f6924 --- /dev/null +++ b/.agents/skills/testbox-assertions/SKILL.md @@ -0,0 +1,304 @@ +--- +name: testbox-assertions +description: "Use this skill when using the TestBox $assert object for xUnit-style assertions: isTrue, isEqual, includes, isEmpty, key, instanceOf, throws, between, closeTo, lengthOf, match, null, typeOf, and others; registering custom assertion functions with addAssertions(); or using BoxLang dynamic assertion methods (assertIsTrue, assertBetween, etc.)." +--- + +# TestBox Assertions — `$assert` Reference + +## When to Use This Skill + +- Writing xUnit-style assertions using the `$assert` object in test functions +- Using the full `testbox.system.Assertion` API for validation +- Registering inline or class-based custom assertions with `addAssertions()` +- Using BoxLang dynamic `assertXxx()` method variants + +--- + +## The `$assert` Object + +Every TestBox bundle receives `$assert` — an instance of `testbox.system.Assertion`. It is available in both xUnit test functions and BDD `it()` blocks. + +```boxlang +function testUserCreation() { + var user = userService.create( { name: "Alice", email: "alice@example.com" } ) + $assert.isNotNull( user ) + $assert.isEqual( "Alice", user.name ) + $assert.typeOf( "struct", user ) +} +``` + +--- + +## Complete Assertion Reference + +### Boolean + +```boxlang +$assert.isTrue( actual, [message] ) +$assert.isFalse( actual, [message] ) +``` + +### Equality + +```boxlang +$assert.isEqual( expected, actual, [message] ) // case-insensitive +$assert.isEqualWithCase( expected, actual, [message] ) +$assert.isNotEqual( expected, actual, [message] ) +``` + +### Null + +```boxlang +$assert.null( actual, [message] ) +$assert.notNull( actual, [message] ) +``` + +### Emptiness + +```boxlang +$assert.isEmpty( target, [message] ) // array, struct, string, query +$assert.isNotEmpty( target, [message] ) +``` + +### Size / Length + +```boxlang +$assert.lengthOf( target, length, [message] ) +$assert.notLengthOf( target, length, [message] ) +``` + +### Key Existence (Structs) + +```boxlang +$assert.key( target, key, [message] ) +$assert.notKey( target, key, [message] ) +$assert.deepKey( target, key, [message] ) // recursive search +$assert.notDeepKey( target, key, [message] ) +``` + +### Inclusion (Strings / Arrays) + +```boxlang +$assert.includes( target, needle, [message] ) // case-insensitive +$assert.includesWithCase( target, needle, [message] ) +$assert.notIncludes( target, needle, [message] ) +$assert.notIncludesWithCase( target, needle, [message] ) +``` + +### Type Checks + +```boxlang +$assert.typeOf( type, actual, [message] ) // uses isValid() under the hood +$assert.notTypeOf( type, actual, [message] ) +$assert.instanceOf( actual, typeName, [message] ) +$assert.notInstanceOf( actual, typeName, [message] ) +``` + +Common `type` values: `array`, `struct`, `component`, `numeric`, `boolean`, `date`, `string`, `uuid`, `email`, `url`, `query` + +### Numeric Comparisons + +```boxlang +$assert.isGT( actual, target, [message] ) +$assert.isGTE( actual, target, [message] ) +$assert.isLT( actual, target, [message] ) +$assert.isLTE( actual, target, [message] ) +$assert.between( actual, min, max, [message] ) +$assert.closeTo( expected, actual, delta, [datePart], [message] ) +``` + +### String / Regex + +```boxlang +$assert.match( actual, regex, [message] ) // case-insensitive +$assert.matchWithCase( actual, regex, [message] ) +$assert.notMatch( actual, regex, [message] ) +``` + +### Exceptions + +```boxlang +// Assert a closure THROWS +$assert.throws( target, [type], [regex], [message] ) + +// target must be a closure/function reference +$assert.throws( () => service.delete( 999 ), "NotFoundException" ) +$assert.throws( () => service.delete( 999 ), "NotFoundException", "999" ) + +// Assert a closure DOES NOT throw +$assert.notThrows( target, [type], [regex], [message] ) +$assert.notThrows( () => service.safeOperation() ) +``` + +### Force Pass/Fail/Skip + +```boxlang +$assert.fail( [message] ) // forcibly fail the test +$assert.skip( message, [detail] ) // skip current test with a message +``` + +### Generic Expression Assert + +```boxlang +$assert.assert( expression, [message] ) +// example: +$assert.assert( user.age >= 18, "User must be an adult" ) +``` + +--- + +## BoxLang Dynamic Assertion Methods + +When running tests in BoxLang, you can call any `$assert` method as a top-level function by prefixing it with `assert`: + +```boxlang +// Standard $assert style +$assert.isTrue( myBool ) +$assert.isEqual( expected, actual ) +$assert.between( value, 1, 100 ) + +// BoxLang dynamic method style (identical behaviour) +assertIsTrue( myBool ) +assertIsEqual( expected, actual ) +assertBetween( value, 1, 100 ) +assertThrows( () => badCall(), "MyException" ) +assertCloseTo( 3.14, 3.14159, 0.01 ) +``` + +--- + +## Common Assertion Patterns + +```boxlang +// Struct shape +$assert.isNotEmpty( responseData ) +$assert.key( responseData, "id" ) +$assert.key( responseData, "name" ) +$assert.typeOf( "numeric", responseData.id ) + +// Array contents +$assert.typeOf( "array", results ) +$assert.isNotEmpty( results ) +$assert.lengthOf( results, 3 ) +$assert.includes( results, "expectedValue" ) + +// Exception handling +$assert.throws( + () => userService.findById( -1 ), + "ValidationException", + "-1" +) + +// Approximate date equality +$assert.closeTo( + expected: now(), + actual: user.createdAt, + delta: 2, + datePart: "s" +) +``` + +--- + +## Custom Assertions + +### Inline Registration + +Register in `beforeTests()` (xUnit) or `beforeAll()` (BDD) to keep assertions clean across specs: + +```boxlang +function beforeTests() { + addAssertions( { + + isValidEmail: function( actual ) { + return isValid( "email", actual ) + ? true + : fail( "[#actual#] is not a valid email address" ) + }, + + isUUID: function( actual ) { + return isValid( "uuid", actual ) + ? true + : fail( "[#actual#] is not a UUID" ) + }, + + hasNoErrors: function( actual ) { + return ( actual.keyExists( "errors" ) && !actual.errors.isEmpty() ) + ? fail( "Response has errors: #serializeJSON( actual.errors )#" ) + : true + } + + } ) +} + +// Usage +function testEmailValidation() { + $assert.isValidEmail( "alice@example.com" ) // passes + $assert.isValidEmail( "not-an-email" ) // fails + $assert.isUUID( createUUID() ) +} +``` + +### Class-Based Assertion Library + +For shared, reusable assertions across multiple test bundles: + +```boxlang +// tests/helpers/AppAssertions.cfc +component { + + function assertIsActiveUser( expected, actual ) { + return ( actual.keyExists( "isActive" ) && actual.isActive ) + ? true + : fail( "User [#actual.name ?: 'unknown'#] is not active" ) + } + + function assertHasPermission( expected, actual ) { + return ( actual.permissions.findNoCase( expected ) > 0 ) + ? true + : fail( "User does not have permission [#expected#]" ) + } + + function assertResponseSuccess( expected, actual ) { + return actual.success + ? true + : fail( "Response was not successful. Message: #actual.message ?: ''#" ) + } + +} +``` + +```boxlang +function beforeTests() { + // Single class + addAssertions( "tests.helpers.AppAssertions" ) + + // Multiple classes + addAssertions( "tests.helpers.AppAssertions,tests.helpers.SecurityAssertions" ) + // or array: + addAssertions( [ "tests.helpers.AppAssertions", "tests.helpers.SecurityAssertions" ] ) +} + +function testAdminUser() { + var admin = userService.findById( 1 ) + $assert.isActiveUser( admin ) + $assert.hasPermission( "ADMIN", admin ) +} +``` + +--- + +## `$assert` vs `expect()` Guide + +Use `$assert` when: +- Writing xUnit-style test functions +- You prefer imperative `isEqual( a, b )` syntax +- You need `assert( expression )` for arbitrary boolean checks + +Use `expect()` when: +- Writing BDD specs +- You want fluent chaining: `expect( x ).toBe( y ).toHaveKey( "z" )` +- You want `expectAll()` for collection assertions +- You want custom matchers with `not` negation + +Both are available in the same bundle — mix freely. diff --git a/.agents/skills/testbox-bdd/SKILL.md b/.agents/skills/testbox-bdd/SKILL.md new file mode 100644 index 0000000..1ecc386 --- /dev/null +++ b/.agents/skills/testbox-bdd/SKILL.md @@ -0,0 +1,358 @@ +--- +name: testbox-bdd +description: "Use this skill when writing BDD-style tests with TestBox using describe/it blocks, feature/story/scenario/given/when/then Gherkin-style suites, lifecycle hooks (beforeAll/afterAll/beforeEach/afterEach/aroundEach), focused specs (fit/fdescribe), skipping specs (xit/xdescribe/skip()), spec data binding, asyncAll parallel specs, nested suite trees, labels, or organizing tests around behavior descriptions." +--- + +# BDD Testing with TestBox + +## When to Use This Skill + +- Writing BDD-style test bundles for any ColdBox/BoxLang application +- Using `describe()`, `it()`, or Gherkin aliases (`feature/story/scenario/given/when/then`) +- Setting up lifecycle methods: `beforeAll`, `afterAll`, `beforeEach`, `afterEach`, `aroundEach` +- Focusing or skipping suites and specs +- Passing data into specs via data binding +- Running specs in parallel with `asyncAll` + +--- + +## Language Reference + +| Concept | BoxLang (`.bx`) preferred | CFML (`.cfc`) compatible | +|---|---|---| +| Class declaration | `class extends="testbox.system.BaseSpec" {}` | `component extends="testbox.system.BaseSpec" {}` | +| Arrow closures | `() => {}` | `function() {}` | +| Lambda with args | `( currentSpec, data ) => {}` | `function( currentSpec, data ) {}` | + +--- + +## Canonical Bundle Structure + +```boxlang +class extends="testbox.system.BaseSpec" { + + // Global lifecycle — runs ONCE for the entire bundle + function beforeAll() { + variables.sut = getInstance( "models.UserService" ) + } + + function afterAll() { + // global teardown + } + + function run( testResults, testBox ) { + + describe( "UserService", () => { + + // Suite lifecycle — runs around each it() + beforeEach( ( currentSpec ) => { + variables.mockRepo = createMock( "models.UserRepository" ) + variables.sut.$property( propertyName = "userRepository", mock = mockRepo ) + } ) + + afterEach( ( currentSpec ) => { + // per-spec teardown + } ) + + it( "creates a user and returns the saved entity", () => { + mockRepo.$( "save" ).$results( { id: 1, name: "Alice" } ) + var result = sut.create( { name: "Alice", email: "alice@example.com" } ) + expect( result ).toBeStruct() + expect( result.id ).toBeNumeric() + expect( result.name ).toBe( "Alice" ) + } ) + + it( "throws ValidationException for missing email", () => { + expect( () => { + sut.create( { name: "Alice" } ) + } ).toThrow( type: "ValidationException" ) + } ) + + } ) + + } + +} +``` + +--- + +## Lifecycle Methods + +### Global (`beforeAll` / `afterAll`) + +Run **once** for the entire bundle — use for expensive one-time setup (boot DI container, seed DB, create JWT keys, etc.). + +```boxlang +function beforeAll() { + ORMSessionClear() + structClear( request ) + variables.jwt = getInstance( "JWTService@cbsecurity" ) + variables.jwt.getSettings().jwt.tokenStorage.driver = "cachebox" + variables.securityService = getInstance( "SecurityService" ) +} + +function afterAll() { + variables.securityService.logout() + directoryDelete( "/tests/tmp", true ) +} +``` + +### Suite (`beforeEach` / `afterEach`) + +Run around **every** `it()` in the containing `describe()`. In nested suites, TestBox walks **down** the tree for `beforeEach` and **up** for `afterEach`. + +```boxlang +describe( "Outer", () => { + + beforeEach( ( currentSpec, data ) => { + // runs first for all nested specs + } ) + + describe( "Inner", () => { + + beforeEach( ( currentSpec, data ) => { + // runs second (after outer beforeEach) + } ) + + afterEach( ( currentSpec, data ) => { + // runs first on the way out (before outer afterEach) + } ) + + } ) + + afterEach( ( currentSpec, data ) => { + // runs last for all nested specs + } ) + +} ) +``` + +### `aroundEach` + +Wraps each spec — useful for transaction rollbacks or resource acquisition/release patterns. + +```boxlang +aroundEach( ( spec, suite ) => { + transaction { + spec.body() // run the spec inside the transaction + transactionRollback() + } +} ) +``` + +--- + +## Suite Aliases (Gherkin) + +`describe()` is aliased as: `story()`, `feature()`, `scenario()`, `given()`, `when()`. +`it()` is aliased as `then()` (but `then` uses the argument name `then` instead of `title`). + +```boxlang +feature( "Box Volume Calculator", () => { + + scenario( "Calculate box volume", () => { + + given( "a width of 20, height of 30, depth of 40", () => { + + when( "I run the calculation", () => { + + then( "the result is 24000", () => { + expect( boxCalc.volume( 20, 30, 40 ) ).toBe( 24000 ) + } ) + + } ) + + } ) + + } ) + +} ) +``` + +```boxlang +story( "As an author, I want to list all active users", () => { + + given( "no options", () => { + then( "it returns all active system users", () => { + var event = this.get( "/cbapi/v1/authors" ) + expect( event.getResponse() ).toHaveStatus( 200 ) + expect( event.getResponse().getData() ).toBeArray().notToBeEmpty() + } ) + } ) + + given( "isActive = false", () => { + then( "it returns inactive users", () => { + var event = this.get( "/cbapi/v1/authors?isActive=false" ) + expect( event.getResponse() ).toHaveStatus( 200 ) + expect( event.getResponse().getData() ).toBeArray().notToBeEmpty() + } ) + } ) + +} ) +``` + +--- + +## Focused Specs and Suites + +Prefix with `f` to run **only** those suites/specs. All others are skipped. + +```boxlang +fdescribe( "I only run", () => { + fit( "I am the only spec that runs", () => { + expect( true ).toBeTrue() + } ) + it( "I am skipped because parent fdescribe runs all children", () => { + // still runs — parent focus runs all children + } ) +} ) + +describe( "Normal suite", () => { + fit( "focused individual spec", () => { + expect( 1 ).toBe( 1 ) + } ) + it( "this one is skipped", () => { } ) +} ) +``` + +> Focused prefixes work on all suite aliases: `fdescribe`, `fstory`, `ffeature`, `fscenario`, `fgiven`, `fwhen`, `fit`, `fthen`. + +--- + +## Skipping Specs and Suites + +Prefix with `x` or use the `skip` argument or call `skip()` inline. + +```boxlang +xdescribe( "disabled suite", () => { + it( "never runs", () => { } ) +} ) + +xit( "disabled spec", () => { } ) + +it( "engine-conditional skip", () => { + if ( !server.keyExists( "lucee" ) ) { + skip( "Only runs on Lucee" ) + } + expect( luceeSpecificBehavior() ).toBeTrue() +} ) + +it( title: "conditional skip closure", body: () => { + expect( 1 ).toBe( 1 ) +}, skip: () => { + return !structKeyExists( server, "railo" ) +} ) +``` + +--- + +## Spec Data Binding + +Pass a `data` struct into `it()` to avoid closure-capture bugs in loops. + +```boxlang +var filePaths = directoryList( "/tests/fixtures", false, "path", "*.json" ) + +for ( var filePath in filePaths ) { + it( + title: "#getFileFromPath( filePath )# is valid JSON", + data: { filePath: filePath }, + body: ( data ) => { + var json = fileRead( data.filePath ) + expect( json ).notToBeEmpty() + expect( isJSON( json ) ).toBeTrue( "File is not valid JSON: #data.filePath#" ) + } + ) +} +``` + +--- + +## Nested and Labeled Suites + +```boxlang +describe( "Payment processing", { labels: "integration,payments" }, () => { + + describe( "Credit cards", { labels: "credit" }, () => { + it( "charges a card successfully", { labels: "happy-path" }, () => { + var result = paymentService.charge( fixtures.validCard, 99.99 ) + expect( result.success ).toBeTrue() + expect( result.transactionId ).notToBeEmpty() + } ) + + it( "declines an expired card", () => { + expect( () => { + paymentService.charge( fixtures.expiredCard, 99.99 ) + } ).toThrow( type: "PaymentDeclinedException" ) + } ) + } ) + + describe( "Refunds", () => { + it( "processes a full refund", () => { + var result = paymentService.refund( fixtures.completedTransaction ) + expect( result.refunded ).toBeTrue() + } ) + } ) + +} ) +``` + +Run with labels: `box testbox run labels=credit excludes=slow` + +--- + +## Parallel (asyncAll) Suites + +```boxlang +describe( "Parallel cache reads", { asyncAll: true }, () => { + + it( "reads product 1", () => { + var p = productService.getById( 1 ) + expect( p.getId() ).toBe( 1 ) + } ) + + it( "reads product 2", () => { + var p = productService.getById( 2 ) + expect( p.getId() ).toBe( 2 ) + } ) + +} ) +``` + +**Rules for `asyncAll`:** +- All spec variables **must** be locally scoped (`var`) or in `variables` with no inter-spec mutation +- Add CFThread `name` attributes or unique keys to avoid lock collisions +- Reserve for IO-bound or concurrency-sensitive behavior; do not over-parallelize + +--- + +## Key Functions Quick Reference + +| Function | Alias(es) | Description | +|---|---|---| +| `describe( title, body, [labels], [asyncAll], [skip] )` | `story`, `feature`, `scenario`, `given`, `when` | Define a test suite | +| `it( title, body, [labels], [skip], [data] )` | `then` | Define a spec / test case | +| `beforeAll( body )` | — | Run once before all specs in the bundle | +| `afterAll( body )` | — | Run once after all specs in the bundle | +| `beforeEach( body, [data] )` | — | Run before each spec in the suite | +| `afterEach( body, [data] )` | — | Run after each spec in the suite | +| `aroundEach( body )` | — | Wrap each spec (transaction rollback pattern) | +| `expect( actual )` | — | Start a fluent expectation chain | +| `expectAll( collection )` | — | Assert every element in an array/struct | +| `skip( [message], [detail] )` | — | Skip the current spec or suite inline | +| `addMatchers( matchers )` | — | Register custom matchers | +| `getInstance( name )` | — | Shortcut for WireBox `getInstance()` | +| `debug( var, [label] )` | — | Output a variable to the debug panel | + +--- + +## CommandBox Scaffolding + +```bash +# Create a new BDD spec +coldbox create bdd name=UserServiceSpec open=true + +# Create with handler integration +coldbox create integration-test handler=users +``` diff --git a/.agents/skills/testbox-cbmockdata/SKILL.md b/.agents/skills/testbox-cbmockdata/SKILL.md new file mode 100644 index 0000000..3cbeb19 --- /dev/null +++ b/.agents/skills/testbox-cbmockdata/SKILL.md @@ -0,0 +1,360 @@ +--- +name: testbox-cbmockdata +description: "Use this skill when generating realistic fake/mock data in tests using cbMockData (WireBox ID: MockData@cbMockData): age, boolean, date, datetime, email, fname, lname, name, num, sentence, ssn, string, tel, uuid, url, words, lorem, baconlorem, imageurl, ipaddress, autoincrement, oneof, rnd/rand; generating arrays of objects, nested objects, arrays of values, or using custom supplier closures." +--- + +# cbMockData — Test Data Generation Reference + +## When to Use This Skill + +- Generating realistic fake data for test seeding, fixtures, or factories +- Creating arrays of mock objects (users, orders, products, etc.) +- Producing nested or related data structures +- Using custom supplier closures for computed or conditional test data + +--- + +## Overview + +`cbMockData` is a data generation module bundled with TestBox. It generates realistic fake values — names, emails, dates, SSNs, UUIDs, lorem ipsum, and more — via a simple fluent API. + +**WireBox ID**: `MockData@cbMockData` + +**Module**: Included automatically with TestBox. No separate installation needed. + +--- + +## Getting an Instance + +### In Tests via WireBox + +```boxlang +property name="mockData" inject="MockData@cbMockData" + +// or get it lazily inside a method: +var mockData = getInstance( "MockData@cbMockData" ) +``` + +### Directly (No WireBox) + +```boxlang +var mockData = new cbmockdata.models.MockData() +``` + +### In BDD `beforeAll()` + +```boxlang +component extends="testbox.system.BaseSpec" { + + property name="mockData" inject="MockData@cbMockData" + + function run() { + describe( "User registration", () => { + + var user = {} + + beforeAll( () => { + user = mockData.mock( + fname: "fname", + lname: "lname", + email: "email" + ) + } ) + + it( "has a valid email", () => { + expect( user.email ).toMatch( ".+@.+\..+" ) + } ) + + } ) + } + +} +``` + +--- + +## The `mock()` Method + +``` +mockData.mock( + [fieldName]: "[typeString]", // repeated for each field + $num: 10, // number of records to return (default: 1) + $returnType: "array" // "array" | "struct" (default: "array") +) +``` + +- When `$num > 1` the return is always an array. +- When `$num == 1` and `$returnType == "struct"` a single struct is returned. + +--- + +## All Type Strings + +| Type | Description | Example Output | +|---|---|---| +| `age` | Age 1–99 | `34` | +| `all_age` | Age 1–120 | `89` | +| `autoincrement` | Incrementing integer per call | `1`, `2`, `3` | +| `baconlorem` | Bacon ipsum sentences | `"Bacon ipsum dolor amet..."` | +| `boolean` | `true` or `false` | `true` | +| `boolean-digit` | `1` or `0` | `1` | +| `date` | Date string | `"2023-04-15"` | +| `datetime` | Date+time string | `"2023-04-15 14:32:00"` | +| `datetime-iso` | ISO 8601 datetime | `"2023-04-15T14:32:00Z"` | +| `email` | Valid email address | `"alice.smith@example.com"` | +| `fname` | First name | `"Alice"` | +| `lname` | Last name | `"Smith"` | +| `name` | Full name | `"Alice Smith"` | +| `num` | Number 1–9999 | `4721` | +| `oneof:a:b:c` | Random pick from colon-separated list | `"b"` | +| `rnd:min:max` / `rand:min:max` | Random integer in range | `rnd:1:100` → `57` | +| `sentence` | Lorem ipsum sentence | `"Lorem ipsum dolor..."` | +| `ssn` | US SSN format | `"123-45-6789"` | +| `string` | Random alphanumeric string | `"aB3kZ9"` | +| `string-alpha` | Random alphabetic string | `"AbCdEf"` | +| `string-numeric` | Random numeric string | `"482910"` | +| `string-secure` | Cryptographically random string | `"x9Qa#mK..."` | +| `tel` | Phone number | `"(555) 867-5309"` | +| `uuid` | UUID v4 | `"550e8400-e29b-41d4-a716-..."` | +| `guid` | GUID (same as uuid) | `"550e8400-..."` | +| `url` | URL | `"https://example.com/path"` | +| `words` | 1–5 random words | `"lorem ipsum dolor"` | +| `lorem` | Paragraph of lorem ipsum | `"Lorem ipsum..."` | +| `imageurl` | Placehold.it image URL | `"https://placehold.it/320x200"` | +| `ipaddress` | IPv4 address | `"192.168.1.42"` | + +--- + +## Basic Usage + +### Single Record (Struct) + +```boxlang +var user = mockData.mock( + $returnType: "struct", + id: "autoincrement", + firstName: "fname", + lastName: "lname", + email: "email", + phone: "tel", + age: "age", + isActive: "boolean", + createdAt: "datetime-iso" +) +// user.firstName => "Alice" +// user.email => "alice@example.com" +// user.isActive => true +``` + +### Array of Records + +```boxlang +// By default $num returns an array +var users = mockData.mock( + $num: 20, + id: "autoincrement", + firstName: "fname", + lastName: "lname", + email: "email", + role: "oneof:admin:editor:viewer" +) +// returns array of 20 user structs +// each has a unique id (1-20), random names/emails, and one of 3 roles +``` + +--- + +## Advanced Patterns + +### Fixed Value (Non-Type) + +Any value that is NOT a recognized type string is used as-is: + +```boxlang +var record = mockData.mock( + $returnType: "struct", + status: "active", // literal string — always "active" + version: 1, // literal number — always 1 + source: "test-suite" +) +``` + +### `oneof` — Enumerated Values + +```boxlang +var order = mockData.mock( + $returnType: "struct", + status: "oneof:pending:processing:shipped:delivered:cancelled", + priority: "oneof:low:medium:high", + paymentType: "oneof:credit:debit:paypal" +) +``` + +### `rnd` / `rand` — Numeric Range + +```boxlang +var product = mockData.mock( + $returnType: "struct", + price: "rnd:10:999", // integer 10–999 + quantity: "rand:1:50", // integer 1–50 + discount: "rnd:0:40" // integer 0–40 (percent) +) +``` + +--- + +## Nested Data Structures + +### Array of Objects (Nested Array) + +```boxlang +var orders = mockData.mock( + $num: 5, + id: "autoincrement", + customerId: "uuid", + total: "rnd:50:5000", + items: { // nested: array of item structs + $num: "rnd:1:5", + sku: "uuid", + name: "words", + price: "rnd:5:200", + qty: "rnd:1:10" + } +) +// Each order has an `items` array with 1–5 item structs +``` + +### Nested Object (Single Struct) + +```boxlang +var userWithAddress = mockData.mock( + $returnType: "struct", + id: "autoincrement", + name: "name", + email: "email", + address: { // nested single struct (no $num = single object) + street: "words", + city: "words", + country: "oneof:US:CA:UK:AU" + } +) +``` + +### Array of Scalar Values + +```boxlang +var record = mockData.mock( + $returnType: "struct", + id: "autoincrement", + tags: [ "words" ] // array wrapper = array of that type + // tags will be an array of random word strings +) +``` + +--- + +## Custom Supplier Closures + +When no built-in type covers your need, pass a closure. The closure receives the current mock struct (partial, built so far) and returns the value: + +```boxlang +var record = mockData.mock( + $returnType: "struct", + firstName: "fname", + lastName: "lname", + email: function( current ) { + // derive email from already-generated names + return lCase( current.firstName & "." & current.lastName & "@example.com" ) + }, + slug: function( current ) { + return lCase( replace( current.firstName & "-" & current.lastName, " ", "-", "all" ) ) + }, + score: function( current ) { + return randRange( 0, 100 ) + } +) +``` + +--- + +## Integration with Test Factories + +Use cbMockData inside a factory helper to produce consistent test data across specs: + +```boxlang +// tests/helpers/UserFactory.bx +class { + property name="mockData" inject="MockData@cbMockData" + + // Return a valid user struct + function make( struct overrides={} ) { + var user = mockData.mock( + $returnType: "struct", + id: "autoincrement", + firstName: "fname", + lastName: "lname", + email: "email", + role: "oneof:admin:editor:viewer", + isActive: "boolean", + createdAt: "datetime-iso" + ) + // merge overrides onto generated defaults + return user.append( overrides, true ) + } + + // Return N user structs + function makeMany( numeric count=5, struct overrides={} ) { + return mockData.mock( + $num: count, + id: "autoincrement", + firstName: "fname", + lastName: "lname", + email: "email", + role: "oneof:admin:editor:viewer", + isActive: "boolean" + ).map( ( u ) => u.append( overrides, true ) ) + } + +} +``` + +```boxlang +// In a test bundle +property name="userFactory" inject="UserFactory@myApp" + +function run() { + describe( "UserService", () => { + + it( "can find admin users", () => { + var admin = userFactory.make( { role: "admin" } ) + userService.seed( admin ) + expect( userService.getAdmins() ).notToBeEmpty() + } ) + + } ) +} +``` + +--- + +## Quick Reference + +```boxlang +// Single struct +mockData.mock( $returnType: "struct", email: "email", name: "name" ) + +// Array of 10 +mockData.mock( $num: 10, id: "autoincrement", name: "name" ) + +// Enum / pick one +mockData.mock( $returnType: "struct", status: "oneof:active:inactive:pending" ) + +// Range +mockData.mock( $returnType: "struct", score: "rnd:1:100" ) + +// Nested objects +mockData.mock( $num: 3, name: "name", address: { city: "words", country: "oneof:US:UK" } ) + +// Supplier closure +mockData.mock( $returnType: "struct", id: "autoincrement", label: ( curr ) => "Item-#curr.id#" ) +``` diff --git a/.agents/skills/testbox-expectations/SKILL.md b/.agents/skills/testbox-expectations/SKILL.md new file mode 100644 index 0000000..1d26f7a --- /dev/null +++ b/.agents/skills/testbox-expectations/SKILL.md @@ -0,0 +1,332 @@ +--- +name: testbox-expectations +description: "Use this skill when writing fluent expectations in TestBox using expect(), expectAll(), all built-in matchers (toBe, toBeTrue, toBeArray, toHaveKey, toThrow, toMatch, toBeBetween, toBeCloseTo, toInclude, etc.), the not operator (notToBe, notToBeEmpty, etc.), chaining multiple matchers on one expect(), creating custom matchers with addMatchers(), or using expectAll() over collections." +--- + +# TestBox Expectations — Fluent Assertion DSL + +## When to Use This Skill + +- Writing fluent `expect( actual ).toBeXxx()` assertions in BDD or xUnit bundles +- Chaining multiple matchers on a single `expect()` call +- Using `expectAll()` to assert every element in an array or struct +- Using the `not` operator to negate any matcher (`notToBe`, `notToBeEmpty`, etc.) +- Building and registering custom matchers with `addMatchers()` + +--- + +## Core Pattern + +```boxlang +// expect( actual ).matcher( expected ) +expect( result ).toBe( "hello" ) + +// Chained matchers on same actual value +expect( myArray ) + .toBeArray() + .notToBeEmpty() + .toHaveLength( 3 ) + +// Negative operators — prefix any matcher with "not" +expect( result ).notToBe( "wrong" ) +expect( list ).notToBeEmpty() +expect( value ).notToBeNull() +``` + +--- + +## All Built-in Matchers + +### Equality + +```boxlang +expect( result ).toBe( expected ) // case-insensitive equality (simple and complex) +expect( result ).toBeWithCase( expected ) // case-sensitive equality +expect( result ).notToBe( expected ) +expect( result ).notToBeWithCase( expected ) +``` + +### Boolean / Truthiness + +```boxlang +expect( value ).toBeTrue() +expect( value ).toBeFalse() +expect( value ).toBeNull() +expect( value ).notToBeNull() +``` + +### Emptiness & Length + +```boxlang +expect( collection ).toBeEmpty() // array, struct, string, query +expect( collection ).notToBeEmpty() +expect( collection ).toHaveLength( n ) +expect( collection ).notToHaveLength( n ) +``` + +### Type Checks + +```boxlang +// Generic type (uses CF isValid() under the hood) +expect( value ).toBeTypeOf( "array" ) // array, struct, component, numeric, boolean, date, uuid… +expect( value ).notToBeTypeOf( "array" ) + +// Dynamic shorthand — toBeXxx() where Xxx is any isValid() type +expect( [1,2,3] ).toBeArray() +expect( {} ).toBeStruct() +expect( "03/01/1990" ).toBeUsDate() +expect( createUUID() ).toBeUuid() +expect( 42 ).toBeNumeric() +expect( "hello" ).toBeString() +expect( true ).toBeBoolean() + +// Instance check +expect( myObj ).toBeInstanceOf( "models.UserService" ) +expect( myObj ).notToBeInstanceOf( "models.OtherService" ) +``` + +### Struct Key Existence + +```boxlang +expect( myStruct ).toHaveKey( "email" ) +expect( myStruct ).notToHaveKey( "password" ) + +// Deep key search (nested structs) +expect( myStruct ).toHaveDeepKey( "address" ) +expect( myStruct ).notToHaveDeepKey( "ssn" ) +``` + +### String / Array Inclusion + +```boxlang +// Case-insensitive +expect( "Hello World" ).toInclude( "hello" ) +expect( [1,2,3] ).toInclude( 2 ) +expect( "Hello World" ).notToInclude( "foo" ) + +// Case-sensitive +expect( "Hello World" ).toIncludeWithCase( "Hello" ) +expect( "Hello World" ).notToIncludeWithCase( "hello" ) // fails — "hello" != "Hello" +``` + +### Regular Expressions + +```boxlang +expect( "foo@bar.com" ).toMatch( "^[^@]+@[^@]+" ) // case-insensitive +expect( "Hello" ).toMatchWithCase( "^Hello" ) // case-sensitive +expect( "123" ).notToMatch( "[a-z]" ) +expect( "ABC" ).notToMatchWithCase( "[a-z]" ) +``` + +### Numeric Comparisons + +```boxlang +expect( 5 ).toBeGT( 4 ) +expect( 5 ).toBeGTE( 5 ) +expect( 5 ).toBeLT( 6 ) +expect( 5 ).toBeLTE( 5 ) +expect( 5 ).toBeBetween( 1, 10 ) +expect( 5 ).notToBeBetween( 20, 30 ) + +// Approximate equality (numbers or dates) +expect( 3.14159 ).toBeCloseTo( expected: 3.14, delta: 0.01 ) +expect( now() ).toBeCloseTo( expected: dateAdd( "s", 1, now() ), delta: 2, datepart: "s" ) +``` + +### Exceptions + +```boxlang +// Any exception +expect( () => { + service.riskyOperation() +} ).toThrow() + +// Specific type +expect( () => { + service.delete( 999 ) +} ).toThrow( type: "NotFoundException" ) + +// Type + message regex +expect( () => { + service.delete( 999 ) +} ).toThrow( type: "NotFoundException", regex: "999" ) + +// Assert NO exception +expect( () => { + service.safeOperation() +} ).notToThrow() +``` + +--- + +## Chaining Multiple Matchers + +All matchers return the expectation object, so you can chain: + +```boxlang +expect( response ) + .toBeStruct() + .toHaveKey( "status" ) + .toHaveKey( "data" ) + +expect( users ) + .toBeArray() + .notToBeEmpty() + .toHaveLength( 5 ) + +expect( email ) + .toBeString() + .notToBeEmpty() + .toMatch( ".+@.+\..+" ) +``` + +--- + +## `expectAll()` — Collection Assertions + +Assert that a matcher applies to **every element** in an array or every value in a struct: + +```boxlang +// All values are even numbers +expectAll( [2, 4, 6] ).toSatisfy( ( x ) => x % 2 == 0 ) + +// All struct values are non-empty +expectAll( { a: "foo", b: "bar" } ).notToBeEmpty() + +// All users are active +expectAll( userService.listAll() ).toSatisfy( ( user ) => user.isActive == true ) +``` + +--- + +## Custom Matchers + +Register custom matchers in `beforeAll()` or `beforeEach()` for global availability: + +### Inline Struct + +```boxlang +function beforeAll() { + addMatchers( { + + toBeValidEmail: function( expectation, args = {} ) { + expectation.message = isNull( args.message ) + ? "[#expectation.actual#] is not a valid email address" + : args.message + var passes = isValid( "Email", expectation.actual ) + return expectation.isNot ? !passes : passes + }, + + toBePositive: function( expectation, args = {} ) { + expectation.message = "[#expectation.actual#] is not a positive number" + return expectation.isNot + ? expectation.actual <= 0 + : expectation.actual > 0 + }, + + toHaveStatus: function( expectation, args = {} ) { + var expected = args[ 1 ] ?: args.status ?: 200 + expectation.message = "Expected HTTP status [#expected#] but got [#expectation.actual.getStatusCode()#]" + var passes = expectation.actual.getStatusCode() == expected + return expectation.isNot ? !passes : passes + } + + } ) +} +``` + +Usage: + +```boxlang +expect( "alice@example.com" ).toBeValidEmail() +expect( -5 ).notToBePositive() +expect( event.getResponse() ).toHaveStatus( 200 ) +``` + +### Class-Based Matchers (Reusable Library) + +```boxlang +// tests/helpers/AppMatchers.cfc +component { + + boolean function toBeActiveUser( required expectation, args = {} ) { + expectation.message = "Expected user to be active" + var passes = expectation.actual.keyExists( "isActive" ) && expectation.actual.isActive + return expectation.isNot ? !passes : passes + } + + boolean function toHavePermission( required expectation, args = {} ) { + var permission = args[ 1 ] ?: "" + expectation.message = "Expected user to have permission [#permission#]" + var passes = expectation.actual.permissions.findNoCase( permission ) > 0 + return expectation.isNot ? !passes : passes + } + +} +``` + +```boxlang +function beforeAll() { + addMatchers( "tests.helpers.AppMatchers" ) + // or: addMatchers( new tests.helpers.AppMatchers() ) +} + +// Usage +expect( adminUser ).toBeActiveUser() +expect( adminUser ).toHavePermission( "MANAGE_USERS" ) +expect( guestUser ).notToHavePermission( "MANAGE_USERS" ) +``` + +--- + +## Custom Matcher Signature Rules + +Every custom matcher function must follow this contract: + +| Rule | Detail | +|---|---| +| Signature | `boolean function myMatcher( required expectation, args = {} )` | +| Return | `true` = passes, `false` = fails | +| `expectation.actual` | The value passed into `expect()` | +| `expectation.isNot` | `true` when called as `notToMyMatcher()` | +| `expectation.message` | Set this to provide a custom failure message | +| `args` | All arguments passed to the matcher call | + +```boxlang +// Template for a custom matcher +boolean function toMeetCriteria( required expectation, args = {} ) { + expectation.message = args.message ?: "Custom failure message" + var passes = /* your evaluation */ true + return expectation.isNot ? !passes : passes +} +``` + +--- + +## Matcher Quick Reference + +| Matcher | Description | +|---|---| +| `toBe( val )` | Equality (case-insensitive for strings) | +| `toBeWithCase( val )` | Equality (case-sensitive) | +| `toBeTrue()` / `toBeFalse()` | Boolean assertion | +| `toBeNull()` | Null check | +| `toBeEmpty()` | Empty check (array/struct/string/query) | +| `toHaveLength( n )` | Size assertion | +| `toBeTypeOf( type )` | Type via `isValid()` | +| `toBe{Type}()` | e.g. `toBeArray()`, `toBeStruct()` | +| `toBeInstanceOf( class )` | Class/interface check | +| `toHaveKey( key )` | Struct key existence | +| `toHaveDeepKey( key )` | Deep nested struct key | +| `toInclude( needle )` | String/array inclusion | +| `toIncludeWithCase( needle )` | Case-sensitive inclusion | +| `toMatch( regex )` | Regex match (no case) | +| `toMatchWithCase( regex )` | Regex match (case-sensitive) | +| `toBeGT( n )` | Greater than | +| `toBeGTE( n )` | Greater than or equal | +| `toBeLT( n )` | Less than | +| `toBeLTE( n )` | Less than or equal | +| `toBeBetween( min, max )` | Numeric/date range | +| `toBeCloseTo( expected, delta )` | Approximate numeric/date equality | +| `toThrow( [type], [regex] )` | Exception assertion on a closure | +| All of the above prefixed with `not` | Negated version of that matcher | diff --git a/.agents/skills/testbox-listeners/SKILL.md b/.agents/skills/testbox-listeners/SKILL.md new file mode 100644 index 0000000..18e8847 --- /dev/null +++ b/.agents/skills/testbox-listeners/SKILL.md @@ -0,0 +1,298 @@ +--- +name: testbox-listeners +description: "Use this skill when implementing TestBox run listeners (callbacks): onBundleStart, onBundleEnd, onSuiteStart, onSuiteEnd, onSpecStart, onSpecEnd; building progress indicators, custom loggers, or live dashboards that react to test lifecycle events; or passing listener callbacks to TestBox's run(), runRaw(), or the standalone runner." +--- + +# TestBox Run Listeners — Comprehensive Reference + +## When to Use This Skill + +- Building custom progress bars, live dashboards, or real-time loggers during test runs +- Reacting to test lifecycle events (bundle start/end, suite start/end, spec start/end) +- Implementing CI annotations that annotate specific failures as they occur +- Integrating with external notification systems (Slack, webhooks) upon suite completion + +--- + +## Listener Events + +| Event | When It Fires | +|---|---| +| `onBundleStart` | Before any suite in a bundle (CFC file) begins | +| `onBundleEnd` | After all suites in a bundle complete | +| `onSuiteStart` | Before a `describe()` / `feature()` block begins | +| `onSuiteEnd` | After a `describe()` / `feature()` block completes | +| `onSpecStart` | Before an `it()` / `scenario()` / test function begins | +| `onSpecEnd` | After an `it()` / `scenario()` / test function completes | + +--- + +## Listener Arguments + +Each callback receives a single `required struct results` argument. The struct differs by event: + +### `onBundleStart` / `onBundleEnd` + +``` +results = { + bundle: , + testbox: , + bundleReport: // onBundleEnd only — final result struct +} +``` + +### `onSuiteStart` / `onSuiteEnd` + +``` +results = { + suite: , + bundle: , + testbox: +} +``` + +### `onSpecStart` / `onSpecEnd` + +``` +results = { + spec: , + suite: , + bundle: , + testbox: +} +``` + +Common `spec` properties: +- `spec.name` — spec display name +- `spec.status` — `"passed"`, `"failed"`, `"error"`, `"skipped"`, `"pending"` +- `spec.duration` — ms taken +- `spec.failMessage` — failure message if status is failed/error +- `spec.failOrigin` — origin file/line of failure + +--- + +## Passing Listeners to TestBox + +Listeners are passed as a `callbacks` struct — each key is the event name, value is a closure (or function reference). + +### Programmatic + +```boxlang +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "min", + callbacks: { + + onBundleStart: function( required struct results ) { + systemOutput( ">> Bundle: #results.bundle.path#" ) + }, + + onBundleEnd: function( required struct results ) { + systemOutput( " Bundle done: #results.bundleReport.totalPass# pass, #results.bundleReport.totalFail# fail" ) + }, + + onSuiteStart: function( required struct results ) { + systemOutput( " Suite: #results.suite.name#" ) + }, + + onSpecEnd: function( required struct results ) { + switch ( results.spec.status ) { + case "passed": systemOutput( " [OK] #results.spec.name# (#results.spec.duration#ms)" ); break + case "failed": systemOutput( " [FAIL] #results.spec.name# — #results.spec.failMessage#" ); break + case "error": systemOutput( " [ERR] #results.spec.name# — #results.spec.failMessage#" ); break + case "skipped": systemOutput( " [SKIP] #results.spec.name#" ); break + } + } + + } +).run() +``` + +--- + +## Class-Based Listeners + +For reusable, maintainable listeners, implement them as a component. The component has methods matching the event names. + +```boxlang +// tests/listeners/ProgressListener.bx +class { + + property name="failedSpecs" type="array" + + function init() { + variables.failedSpecs = [] + return this + } + + function onBundleStart( required struct results ) { + systemOutput( "" ) + systemOutput( "Bundle: #results.bundle.getBundlePath()#" ) + } + + function onBundleEnd( required struct results ) { + var r = results.bundleReport + systemOutput( " Done | Pass=#r.totalPass# Fail=#r.totalFail# Error=#r.totalError# Skipped=#r.totalSkipped# (#r.totalDuration#ms)" ) + } + + function onSuiteStart( required struct results ) { + systemOutput( " Suite: #results.suite.getName()#" ) + } + + function onSpecEnd( required struct results ) { + var spec = results.spec + if ( spec.status == "failed" || spec.status == "error" ) { + variables.failedSpecs.append( spec.name ) + systemOutput( " [FAIL] #spec.name# — #spec.failMessage ?: 'unknown error'#" ) + } + } + + function getSummary() { + return variables.failedSpecs + } + +} +``` + +```boxlang +// Instantiate and pass to TestBox +var listener = new tests.listeners.ProgressListener() + +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + callbacks: { + onBundleStart: listener.onBundleStart, + onBundleEnd: listener.onBundleEnd, + onSuiteStart: listener.onSuiteStart, + onSpecEnd: listener.onSpecEnd + } +).run() + +// Access gathered data after run +var failed = listener.getSummary() +if ( !failed.isEmpty() ) { + // send webhook, write report, etc. +} +``` + +--- + +## Common Listener Patterns + +### Progress Bar + +```boxlang +var total = 0 +var current = 0 + +callbacks = { + + // Count total specs first via dry-run or estimate + onSpecStart: function( required struct results ) { + current++ + var pct = ( total > 0 ) ? int( current / total * 100 ) : 0 + systemOutput( "\r[#repeatString("#", pct)##repeatString("-", 100 - pct)#] #pct#%", false ) + } + +} +``` + +### Failure Capture for CI Annotation + +```boxlang +var failures = [] + +callbacks = { + onSpecEnd: function( required struct results ) { + if ( results.spec.status == "failed" || results.spec.status == "error" ) { + failures.append( { + name: results.spec.name, + message: results.spec.failMessage ?: "", + origin: results.spec.failOrigin ?: "" + } ) + } + } +} + +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + callbacks: callbacks +).run() + +// After run — write GitHub Actions annotations +for ( var f in failures ) { + // ::error file=...,line=...::message + systemOutput( "::error file=#f.origin#::#f.name# — #f.message#" ) +} +``` + +### Timing Profiler — Find Slowest Specs + +```boxlang +var timings = [] + +callbacks = { + onSpecEnd: function( required struct results ) { + timings.append( { + name: results.spec.name, + duration: results.spec.duration + } ) + } +} + +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + callbacks: callbacks +).run() + +// Sort and print top 5 slowest +timings.sort( "numeric", "desc", "duration" ) +systemOutput( "--- Top 5 Slowest Specs ---" ) +timings.slice( 1, min( 5, timings.len() ) ).each( ( t ) => { + systemOutput( "#t.duration#ms — #t.name#" ) +} ) +``` + +### Suite-Level Logging + +```boxlang +callbacks = { + onSuiteStart: function( required struct results ) { + // Log entry to external system, e.g., Elasticsearch + logService.info( "Suite started: #results.suite.getName()#" ) + }, + + onSuiteEnd: function( required struct results ) { + var suite = results.suite + logService.info( "Suite ended: #suite.getName()# — #suite.getTotalPass()# pass, #suite.getTotalFail()# fail" ) + } +} +``` + +--- + +## Listener in `runRaw()` + +Callbacks work identically with `runRaw()`: + +```boxlang +var results = new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + callbacks: { + onSpecEnd: ( r ) => systemOutput( r.spec.status == "passed" ? "." : "F" ) + } +).runRaw() +``` + +--- + +## Quick Reference + +| Event | Good For | +|---|---| +| `onBundleStart` | Log which file is being processed | +| `onBundleEnd` | Per-file summary, CI file annotation | +| `onSuiteStart` | Suite-level logging, progress tracking | +| `onSuiteEnd` | Suite timing, per-suite metrics | +| `onSpecStart` | Timeout tracking, verbose mode | +| `onSpecEnd` | Progress dots, failure capture, timing profiler, notifications | diff --git a/.agents/skills/testbox-mockbox/SKILL.md b/.agents/skills/testbox-mockbox/SKILL.md new file mode 100644 index 0000000..081ec8e --- /dev/null +++ b/.agents/skills/testbox-mockbox/SKILL.md @@ -0,0 +1,312 @@ +--- +name: testbox-mockbox +description: "Use this skill when creating mocks, stubs, and spies in TestBox using MockBox: createMock(), createEmptyMock(), prepareMock(), stubbing methods with $(), chaining $args()/$results()/$throws(), verifying call counts with $once()/$never()/$times()/$atLeast()/$atMost(), reading call logs with $callLog(), injecting mock properties with $property(), simulating queries with querySim(), or spying on real methods with $spy()." +--- + +# MockBox — Mocking & Stubbing in TestBox + +## When to Use This Skill + +- Replacing real dependencies (DAOs, APIs, email services) with controlled test doubles +- Stubbing method return values for different arguments +- Verifying how many times a method was called and with what arguments +- Making a method throw a specific exception +- Spying on internal private methods of the object under test +- Injecting mock property values directly into objects + +--- + +## Creating Test Doubles + +```boxlang +// Full mock — real class instantiated, all methods can be stubbed +variables.mockUserService = createMock( "models.UserService" ) + +// Full mock from an already-instantiated object +variables.mockUserService = createMock( object: new models.UserService() ) + +// Empty mock — all methods wiped; you must stub every method used +variables.mockDAO = createEmptyMock( "models.UserDAO" ) + +// Partial mock — decorate a real object; unstubbed methods execute normally +variables.spySecurity = prepareMock( new models.SecurityService() ) +``` + +| Factory | Methods kept? | Use case | +|---|---|---| +| `createMock()` | Yes (stubbable) | Replace collaborator dependencies | +| `createEmptyMock()` | No (all wiped) | Mock an interface or abstract base | +| `prepareMock()` | Yes (targeted stubs only) | Spy on internal private methods | + +--- + +## Stubbing Return Values — `$()` + +```boxlang +// Return a fixed value every call +mockDAO.$( "findById" ).$results( { id: 1, name: "Alice" } ) + +// Shorthand: inline returns +mockDAO.$( "findById", { id: 1, name: "Alice" } ) + +// Multiple sequential results (cycles on last after exhausting) +mockDAO.$( "getNextRecord" ).$results( { id: 1 }, { id: 2 }, { id: 3 } ) +// call1 → {id:1}, call2 → {id:2}, call3+ → {id:3} + +// Chain multiple stubs on one mock +mockService.$( "isFound", false ).$( "isDirty", false ).$( "isSaved", true ) + +// Dynamic result via callback (receives caller arguments as array) +mockCalc.$( "calculate", ( args ) => args[ 1 ] * 2 ) + +// Void method (returns nothing, just track calls) +mockEmailService.$( "send" ) +``` + +--- + +## Argument-Specific Stubs — `$args()` + +When the same method is called with **different arguments** and must return different values: + +```boxlang +// Positional +mockConfig.$( "getKey" ) + .$args( "debugMode" ).$results( true ) + .$args( "outgoingMail" ).$results( "dev@example.com" ) + +// Named +mockConfig.$( "getKey" ) + .$args( name: "debugMode" ).$results( true ) + .$args( name: "outgoingMail" ).$results( "dev@example.com" ) + +// Usage +expect( mockConfig.getKey( "debugMode" ) ).toBeTrue() +expect( mockConfig.getKey( "outgoingMail" ) ).toBe( "dev@example.com" ) +``` + +> Always follow `$args()` with `$results()`. + +--- + +## Throwing Exceptions — `$throws()` + +```boxlang +// Chain approach +mockDAO.$( "delete" ) + .$args( id: 999 ) + .$throws( type: "NotFoundException", message: "Record 999 not found" ) + +// Inline approach +mockDAO.$( + method: "delete", + throwException: true, + throwType: "NotFoundException", + throwMessage: "Record not found", + throwDetail: "id=999", + throwErrorCode: "404" +) + +// Confirm in spec +expect( () => service.delete( 999 ) ).toThrow( type: "NotFoundException" ) +``` + +--- + +## Injecting Properties — `$property()` + +Inject a mock directly into any scope of the SUT without needing a setter: + +```boxlang +prepareMock( variables.sut ) + .$property( + propertyName: "userRepository", + propertyScope: "variables", // default + mock: mockUserRepository + ) + +// Read it back +var injected = variables.sut.$getProperty( "userRepository" ) +``` + +--- + +## Query Simulation — `querySim()` + +Build query objects inline using pipe-delimited syntax: + +```boxlang +var userQuery = querySim( + "id, name, email + 1 | Alice Majano | alice@example.com + 2 | Bob Clapton | bob@example.com + 3 | Carol Degeneres| carol@example.com" +) + +mockDAO.$( "findAll" ).$results( userQuery ) + +var result = userService.listAll() +expect( result.recordCount ).toBe( 3 ) +expect( result.name[ 1 ] ).toBe( "Alice Majano" ) +``` + +--- + +## Spying on Real Methods — `$spy()` + +The real implementation still executes; MockBox logs calls so you can verify them: + +```boxlang +prepareMock( variables.sut ) +variables.sut.$spy( "sendWelcomeEmail" ) + +userService.register( { name: "Alice", email: "alice@example.com" } ) + +expect( variables.sut.$once( "sendWelcomeEmail" ) ).toBeTrue() +``` + +--- + +## Verification Methods + +```boxlang +// Exactly once +expect( mockEmailService.$once( "send" ) ).toBeTrue() + +// Never called +expect( mockAuditService.$never( "logError" ) ).toBeTrue() + +// Exactly N times +expect( mockDAO.$times( 3, "findById" ) ).toBeTrue() +expect( mockDAO.$verifyCallCount( 3, "findById" ) ).toBeTrue() // alias + +// At least N (>= N) +expect( mockDAO.$atLeast( 2, "findById" ) ).toBeTrue() + +// At most N (<= N) +expect( mockDAO.$atMost( 5, "save" ) ).toBeTrue() + +// Raw count +var total = mockDAO.$count() // all methods +var saveCount = mockDAO.$count( "save" ) // specific method +``` + +--- + +## Inspecting Call Logs — `$callLog()` + +`$callLog()` returns a struct keyed by method name; each value is an array of argument structs. + +```boxlang +mockSession.$( "setVar", callLogging: true ) +mockSession.setVar( "Hello", "World" ) +mockSession.setVar( "Name", "Alice" ) + +var logs = mockSession.$callLog() +// logs.setVar is an array of 2 ordered argument structs +expect( logs.setVar ).toHaveLength( 2 ) +expect( logs.setVar[ 1 ][ "1" ] ).toBe( "Hello" ) // positional key "1" +expect( logs.setVar[ 2 ][ "1" ] ).toBe( "Name" ) +``` + +--- + +## Resetting Between Specs + +```boxlang +afterEach( () => { + mockDAO.$reset() // clears $count, $callLog, all stubs remain + mockEmailService.$reset() +} ) +``` + +--- + +## Complete Integration Example + +```boxlang +class extends="testbox.system.BaseSpec" { + + function beforeAll() { + variables.mockUserDAO = createEmptyMock( "models.UserDAO" ) + variables.mockEmailService = createEmptyMock( "models.EmailService" ) + + variables.sut = prepareMock( new models.UserService() ) + .$property( propertyName: "userDAO", mock: mockUserDAO ) + .$property( propertyName: "emailService", mock: mockEmailService ) + } + + function run() { + + describe( "UserService.register()", () => { + + beforeEach( () => { + mockUserDAO.$reset() + mockEmailService.$reset() + } ) + + it( "saves user and sends welcome email", () => { + mockUserDAO.$( "save" ).$results( { id: 1, name: "Alice" } ) + mockEmailService.$( "sendWelcome" ) + + var result = sut.register( { name: "Alice", email: "alice@example.com" } ) + + expect( result.id ).toBe( 1 ) + expect( mockUserDAO.$once( "save" ) ).toBeTrue() + expect( mockEmailService.$once( "sendWelcome" ) ).toBeTrue() + } ) + + it( "does not send email when save fails", () => { + mockUserDAO.$( "save" ) + .$throws( type: "DatabaseException", message: "Duplicate entry" ) + + expect( () => sut.register( { name: "Dup", email: "dup@example.com" } ) ) + .toThrow( type: "DatabaseException" ) + + expect( mockEmailService.$never( "sendWelcome" ) ).toBeTrue() + } ) + + it( "calls save with the correct data arguments", () => { + mockUserDAO.$( "save", callLogging: true ).$results( { id: 2 } ) + mockEmailService.$( "sendWelcome" ) + + sut.register( { name: "Bob", email: "bob@example.com" } ) + + var log = mockUserDAO.$callLog() + expect( log.save[ 1 ] ).toHaveKey( "name" ) + expect( log.save[ 1 ].name ).toBe( "Bob" ) + } ) + + } ) + + } + +} +``` + +--- + +## Quick Reference + +| Method | Returns | Description | +|---|---|---| +| `createMock( className\|object )` | mock | Full mock, methods intact | +| `createEmptyMock( className\|object )` | mock | All methods wiped | +| `prepareMock( object )` | mock | Decorate existing instance | +| `mock.$( method, [returns] )` | mock | Stub a method | +| `mock.$args( ...args )` | mock | Match specific call arguments | +| `mock.$results( ...values )` | mock | Set sequential return values | +| `mock.$throws( type, message, detail )` | mock | Make method throw | +| `mock.$property( name, scope, mock )` | mock | Inject into any scope | +| `mock.$getProperty( name, [scope] )` | any | Read internal property | +| `mock.$spy( method )` | mock | Spy on real method (real runs + log) | +| `querySim( dsv )` | query | Build query from pipe-delimited string | +| `mock.$once( [method] )` | boolean | Called exactly once | +| `mock.$never( [method] )` | boolean | Never called | +| `mock.$times( n, [method] )` | boolean | Called exactly N times | +| `mock.$atLeast( n, [method] )` | boolean | Called >= N times | +| `mock.$atMost( n, [method] )` | boolean | Called <= N times | +| `mock.$count( [method] )` | numeric | Call count | +| `mock.$callLog()` | struct | All call argument logs | +| `mock.$reset()` | void | Reset counters and logs | +| `mock.$debug()` | struct | Debugging info | diff --git a/.agents/skills/testbox-reporters/SKILL.md b/.agents/skills/testbox-reporters/SKILL.md new file mode 100644 index 0000000..b1d4aea --- /dev/null +++ b/.agents/skills/testbox-reporters/SKILL.md @@ -0,0 +1,343 @@ +--- +name: testbox-reporters +description: "Use this skill when selecting or configuring TestBox reporters: ANTJunit, Console, Doc, JSON, JUnit, Min, MinText, Simple, Text, XML, Streaming; setting reporter options (hideSkipped, editor links for Simple reporter); or creating a custom reporter by implementing the IReporter interface." +--- + +# TestBox Reporters — Comprehensive Reference + +## When to Use This Skill + +- Choosing the right reporter for a use case (CI, development, IDE, browser) +- Configuring reporter-specific options (hideSkipped, IDE links) +- Using the StreamingReporter for real-time SSE output +- Building a custom reporter by implementing `IReporter` + +--- + +## Built-In Reporters + +| Reporter Key | Class | Best For | +|---|---|---| +| `antjunit` | `testbox.system.reports.ANTJunitReporter` | Ant/legacy CI pipelines | +| `console` | `testbox.system.reports.ConsoleReporter` | CI stdout logs | +| `doc` | `testbox.system.reports.DocReporter` | Living documentation | +| `json` | `testbox.system.reports.JSONReporter` | API / tooling consumption | +| `junit` | `testbox.system.reports.JUnitReporter` | Modern CI (GitHub Actions, Jenkins) | +| `min` | `testbox.system.reports.MinReporter` | Fast dot-notation output | +| `mintext` | `testbox.system.reports.MinTextReporter` | Plain-text minimal (no ANSI) | +| `simple` | `testbox.system.reports.SimpleReporter` | Rich HTML browser view | +| `text` | `testbox.system.reports.TextReporter` | Plain-text verbose output | +| `xml` | `testbox.system.reports.XMLReporter` | XML consumers, legacy tools | +| `streaming` | `testbox.system.reports.StreamingReporter` | SSE real-time output (TB7+) | + +--- + +## Reporter Details + +### `min` — Minimal (default for CLI) + +Prints a dot (`.`) for pass, `F` for fail, `E` for error, `S` for skip. Summary at the end. + +```bash +./testbox/run --reporter=min +testbox run reporter=min +``` + +Output: +``` +.....F.....S...E....... +Tests: 15 Pass: 12 Fail: 1 Error: 1 Skipped: 1 Duration: 234ms +``` + +--- + +### `mintext` — Minimal Text (no ANSI colors) + +Same as `min` but no ANSI escape codes — suitable for log files. + +```bash +./testbox/run --reporter=mintext +``` + +--- + +### `console` — Console (colored verbose) + +Prints each spec name with colored pass/fail indicator. Useful in CI logs where you want readable spec names. + +#### Options + +```boxlang +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: { + class: "testbox.system.reports.ConsoleReporter", + options: { hideSkipped: true } // suppress skipped spec noise + } +).run() +``` + +```bash +# Via CLI +testbox run reporter=console options.hideSkipped=true +``` + +--- + +### `simple` — Simple HTML + +Rich HTML output with collapsible suites, color coding, and IDE deep-link support. + +#### Editor Links + +Configure Deep Links so clicking a spec name opens the file in your IDE: + +```boxlang +reporter: { + class: "testbox.system.reports.SimpleReporter", + options: { + // Supported editors: + editor: "vscode" // vscode://file/{path}:{line} + editor: "vscode-insiders" + editor: "sublimetext" // subl://open?url=file://{path}&line={line} + editor: "textmate" // txmt://open?url=file://{path}&line={line} + editor: "emacs" // emacs://open?url=file://{path}&line={line} + editor: "macvim" // mvim://open?url=file://{path}&line={line} + editor: "atom" // atom://open?src={path}&line={line} + editor: "idea" // idea://open?file={path}&line={line} + } +} +``` + +In `box.json`: + +```json +{ + "testbox": { + "runner": "http://localhost:8080/tests/runner.cfm", + "reporter": { + "class": "testbox.system.reports.SimpleReporter", + "options": { "editor": "vscode" } + } + } +} +``` + +--- + +### `json` — JSON + +Returns the full result set as a JSON string. Useful for programmatic consumption, test dashboards, or custom CI tooling. + +```boxlang +var results = new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "json" +).runRaw() +``` + +Typical JSON shape: + +```json +{ + "totalDuration": 512, + "totalSpecs": 42, + "totalPass": 40, + "totalFail": 1, + "totalError": 1, + "totalSkipped": 0, + "labels": [], + "bundleStats": [...] +} +``` + +--- + +### `junit` — JUnit XML + +Produces JUnit-compatible XML. Use this for GitHub Actions, Jenkins, CircleCI, or any CI that parses JUnit reports: + +```bash +./testbox/run --reporter=junit > results/junit.xml +``` + +```yaml +# GitHub Actions +- name: Run Tests + run: ./testbox/run --reporter=junit > test-results/junit.xml + +- name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: test-results/junit.xml +``` + +--- + +### `antjunit` — ANTJunit XML + +Legacy Ant-compatible JUnit XML format. Use only when your build tool requires Ant-style XML. + +--- + +### `doc` — Documentation + +Produces a structured HTML report formatted as living documentation — spec names read like sentences describing application behaviour. + +--- + +### `text` — Text + +Verbose plain-text output: prints every spec name, full error messages and stack traces. Best for deep-dive debugging. + +```bash +./testbox/run --reporter=text --stacktrace +``` + +--- + +### `xml` — XML + +Generic XML representation of the result tree. Different from JUnit — use when you need to feed custom XML consumers (XSLT transforms, legacy reporting tools). + +--- + +### `streaming` — StreamingReporter (TestBox 7+) + +Emits Server-Sent Events (SSE) so results appear in real time in the terminal or browser as specs complete. + +```bash +./testbox/run --stream +testbox run --streaming +``` + +When accessed via HTTP, the Content-Type is `text/event-stream`. Each event payload is a JSON object describing a spec result: + +``` +data: {"specName":"it can create a user","status":"passed","duration":12} + +data: {"specName":"it can delete a user","status":"failed","message":"Expected true but got false","duration":8} +``` + +--- + +## Programmatic Reporter Configuration + +### By String Key + +```boxlang +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "min" +).run() +``` + +### By Struct with Options + +```boxlang +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: { + class: "testbox.system.reports.ConsoleReporter", + options: { hideSkipped: true } + } +).run() +``` + +### By Full Class Path + +```boxlang +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "testbox.system.reports.JUnitReporter" +).run() +``` + +--- + +## Custom Reporter + +Implement the `IReporter` interface to create a fully custom reporter. + +### Interface Contract + +```boxlang +// testbox/system/reports/IReporter.cfc (interface) +interface { + // Called once before any tests run — return initial output string + string function init( required results, required testbox ) + + // Called once after all tests run — return accumulated output string + string function runReport( required results, required testbox, struct options={} ) +} +``` + +### Minimal Implementation + +```boxlang +// tests/reporters/MyReporter.cfc +component implements="testbox.system.reports.IReporter" { + + function init( required results, required testbox ) { + return "" + } + + function runReport( required results, required testbox, struct options={} ) { + var sb = [] + + sb.append( "=== Test Results ===" ) + sb.append( "Total: #results.totalSpecs# | Pass: #results.totalPass# | Fail: #results.totalFail# | Error: #results.totalError# | Skipped: #results.totalSkipped#" ) + sb.append( "Duration: #results.totalDuration#ms" ) + + if ( results.totalFail > 0 || results.totalError > 0 ) { + sb.append( "" ) + sb.append( "FAILURES:" ) + for ( var bundle in results.bundleStats ) { + for ( var suite in bundle.suiteStats ) { + for ( var spec in suite.specStats ) { + if ( spec.status == "failed" || spec.status == "error" ) { + sb.append( " [#spec.status.uCase()#] #spec.name#" ) + if ( spec.keyExists( "failMessage" ) ) { + sb.append( " #spec.failMessage#" ) + } + } + } + } + } + } + + return sb.toList( chr(10) ) + } + +} +``` + +### Using Your Custom Reporter + +```boxlang +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "tests.reporters.MyReporter" +).run() + +// or by instance +new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: new tests.reporters.MyReporter() +).run() +``` + +--- + +## Reporter Selection Guide + +| Scenario | Reporter | +|---|---| +| Fast dev feedback in terminal | `min` or `mintext` | +| Rich browser debugging | `simple` with `editor: "vscode"` | +| CI (GitHub Actions / Jenkins) | `junit` or `antjunit` | +| Log file output | `text` or `mintext` | +| Test dashboard / API | `json` | +| Real-time streaming | `streaming` (or `--stream` flag) | +| Living documentation | `doc` | +| Custom pipeline | Custom class via `IReporter` | diff --git a/.agents/skills/testbox-runners/SKILL.md b/.agents/skills/testbox-runners/SKILL.md new file mode 100644 index 0000000..a867d4b --- /dev/null +++ b/.agents/skills/testbox-runners/SKILL.md @@ -0,0 +1,393 @@ +--- +name: testbox-runners +description: "Use this skill when running TestBox tests: CommandBox CLI (testbox run), BoxLang CLI (./testbox/run), HTML web runner, programmatic TestBox instantiation (run/runRaw/runRemote), configuring test directories or bundles, using the streaming runner (--stream flag / StreamingRunner), watcher mode, all CLI flags (--show-failed-only, --dry-run, --slow-threshold-ms, --stacktrace, --max-failures), or setting up box.json testbox configuration." +--- + +# TestBox Runners — Comprehensive Reference + +## When to Use This Skill + +- Running tests from the command line (CommandBox or BoxLang CLI) +- Configuring `testbox` in `box.json` +- Starting a file-watcher to auto-run tests on change +- Using `--stream` / `StreamingRunner` for SSE-based real-time output +- Invoking TestBox programmatically from CFML/BoxLang code +- Choosing the right runner for a use case (CLI, web, CI, programmatic) + +--- + +## Runner Overview + +| Runner | Use Case | +|---|---| +| CommandBox CLI | Standard development workflow; wraps `testbox run` | +| BoxLang CLI | BoxLang projects; binary at `./testbox/run` | +| HTML Web Runner | Browser-based interactive test runner | +| Programmatic API | Embedding tests in application code or custom scripts | +| StreamingRunner | Real-time SSE-based output (TestBox 7+) | + +--- + +## CommandBox CLI Runner + +### Basic Usage + +```bash +# Run all tests configured in box.json +testbox run + +# Override directory at runtime +testbox run runner="http://localhost:8080/tests/runner.cfm" +``` + +### `box.json` Configuration + +```json +{ + "testbox": { + "runner": "http://localhost:8080/tests/runner.cfm", + "verbose": false, + "labels": "", + "excludes": "", + "reporter": "min", + "recurse": true, + "bundles": "", + "directory": { + "mapping": "tests.specs", + "recurse": true, + "filter": "" + }, + "options": {} + } +} +``` + +### Key CLI Flags + +```bash +# Streaming real-time output (SSE) +testbox run --streaming + +# Verbose output +testbox run verbose=true + +# Specific reporter +testbox run reporter=json + +# Run with labels +testbox run labels=integration + +# Limit concurrent specs (parallel) +testbox run options.maxParallel=4 +``` + +### File Watcher + +Start a persistent process that re-runs tests on file changes: + +```bash +testbox watch + +# Watch specific directory +testbox watch directory=models + +# With streaming output +testbox watch --streaming +``` + +Watch is configured in `box.json`: + +```json +{ + "testbox": { + "runner": "http://localhost:8080/tests/runner.cfm", + "watchDelay": 500, + "watchPaths": "**.cfc,templates/**" + } +} +``` + +--- + +## BoxLang CLI Runner + +For BoxLang projects, TestBox ships a native binary runner at `./testbox/run`. + +### Installation + +TestBox CLI runner is automatically installed when TestBox is a dependency: + +```bash +# Install testbox (adds ./testbox/run) +install testbox +``` + +### All Flags + +```bash +# Run all specs under tests/ +./testbox/run + +# Run specific directory +./testbox/run --directory=tests/unit + +# Run specific bundles (comma-separated) +./testbox/run --bundles=tests.specs.MySpec,tests.specs.OtherSpec + +# Streaming / real-time output +./testbox/run --stream + +# Dry run — discover specs without executing +./testbox/run --dry-run + +# Show only failed specs +./testbox/run --show-failed-only + +# Show passed specs explicitly +./testbox/run --show-passed + +# Show skipped specs explicitly +./testbox/run --show-skipped + +# Show full stack traces +./testbox/run --stacktrace + +# Set maximum failures before stopping +./testbox/run --max-failures=10 + +# Flag specs slower than N ms +./testbox/run --slow-threshold-ms=100 + +# Show top N slowest specs +./testbox/run --top-slowest=5 + +# Set reporter (min, json, junit, etc.) +./testbox/run --reporter=min + +# Combine options +./testbox/run --directory=tests/unit --show-failed-only --slow-threshold-ms=50 --top-slowest=5 +``` + +### Exit Codes + +| Code | Meaning | +|---|---| +| `0` | All tests passed | +| `1` | One or more failures or errors | + +Use exit codes in CI/CD pipelines to gate deployments. + +--- + +## StreamingRunner (TestBox 7+) + +The StreamingRunner emits specs in real time using SSE (Server-Sent Events), allowing the terminal or browser to show results as they complete instead of waiting for the full suite. + +### Via CommandBox + +```bash +testbox run --streaming +``` + +### Via BoxLang CLI + +```bash +./testbox/run --stream +``` + +### Programmatic Setup + +```boxlang +var runner = new testbox.system.runners.StreamingRunner() +runner.run( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "min", + labels: [] +) +``` + +### Web Endpoint (SSE) + +The web-based streaming endpoint sends `text/event-stream`: + +``` +GET /testbox/runners/streamingrunner.bxm?reporter=min&directory=tests.specs +``` + +Browser-side: + +```javascript +const es = new EventSource( "/testbox/stream?reporter=min" ) +es.onmessage = ( e ) => console.log( JSON.parse( e.data ) ) +``` + +--- + +## HTML Web Runner + +The HTML runner is the classic browser-based test interface. + +### Quick Setup + +``` +http://localhost:8080/tests/runner.cfm +``` + +Create `tests/runner.cfm` (or `tests/runner.cfm`): + +```cfm + + // Defaults: directory = /tests/specs + new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true } + ).run() + +``` + +### With Options + +```cfm + + new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "simple", + labels: url.keyExists( "labels" ) ? url.labels : [], + excludes: [], + options: {} + ).run() + +``` + +--- + +## Programmatic Runner API + +### TestBox Construction + +```boxlang +// By directory +var tb = new testbox.system.TestBox( + directory: { + mapping: "tests.specs", // dot-notation mapping + recurse: true, + filter: "" // optional glob filter + } +) + +// By specific bundles +var tb = new testbox.system.TestBox( + bundles: [ + "tests.specs.unit.UserServiceSpec", + "tests.specs.integration.APISpec" + ] +) + +// With reporter and labels +var tb = new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true }, + reporter: "json", + labels: [ "unit", "fast" ], + excludes: [ "slow" ] +) +``` + +### Running + +```boxlang +// Run and render output (returns and writes results to browser/stdout) +tb.run() + +// Run and return raw result struct +var results = tb.runRaw() +// results.totalDuration, results.totalSpecs, results.totalPass, +// results.totalFail, results.totalError, results.totalSkipped + +// Run via HTTP and return result string (for cross-server testing) +var output = tb.runRemote( + testBundles: "tests.specs.MySpec", + reporter: "json", + options: {} +) +writeOutput( output ) +``` + +### Inspecting Raw Results + +```boxlang +var results = new testbox.system.TestBox( + directory: { mapping: "tests.specs", recurse: true } +).runRaw() + +if ( results.totalFail > 0 || results.totalError > 0 ) { + // CI: exit with failure + systemOutput( "TESTS FAILED: #results.totalFail# failures, #results.totalError# errors" ) + abort +} +``` + +--- + +## Auto-Load via `Application.bx` + +In BoxLang projects, wire TestBox into the app lifecycle so you can hit `/tests/` without a dedicated runner file: + +```boxlang +// Application.bx +class { + variables.this.name = "MyApp" + variables.this.mappings[ "/testbox" ] = expandPath( "/testbox" ) + variables.this.mappings[ "/tests" ] = expandPath( "/tests" ) +} +``` + +--- + +## Integration Testing with `bx-web-support` + +Install the `bx-web-support` BoxLang module to run web requests from the CLI runner: + +```bash +install bx-web-support +./testbox/run --directory=tests/integration +``` + +Integration tests can then make HTTP calls directly without a running web server. + +--- + +## CI/CD Integration + +```yaml +# GitHub Actions example +- name: Run TestBox Tests + run: | + ./testbox/run --directory=tests --reporter=junit --show-failed-only + # Non-zero exit on failure automatically fails the step +``` + +```bash +# Jenkins / generic shell +./testbox/run --directory=tests --reporter=junit +if [ $? -ne 0 ]; then + echo "Tests failed" + exit 1 +fi +``` + +--- + +## Quick Reference + +| Task | Command | +|---|---| +| Run all tests | `./testbox/run` or `testbox run` | +| Stream output | `./testbox/run --stream` | +| Only failures | `./testbox/run --show-failed-only` | +| Dry run | `./testbox/run --dry-run` | +| Stack traces | `./testbox/run --stacktrace` | +| Slow spec report | `./testbox/run --slow-threshold-ms=200 --top-slowest=10` | +| Max failures | `./testbox/run --max-failures=5` | +| Specific directory | `./testbox/run --directory=tests/unit` | +| Specific bundles | `./testbox/run --bundles=tests.specs.MySpec` | +| JUnit for CI | `./testbox/run --reporter=junit` | +| Watch mode | `testbox watch` | diff --git a/.agents/skills/testbox-unit-xunit/SKILL.md b/.agents/skills/testbox-unit-xunit/SKILL.md new file mode 100644 index 0000000..0386bf0 --- /dev/null +++ b/.agents/skills/testbox-unit-xunit/SKILL.md @@ -0,0 +1,318 @@ +--- +name: testbox-unit-xunit +description: "Use this skill when writing xUnit-style tests in TestBox using test functions (testXxx()), setup/teardown lifecycle (beforeTests/afterTests/setup/teardown), $assert assertion object, or the Arrange-Act-Assert (AAA) pattern for unit testing services, models, and utilities in isolation." +--- + +# xUnit / Unit Testing with TestBox + +## When to Use This Skill + +- Writing xUnit-style test bundles (functions prefixed with `test`) +- Using `$assert` assertion methods (isTrue, isEqual, includes, throws, etc.) +- Writing `beforeTests()` / `afterTests()` / `setup()` / `teardown()` lifecycle methods +- Unit-testing CFC models, services, or utilities in isolation with mocked dependencies +- Applying the Arrange-Act-Assert (AAA) pattern + +--- + +## Language Reference + +| Concept | BoxLang (`.bx`) preferred | CFML (`.cfc`) compatible | +|---|---|---| +| Class declaration | `class extends="testbox.system.BaseSpec" {}` | `component extends="testbox.system.BaseSpec" {}` | +| Test functions | `function testXxx() {}` | `function testXxx() output="false" {}` | +| Scoped var | `var x = ...` | `var x = ...` | + +--- + +## Canonical xUnit Bundle Structure + +```boxlang +class labels="unit" extends="testbox.system.BaseSpec" { + + /****** LIFECYCLE ******/ + + // Runs ONCE before all test functions in this bundle + function beforeTests() { + variables.service = new models.CalculatorService() + } + + // Runs ONCE after all test functions + function afterTests() { + structClear( variables ) + } + + // Runs before EACH test function + function setup() { + variables.mockLogger = createMock( "models.Logger" ) + variables.service.setLogger( mockLogger ) + } + + // Runs after EACH test function + function teardown() { + mockLogger.$reset() + } + + /****** TEST METHODS ******/ + + function testAddsTwoNumbers() { + // Arrange + var a = 5 + var b = 3 + + // Act + var result = service.add( a, b ) + + // Assert + $assert.isEqual( 8, result ) + } + + function testDivideThrowsOnZero() { + $assert.throws( + () => service.divide( 10, 0 ), + "MathException" + ) + } + + function testSkipped() skip { + $assert.fail( "Should never run" ) + } + +} +``` + +--- + +## Lifecycle Method Reference + +| Method | When It Runs | Use Case | +|---|---|---| +| `beforeTests()` | Once before all test functions | Initialize shared objects, DB connections, JWT settings | +| `afterTests()` | Once after all test functions | Close connections, delete temp files | +| `setup()` | Before **each** test function | Create fresh mocks, reset state, clear caches | +| `teardown()` | After **each** test function | Roll back transactions, delete records, reset stubs | + +```boxlang +function beforeTests() { + // One-time: load heavy collaborators + variables.orm = getInstance( "ORMService@cborm" ) + structClear( request ) +} + +function setup() { + // Per-test: always get a clean state + variables.mockDAO = createEmptyMock( "models.UserDAO" ) + variables.sut = new models.UserService( mockDAO ) +} +``` + +--- + +## `$assert` Assertion Reference + +Every test bundle receives `$assert` — an instance of `testbox.system.Assertion`. + +```boxlang +// Boolean +$assert.isTrue( myBool ) +$assert.isFalse( myBool ) + +// Equality +$assert.isEqual( expected, actual ) +$assert.isEqualWithCase( expected, actual ) +$assert.isNotEqual( expected, actual ) + +// Null +$assert.null( actual ) +$assert.notNull( actual ) + +// Emptiness +$assert.isEmpty( target ) // arrays, structs, strings, queries +$assert.isNotEmpty( target ) + +// Size +$assert.lengthOf( target, length ) +$assert.notLengthOf( target, length ) + +// Key existence +$assert.key( target, key ) +$assert.notKey( target, key ) +$assert.deepKey( target, key ) +$assert.notDeepKey( target, key ) + +// Inclusion +$assert.includes( target, needle ) // case-insensitive +$assert.includesWithCase( target, needle ) +$assert.notIncludes( target, needle ) +$assert.notIncludesWithCase( target, needle ) + +// Type +$assert.typeOf( type, actual ) +$assert.notTypeOf( type, actual ) +$assert.instanceOf( actual, typeName ) +$assert.notInstanceOf( actual, typeName ) + +// Numeric comparison +$assert.isGT( actual, target ) +$assert.isGTE( actual, target ) +$assert.isLT( actual, target ) +$assert.isLTE( actual, target ) +$assert.between( actual, min, max ) +$assert.closeTo( expected, actual, delta ) + +// String / regex +$assert.match( actual, regex ) +$assert.matchWithCase( actual, regex ) +$assert.notMatch( actual, regex ) + +// Exceptions +$assert.throws( target, [type], [regex] ) +$assert.notThrows( target, [type], [regex] ) + +// Force failure +$assert.fail( [message] ) + +// Skip current test +$assert.skip( message, detail ) +``` + +### BoxLang Dynamic Assertion Methods + +In BoxLang you can also invoke any assertion as a free function prefixed with `assert`: + +```boxlang +assertIsTrue( myBool ) +assertIsEqual( expected, actual ) +assertBetween( actual, 1, 100 ) +assertThrows( () => badCall(), "MyException" ) +``` + +--- + +## Arrange-Act-Assert (AAA) Pattern + +```boxlang +function testUserCreation() { + // ARRANGE + var mockUserDAO = createEmptyMock( "models.UserDAO" ) + mockUserDAO.$( "save" ).$results( { id: 42, name: "Alice" } ) + var sut = new models.UserService( mockUserDAO ) + var data = { name: "Alice", email: "alice@example.com" } + + // ACT + var result = sut.createUser( data ) + + // ASSERT + $assert.isEqual( 42, result.id ) + $assert.isEqual( "Alice", result.name ) + $assert.isTrue( mockUserDAO.$once( "save" ) ) +} +``` + +--- + +## Mixing xUnit with Expectations (expect DSL) + +You can freely mix `$assert` and `expect()` fluent matchers in the same bundle: + +```boxlang +function testUserEmail() { + var user = sut.findById( 1 ) + + // xUnit style + $assert.isNotEmpty( user ) + $assert.key( user, "email" ) + + // BDD fluent style (also available in xUnit bundles) + expect( user.email ).toMatch( ".+@.+" ) + expect( user.isActive ).toBeTrue() +} +``` + +--- + +## Skipping Tests + +```boxlang +// Skip via function attribute +function testSomething() skip { + $assert.fail( "won't run" ) +} + +// Skip via argument +function testEngineSpecific() skip="#!server.keyExists( 'lucee' )#" { + $assert.isTrue( luceeOnlyFeature() ) +} + +// Skip programmatically inline +function testConditional() { + if ( !featureEnabled ) { + $assert.skip( "Feature flag is off" ) + } + $assert.isTrue( myFeature.isActive() ) +} +``` + +--- + +## Custom Assertions + +Register in `beforeTests()` to keep the shared `$assert` object clean: + +```boxlang +function beforeTests() { + addAssertions( { + isValidEmail: function( actual ) { + return ( reFindNoCase( "^[^@]+@[^@]+\.[^@]+$", actual ) > 0 + ? true + : fail( "[#actual#] is not a valid email address" ) ) + }, + isUUID: function( actual ) { + return ( isValid( "uuid", actual ) + ? true + : fail( "[#actual#] is not a UUID" ) ) + } + } ) +} + +function testEmailValidator() { + $assert.isValidEmail( "alice@example.com" ) + $assert.isValidEmail( "not-an-email" ) // will fail +} +``` + +For reusable assertion libraries, register a class path or instance: + +```boxlang +function beforeTests() { + addAssertions( "tests.helpers.CustomAssertions" ) + // or + addAssertions( new tests.helpers.CustomAssertions() ) +} +``` + +--- + +## Key Differences: xUnit vs BDD + +| Aspect | xUnit | BDD | +|---|---|---| +| Test declaration | `function testXxx()` | `it( "...", () => {} )` | +| Suite declaration | Class-level | `describe( "...", () => {} )` | +| Lifecycle | `beforeTests/setup/teardown/afterTests` | `beforeAll/beforeEach/afterEach/afterAll/aroundEach` | +| Assertions | `$assert.isXxx()` | `expect().toBeXxx()` | +| Skip | `skip` function attribute | `xit()`, `skip()` inline | +| Nesting | Not supported | Unlimited nested `describe` blocks | +| Data binding | Not supported | `it( data={} )` | + +--- + +## CommandBox Scaffolding + +```bash +# Create xUnit spec +coldbox create unit name=UserServiceTest open=true + +# Scaffold with specific model binding +coldbox create unit name=UserServiceTest methods=testCreate,testUpdate,testDelete +``` diff --git a/.claude/skills/coldbox-docbox-annotations b/.claude/skills/coldbox-docbox-annotations new file mode 120000 index 0000000..e31b329 --- /dev/null +++ b/.claude/skills/coldbox-docbox-annotations @@ -0,0 +1 @@ +../../.agents/skills/coldbox-docbox-annotations \ No newline at end of file diff --git a/.claude/skills/coldbox-docbox-generation b/.claude/skills/coldbox-docbox-generation new file mode 120000 index 0000000..9a91e9a --- /dev/null +++ b/.claude/skills/coldbox-docbox-generation @@ -0,0 +1 @@ +../../.agents/skills/coldbox-docbox-generation \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json index 09d691c..497fc36 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,30 @@ { "version": 1, "skills": { + "coldbox-docbox-annotations": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "docbox/docbox-annotations/SKILL.md", + "computedHash": "1499864b2714c8263f3c9e83dd33b07f2935e4aae72a1b6ed57e0a36d434daa4" + }, + "coldbox-docbox-generation": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "docbox/docbox-generation/SKILL.md", + "computedHash": "a60a9ff8c0118eb7a84e0b534a213281a0cf1d906cdafcfe50598fe6a42c5906" + }, + "coldbox-testing-coverage": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/testing-coverage/SKILL.md", + "computedHash": "2d3df4be2c592c0e1f0092f686c88e3a1fc178c4d6923076e851d054d8157963" + }, + "coldbox-testing-fixtures": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/testing-fixtures/SKILL.md", + "computedHash": "233f1b55bbef3c352082b59d04a37b6d081be8a5ed120916379b8ae2945d8816" + }, "commandbox-config-settings": { "source": "ortus-boxlang/skills", "sourceType": "github", @@ -54,6 +78,60 @@ "sourceType": "github", "skillPath": "commandbox/commandbox-usage/SKILL.md", "computedHash": "db689cb9f7f895a202ecae3986f24557ff0e57d4c614da6919678204e995dd87" + }, + "testbox-assertions": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/assertions/SKILL.md", + "computedHash": "ec20bffbbe0eb45daa80b2b960c09048831932fd1562862e844e074fa2625134" + }, + "testbox-bdd": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/bdd/SKILL.md", + "computedHash": "d49888ca485715b6afd8adfd3e0cb829fd667bf04917d341fb0783e830d386f2" + }, + "testbox-cbmockdata": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/cbmockdata/SKILL.md", + "computedHash": "82f94da5d77b1d83803fd706670eaedf7c34a425ccb608afc13c00e5487a1b72" + }, + "testbox-expectations": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/expectations/SKILL.md", + "computedHash": "2faeb1db2694c762290b3b3beaf6ac10a0bd03e77c6d1b503c6037997d8956fa" + }, + "testbox-listeners": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/listeners/SKILL.md", + "computedHash": "1d64d706323d0becf39b42ff570697ea65afb66d6e3e7bd71633ab03bf203b31" + }, + "testbox-mockbox": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/mockbox/SKILL.md", + "computedHash": "c20d9e2604700c482cf425cd97929e6920589cd9155a9a05f3b790b61370ba61" + }, + "testbox-reporters": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/reporters/SKILL.md", + "computedHash": "2074b9c0f23ce3e2758cc8281ab77e7df9886480d8793b35e809616a1b7fdc96" + }, + "testbox-runners": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/runners/SKILL.md", + "computedHash": "b40b6f5a82252fe2b2a4fefd5b0aae9a0162182604ee753f2862261cafa673dc" + }, + "testbox-unit-xunit": { + "source": "coldbox/skills", + "sourceType": "github", + "skillPath": "testbox/unit/SKILL.md", + "computedHash": "651ccb284e555dc7926701d82f1431aa42f6688b9c9d8441c6a17cd7b6625400" } } } From de71926870304c7b4b358eb699efff92e1ac6a7f Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Fri, 1 May 2026 12:03:00 -0400 Subject: [PATCH 04/42] more updates for ai agents Co-authored-by: Copilot --- .github/copilot-instructions.md | 100 ---------------- AGENTS.md | 199 ++++++++++++++++++++++++++++++++ CLAUDE.md | 3 + 3 files changed, 202 insertions(+), 100 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 564ce31..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,100 +0,0 @@ -# ColdBox CLI - AI Coding Instructions - -This is a CommandBox module (v7.10.0) providing CLI commands for ColdBox framework development. It generates scaffolded code for handlers, models, views, tests, and complete applications. **BoxLang is now the default language** for all new applications and generated code, with full CFML support available via the `--cfml` flag. - -## Architecture & Key Components - -**Command Structure**: Commands follow CommandBox's hierarchical structure in `/commands/coldbox/` with subcommands in nested folders (e.g., `create/handler.cfc`, `create/model.cfc`). Each command extends `BaseCommand.cfc` which provides common functionality like BoxLang detection and standardized print methods. - -**Template System**: Code generation uses text templates in `/templates/` with token replacement (e.g., `|handlerName|`, `|Description|`). Templates are organized by type and language: -- `/templates/modules/cfml/` - CFML templates -- `/templates/modules/bx/` - BoxLang templates -- `/templates/crud/cfml/` vs `/templates/crud/bx/` - Language-specific variants - -**BoxLang Detection**: The `isBoxLangProject()` method in `BaseCommand.cfc` detects BoxLang projects via: -1. Server engine detection (`serverInfo.cfengine` contains "boxlang") -2. Package.json `testbox.runner` setting -3. Package.json `language` property - -**Language Flags**: -- `--boxlang` - Force BoxLang generation (usually not needed as it's the default) -- `--cfml` - Force CFML generation (overrides BoxLang default) - -**Application Creation Features**: -- `coldbox create app-wizard` - Interactive wizard for creating applications -- `--migrations` - Include database migrations support -- `--docker` - Include Docker configuration and containerization -- `--vite` - Include Vite frontend asset building (modern/BoxLang templates) -- `--rest` - Configure as REST API application (BoxLang templates) - -**Code Style Conventions**: -- **Semicolons are optional** in CFML/BoxLang and should NOT be used in generated code except: - - When demarcating property declarations - - When required in inline component syntax - - Example: `property name="userService" inject="UserService";` (property with semicolon) - - Example: `var result = service.getData()` (no semicolon needed) - -- **Closure Scoping**: When using closures (arrow functions or anonymous functions), you CANNOT use the `arguments` scope to access outer function parameters - - ❌ WRONG: `guidelines.filter( ( g ) => g.name != arguments.name )` (will fail - arguments.name is not accessible) - - ✅ CORRECT: `guidelines.filter( ( g ) => g.name != name )` (remove scope prefix, variable will be found in outer scope) - - This applies to all scopes inside closures - use unscoped variable names to access outer function variables - - The closure will automatically search outer scopes for unscoped variables - - If there is ambiguity with a variable declared internally, then before the closure call you can assign the outer variable to a new variable with a different name and use that inside the closure - -**Markdown File Standards**: -- **Always lint markdown files after editing** - Run `npx markdownlint-cli -f {filename}` after any markdown file modifications -- Markdown linting configuration is in `.markdownlint.json` -- Fix any linting errors before committing changes - -## Development Workflows - -**Command Development**: -- New commands extend `BaseCommand.cfc` and use dependency injection (`property name="utility" inject="utility@coldbox-cli"`) -- Use standardized print methods: `printInfo()`, `printError()`, `printWarn()`, `printSuccess()` -- Commands support `--force` for overwriting and `--open` for opening generated files - -**CLI User Interface Guidelines**: - -Creating beautiful CLI interfaces enhances user experience. CommandBox provides rich output formatting capabilities: - -- **Print Helpers** - Color text, indentation, lines, boxes: -- **Tables** - Display data in formatted tables: -- **Columns** - Multi-column output layouts: -- **Trees** - Hierarchical tree structures: -- **Progress Bars** - Visual progress indicators: -- **Interactive Jobs** - User prompts, confirmations, selections: - -Use these tools to create polished, professional command interfaces that improve usability and provide clear visual feedback. - -**Template Management**: -- Templates use token replacement with `replaceNoCase(content, "|token|", value, "all")` -- BoxLang conversion uses `toBoxLangClass()` to transform `component` to `class` -- Resource generation supports both REST and standard handlers via template selection -- Modern templates (`boxlang`, `modern`) support additional features via flags: `--vite`, `--rest`, `--docker`, `--migrations` -- Default skeleton is now `boxlang` instead of `advanced` - -**Module Dependencies**: The module lazy-loads `testbox-cli` and `commandbox-migrations` via utility methods `ensureTestBoxModule()` and `ensureMigrationsModule()` only when needed. - -## Key Patterns & Conventions - -**File Generation Logic**: Commands typically: -1. Resolve and validate paths using `resolvePath()` -2. Read appropriate templates based on `--rest`, `--boxlang`, `--cfml` flags -3. Perform token replacements for customization -4. Create directories if they don't exist -5. Generate additional files (views, tests) based on flags -6. For app creation: apply feature flags (`--vite`, `--docker`, `--migrations`) to configure project - -**Cross-Component Integration**: -- Models can generate handlers via `--handler` flag -- Handlers can generate views via `--views` flag -- Resource commands generate full CRUD scaffolding -- Migration and seeder generation integrated with model creation - -**Error Handling**: Use `BaseCommand` print methods for consistent user feedback and check file existence before operations when `--force` is not specified. - -## Testing & Build - -**Build Process**: Uses `/build/Build.cfc` task runner with `box scripts` integration. Run `box build:module` for full build or `box format` for code formatting. - -**Template Testing**: The `/tests/` directory contains sample module structure for testing generated code patterns. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..846537f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,199 @@ +# ColdBox CLI - AI Coding Instructions + +This is a CommandBox module providing CLI commands for ColdBox framework development. It generates scaffolded code for handlers, models, views, tests, ORM entities, and complete applications. It also provides AI integration commands for managing agents, skills, guidelines, and MCP servers. **BoxLang is now the default language** for all new applications and generated code, with full CFML support available via the `--cfml` flag. + +## Architecture & Key Components + +**Command Structure**: Commands follow CommandBox's hierarchical structure in `/commands/coldbox/` with subcommands in nested folders: + +- **Core commands**: `apidocs.cfc`, `docs.cfc`, `help.cfc`, `reinit.cfc`, `watch-reinit.cfc` +- **Create subcommands** (`/commands/coldbox/create/`): `app.cfc`, `app-wizard.cfc`, `handler.cfc`, `model.cfc`, `view.cfc`, `layout.cfc`, `interceptor.cfc`, `service.cfc`, `resource.cfc`, `module.cfc`, `bdd.cfc`, `unit.cfc`, `integration-test.cfc`, `orm-crud.cfc`, `orm-entity.cfc`, `orm-event-handler.cfc`, `orm-service.cfc`, `orm-virtual-service.cfc` +- **AI subcommands** (`/commands/coldbox/ai/`): + - `agents/` — `active.cfc`, `add.cfc`, `list.cfc`, `open.cfc`, `remove.cfc` + - `skills/` — `create.cfc`, `find.cfc`, `install.cfc`, `list.cfc`, `override.cfc`, `refresh.cfc`, `remove.cfc` + - `guidelines/` — `add.cfc`, `create.cfc`, `list.cfc`, `override.cfc`, `refresh.cfc`, `remove.cfc` + - `mcp/` — `add.cfc`, `install.cfc`, `list.cfc`, `remove.cfc` + - `doctor.cfc`, `info.cfc`, `refresh.cfc`, `stats.cfc`, `tree.cfc`, `uninstall.cfc` + +Each command extends `BaseCommand.cfc` (or `BaseAICommand.cfc` for AI commands) which provides common functionality like BoxLang detection, layout detection, and standardized print methods. + +**Model Layer** (`/models/`): +- `BaseCommand.cfc` — Base for all commands: DI properties, `isBoxLangProject()`, `getAppPrefix()`, `getModulesPrefix()`, `resolveServerBaseUrl()`, and print helpers (`printInfo()`, `printError()`, `printWarn()`, `printSuccess()`) +- `BaseAICommand.cfc` — Extends `BaseCommand` for AI commands: `ensureInstalled()`, manifest read/write helpers, MCP JSON generation +- `Utility.cfc` — Shared utilities: template path resolution, `ensureTestBoxModule()`, `ensureMigrationsModule()`, `ensureBoxLangModule()`, module installation checks +- `AIService.cfc` — Central AI operations: install/uninstall AI integration, coordinate guidelines/skills/MCP/agents, manage `.agents/` directory and manifest +- `SkillManager.cfc` — Remote-first skill management: install/refresh skills from `skills.boxlang.io`, SHA-locked downloads, batch operations, orphan pruning +- `GuidelineManager.cfc` — Guideline management for AI coding standards +- `AgentRegistry.cfc` — Agent configuration registry (Claude, Copilot, Codex, Gemini, OpenCode) +- `MCPRegistry.cfc` — MCP server registry and `.mcp.json` generation + +**Template System**: Code generation uses text templates in `/templates/` with token replacement (e.g., `|handlerName|`, `|Description|`). Templates are organized by type and language: +- `/templates/modules/cfml/` — CFML module templates +- `/templates/modules/bx/` — BoxLang module templates +- `/templates/crud/cfml/` vs `/templates/crud/bx/` — Language-specific CRUD variants +- `/templates/ai/` — AI integration templates (agents, guidelines, skills) +- `/templates/docker/` — Docker configuration templates (`Dockerfile`, `docker-compose.yml`, `.dockerignore`) +- `/templates/orm/` — ORM templates (`ORMEventHandler.txt`, `TemplatedEntityService.txt`, `VirtualEntityService.txt`) +- `/templates/resources/` — Resource handler templates +- `/templates/rest/` — REST API templates (router, handlers, models, specs, config, apidocs) +- `/templates/testing/` — Test templates (BDD, TestCase, Handler/Interceptor/Model tests) +- `/templates/vite/` — Vite frontend templates (`vite.config.mjs`, `package.json`, `babelrc`, assets, layouts) + +**BoxLang Detection**: The `isBoxLangProject()` method in `BaseCommand.cfc` detects BoxLang projects via: +1. Server engine detection (`serverInfo.cfengine` contains "boxlang") +2. Package.json `testbox.runner` setting +3. Package.json `language` property + +**Layout Detection**: The `Utility.detectTemplateType()` method distinguishes between: +- **Modern layout** — `app/` and `public/` directories exist; code lives under `app/` +- **Flat layout** — Traditional structure with code at project root + +**Language Flags**: +- `--boxlang` — Force BoxLang generation (usually not needed as it's the default) +- `--cfml` — Force CFML generation (overrides BoxLang default) + +**Application Creation Features**: +- `coldbox create app` — Quick app creation with skeleton selection +- `coldbox create app-wizard` — Interactive wizard for creating applications +- `--migrations` — Include database migrations support +- `--docker` — Include Docker configuration and containerization +- `--vite` — Include Vite frontend asset building (modern/BoxLang templates) +- `--rest` — Configure as REST API application (BoxLang templates) + +**AI Integration Features**: +- `coldbox ai install` — Install AI integration (`.agents/` directory, manifest, guidelines, skills) +- `coldbox ai refresh` — Refresh all AI assets from remote sources +- `coldbox ai uninstall` — Remove AI integration +- `coldbox ai info` / `coldbox ai stats` / `coldbox ai tree` / `coldbox ai doctor` — Diagnostics and status +- `coldbox ai agents` — Manage AI agent configurations (add, list, remove, open, active) +- `coldbox ai skills` — Manage skills (install, find, list, refresh, remove, override, create) +- `coldbox ai guidelines` — Manage coding guidelines (add, list, refresh, remove, override, create) +- `coldbox ai mcp` — Manage MCP servers (install, list, add, remove) + +**AI Assets Directory** (`.agents/`): +- `.agents/skills/` — Installed skills from `skills.boxlang.io` and other sources. Each skill is a folder containing a `SKILL.md` file (e.g., `.agents/skills/coldbox-docbox-annotations/SKILL.md`) +- `.agents/guidelines/` — AI coding guidelines and standards +- `.agents/agents/` — Agent-specific configurations (Claude, Copilot, etc.) +- `.agents/manifest.json` — Tracks installed skills, guidelines, agents, and their versions/SHAs + +**Module Settings** (from `ModuleConfig.cfc`): +- `templatesPath` — Absolute path to `/templates/` +- `skillsRegistryUrl` — `https://skills.boxlang.io` +- `coldboxSkillsRepo` — `{ owner: "coldbox", repo: "skills" }` +- `boxlangSkillsRepo` — `{ owner: "ortus-boxlang", repo: "skills" }` +- `ortusSkillsRepo` — `{ owner: "ortus-solutions", repo: "skills" }` + +**Code Style Conventions**: +- **Semicolons are optional** in CFML/BoxLang and should NOT be used in generated code except: + - When demarcating property declarations + - When required in inline component syntax + - Example: `property name="userService" inject="UserService";` (property with semicolon) + - Example: `var result = service.getData()` (no semicolon needed) + +- **Closure Scoping**: When using closures (arrow functions or anonymous functions), you CANNOT use the `arguments` scope to access outer function parameters + - ❌ WRONG: `guidelines.filter( ( g ) => g.name != arguments.name )` (will fail - arguments.name is not accessible) + - ✅ CORRECT: `guidelines.filter( ( g ) => g.name != name )` (remove scope prefix, variable will be found in outer scope) + - This applies to all scopes inside closures - use unscoped variable names to access outer function variables + - The closure will automatically search outer scopes for unscoped variables + - If there is ambiguity with a variable declared internally, then before the closure call you can assign the outer variable to a new variable with a different name and use that inside the closure + +**Markdown File Standards**: +- **Always lint markdown files after editing** - Run `npx markdownlint-cli -f {filename}` after any markdown file modifications +- Markdown linting configuration is in `.markdownlint.json` +- Fix any linting errors before committing changes + +## Development Workflows + +**Command Development**: +- New commands extend `BaseCommand.cfc` and use dependency injection (`property name="utility" inject="utility@coldbox-cli"`) +- AI commands extend `BaseAICommand.cfc` which adds AI-specific helpers +- Use standardized print methods: `printInfo()`, `printError()`, `printWarn()`, `printSuccess()` +- Commands support `--force` for overwriting and `--open` for opening generated files + +**CLI User Interface Guidelines**: + +Creating beautiful CLI interfaces enhances user experience. CommandBox provides rich output formatting capabilities: + +- **Print Helpers** - Color text, indentation, lines, boxes: +- **Tables** - Display data in formatted tables: +- **Columns** - Multi-column output layouts: +- **Trees** - Hierarchical tree structures: +- **Progress Bars** - Visual progress indicators: +- **Interactive Jobs** - User prompts, confirmations, selections: + +Use these tools to create polished, professional command interfaces that improve usability and provide clear visual feedback. + +**Template Management**: +- Templates use token replacement with `replaceNoCase(content, "|token|", value, "all")` +- BoxLang conversion uses `toBoxLangClass()` to transform `component` to `class` +- Resource generation supports both REST and standard handlers via template selection +- Modern templates (`boxlang`, `modern`) support additional features via flags: `--vite`, `--rest`, `--docker`, `--migrations` +- Default skeleton is now `boxlang` instead of `advanced` + +**Module Dependencies**: The module declares dependencies in `box.json`: +- `testbox-cli` — TestBox CLI integration +- `commandbox-migrations` — Database migration support +- `commandbox-boxlang` — BoxLang language support +- Dev dependencies: `commandbox-cfformat`, `commandbox-docbox` + +The module lazy-loads `testbox-cli` and `commandbox-migrations` via utility methods `ensureTestBoxModule()` and `ensureMigrationsModule()` only when needed. + +## Key Patterns & Conventions + +**File Generation Logic**: Commands typically: +1. Resolve and validate paths using `resolvePath()` +2. Detect layout type (modern vs flat) via `Utility.detectTemplateType()` +3. Read appropriate templates based on `--rest`, `--boxlang`, `--cfml` flags +4. Perform token replacements for customization +5. Create directories if they don't exist +6. Generate additional files (views, tests) based on flags +7. For app creation: apply feature flags (`--vite`, `--docker`, `--migrations`) to configure project + +**Cross-Component Integration**: +- Models can generate handlers via `--handler` flag +- Handlers can generate views via `--views` flag +- Resource commands generate full CRUD scaffolding +- Migration and seeder generation integrated with model creation +- ORM commands generate entities, services, virtual services, event handlers, and full CRUD + +**Error Handling**: Use `BaseCommand` print methods for consistent user feedback and check file existence before operations when `--force` is not specified. + +## Testing & Build + +**Build Scripts** (from `box.json`): +- `box build:module` — Full module build via `/build/Build.cfc` +- `box build:docs` — Generate documentation via DocBox +- `box format` — Run CFFormat on commands, models, build, and ModuleConfig.cfc +- `box format:watch` — Watch and auto-format on file changes +- `box format:check` — Check formatting without modifying files +- `box release` — Run release recipe (`build/release.boxr`) + +**Template Testing**: The `/tests/` directory contains sample module structure for testing generated code patterns. The `/testapp/` directory contains a full test application with modern layout. handlers via template selection +- Modern templates (`boxlang`, `modern`) support additional features via flags: `--vite`, `--rest`, `--docker`, `--migrations` +- Default skeleton is now `boxlang` instead of `advanced` + +**Module Dependencies**: The module lazy-loads `testbox-cli` and `commandbox-migrations` via utility methods `ensureTestBoxModule()` and `ensureMigrationsModule()` only when needed. + +## Key Patterns & Conventions + +**File Generation Logic**: Commands typically: +1. Resolve and validate paths using `resolvePath()` +2. Read appropriate templates based on `--rest`, `--boxlang`, `--cfml` flags +3. Perform token replacements for customization +4. Create directories if they don't exist +5. Generate additional files (views, tests) based on flags +6. For app creation: apply feature flags (`--vite`, `--docker`, `--migrations`) to configure project + +**Cross-Component Integration**: +- Models can generate handlers via `--handler` flag +- Handlers can generate views via `--views` flag +- Resource commands generate full CRUD scaffolding +- Migration and seeder generation integrated with model creation + +**Error Handling**: Use `BaseCommand` print methods for consistent user feedback and check file existence before operations when `--force` is not specified. + +## Testing & Build + +**Build Process**: Uses `/build/Build.cfc` task runner with `box scripts` integration. Run `box build:module` for full build or `box format` for code formatting. + +**Template Testing**: The `/tests/` directory contains sample module structure for testing generated code patterns. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..01ccb06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Instructions + +For AI coding instructions and project conventions, see [AGENTS.md](./AGENTS.md). From f6f93599987dd1577b069c550b3388c402fbb0f8 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Fri, 1 May 2026 12:52:43 -0400 Subject: [PATCH 05/42] change to bxformat --- .cfformat.json => .bxformat.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .cfformat.json => .bxformat.json (100%) diff --git a/.cfformat.json b/.bxformat.json similarity index 100% rename from .cfformat.json rename to .bxformat.json From 7fb052e6c457dee0d8c1c868398136d8c89af9ac Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Fri, 1 May 2026 17:13:10 -0400 Subject: [PATCH 06/42] - **VSCode Copilot MCP Mirroring** --- changelog.md | 7 +++++++ models/AIService.cfc | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/changelog.md b/changelog.md index 3eec158..a80e382 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **VSCode Copilot MCP Mirroring** + - When Copilot is a configured agent, MCP server configuration is now mirrored to `.vscode/mcp.json` using the VSCode-specific schema (`"servers"` + `"inputs": []`) + - Ensures GitHub Copilot agents in VSCode can discover MCP servers registered via `coldbox ai mcp` commands + - `.vscode/mcp.json` is written alongside the root `.mcp.json` whenever `generateMCPJson()` runs (install, refresh, MCP add/remove) + ## [8.11.0] - 2026-04-28 ### Changed diff --git a/models/AIService.cfc b/models/AIService.cfc index 2611b7c..2af716b 100644 --- a/models/AIService.cfc +++ b/models/AIService.cfc @@ -678,6 +678,21 @@ component singleton { json: mcpJson ); + // Mirror to .vscode/mcp.json when copilot is a configured agent + var agents = arguments.manifest.agents ?: [] + if ( agents.findNoCase( "copilot" ) ) { + var vscodeMcpJson = { + "servers" : mcpJson.mcpServers, + "inputs" : [] + } + var vscodeDir = arguments.directory & "/.vscode"; + directoryCreate( vscodeDir, true, true ); + variables.JSONService.writeJSONFile( + path: vscodeDir & "/mcp.json", + json: vscodeMcpJson + ) + } + return this; } From 85e3bf66ad58db15cd21e21dbc4e23e4233a643e Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Fri, 1 May 2026 17:22:45 -0400 Subject: [PATCH 07/42] more udpates on guidelines --- templates/ai/guidelines/core/boxlang.md | 59 +++++++++++++++++++------ 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/templates/ai/guidelines/core/boxlang.md b/templates/ai/guidelines/core/boxlang.md index 6ed9dc2..ae56dde 100644 --- a/templates/ai/guidelines/core/boxlang.md +++ b/templates/ai/guidelines/core/boxlang.md @@ -14,19 +14,30 @@ BoxLang is a modern, dynamic JVM language that compiles to Java bytecode. It com - **Modern class syntax** - Uses `class` instead of `component` - **Dynamic typing** - Optional type declarations with type inference - **Full Java interoperability** - Direct access to Java libraries and classes -- **Lambda expressions** - Arrow function syntax `() => result` +- **Closure expressions** - Arrow function syntax `() => result` carries the external state of its definition context +- **Lambda expressions** - Arrow function syntax `() -> result` carries no external state, using only its parameters - **Streams API** - Functional data processing - **Low verbosity** - Minimal ceremony, highly readable code -- **Multiple runtimes** - Web servers, CLI, AWS Lambda, Docker +- **Multiple runtimes** - Web servers, CLI, AWS Lambda, Docker, and more ## Class Syntax ### Basic Class Structure +Annotations are supported in Boxlang using the `@` symbol. The `@inject` annotation is used for dependency injection by a framework. The annotation can be applied to classes, properties and functions. They can have no value, or the value within `( )` can be a boolean, integer, string, array or struct. + ```boxlang -class UserService { - property name="userDAO" inject; - property name="log" inject="logbox:logger:{this}"; +@singleton +@anotherAnnotation( true ) +@anotherOne( "value" ) +@arrayAnnotation( [ "item1", "item2" ] ) +@structAnnotation( { key: "value" } ) +class{ + @inject + property name="userDAO"; + + @inject( "logbox:logger:{this}" ) + property name="log"; function getAll() { return userDAO.findAll() @@ -47,10 +58,12 @@ class UserService { ```boxlang // Auto-inject by name -property name="userService" inject; +@inject +property name="userService"; // Explicit injection -property name="cache" inject="cachebox:default"; +@inject( "cachebox:default" ) +property name="cache"; // Typed properties property name="count" type="numeric"; @@ -63,7 +76,7 @@ property name="status" type="string" default="pending"; ### Constructors ```boxlang -class User { +class { property name="firstName"; property name="lastName"; @@ -82,10 +95,12 @@ class User { ### Accessors +Automatic getters and setters are supported by default. You can enable or disable them at the class level with the `@accessors` annotation, but they are on by default for all properties. You can also define custom getters and setters if you need custom logic. + +Implicit invokers are also on by default, allowing you to call getters and setters as if they were properties or functions without the `get`/`set` prefix. + ```boxlang -// Enable automatic getters/setters -@accessors true -class User { +class { property name="firstName"; property name="lastName"; property name="email"; @@ -93,9 +108,14 @@ class User { // Usage user = new User() +// Use setters and getters user.setFirstName( "Luis" ) user.setLastName( "Majano" ) var name = user.getFirstName() +// Use implicit invokers +user.firstName = "Luis" +user.lastName = "Majano" +var name = user.firstName ``` ## Lambda Expressions @@ -189,6 +209,12 @@ var result = stringBuffer.toString() // Using new operator var uuid = new java:java.util.UUID.randomUUID() var dateFormatter = new java:java.text.SimpleDateFormat( "yyyy-MM-dd" ) + +// Importing Java classes +import java.util.ArrayList +var list = new ArrayList() +list.add( "item1" ) +list.add( "item2" ) ``` ### Java Casting @@ -199,16 +225,21 @@ var intValue = javaCast( "int", 42 ) var longValue = javaCast( "long", 1000000 ) var boolValue = javaCast( "boolean", true ) +// Use the castAs is prefferred. +var intValue = 42 castAs int +var longValue = 1000000 castAs long +var boolValue = true castAs boolean + // Array casting -var javaArray = javaCast( "java.lang.Object[]", [ 1, 2, 3 ] ) +var javaArray = [ 1, 2, 3 ] castAs java.lang.Object[] ``` ### Using Java Libraries ```boxlang // Import Java classes -import java:java.util.ArrayList; -import java:java.util.HashMap; +import java:java.util.ArrayList +import java:java.util.HashMap class DataProcessor { function processData() { From eb0359aca1dd220d02c8ce45ca4b819cb9473b1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:04:14 +0000 Subject: [PATCH 08/42] Initial plan From 214c62f26f7678fe615854e0837710384ace5c73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:12:08 +0000 Subject: [PATCH 09/42] feat: separate custom skills into skills-custom/ folder with dedicated manifest section Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/bd6c5d76-044f-4462-b8fb-459c2651fc07 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- commands/coldbox/ai/skills/create.cfc | 4 +- commands/coldbox/ai/skills/list.cfc | 18 +- commands/coldbox/ai/skills/override.cfc | 4 +- commands/coldbox/ai/skills/remove.cfc | 2 +- models/AIService.cfc | 30 +-- models/AgentRegistry.cfc | 13 +- models/SkillManager.cfc | 194 ++++++++++++------ .../ai/agents/agent-flat-instructions.md | 32 +-- .../ai/agents/agent-modern-instructions.md | 24 ++- 9 files changed, 209 insertions(+), 112 deletions(-) diff --git a/commands/coldbox/ai/skills/create.cfc b/commands/coldbox/ai/skills/create.cfc index 553bd0c..b4e37b8 100644 --- a/commands/coldbox/ai/skills/create.cfc +++ b/commands/coldbox/ai/skills/create.cfc @@ -1,6 +1,6 @@ /** * Create a custom skill template - * Scaffolds a new skill in .agents/skills/{name}/ + * Scaffolds a new skill in .agents/skills-custom/{name}/ * * Examples: * coldbox ai skills create api-development @@ -44,7 +44,7 @@ component extends="coldbox-cli.models.BaseAICommand" { printInfo( "Creating custom skill: #arguments.name# (#uCase( language )#)" ) // Check if already exists - var skillPath = skillManager.getSkillsDirectory( arguments.directory ) & "/#arguments.name#/SKILL.md" + var skillPath = skillManager.getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#/SKILL.md" if ( fileExists( skillPath ) ) { printError( "Skill '#arguments.name#' already exists at:" ) printError( " #skillPath#" ) diff --git a/commands/coldbox/ai/skills/list.cfc b/commands/coldbox/ai/skills/list.cfc index 10a50ba..f5fa40f 100644 --- a/commands/coldbox/ai/skills/list.cfc +++ b/commands/coldbox/ai/skills/list.cfc @@ -37,28 +37,32 @@ component extends="coldbox-cli.models.BaseAICommand" { printSuccess( "All skills are up to date." ) return } - info.skills = info.skills.filter( ( s ) => staleNames.find( s.name ) > 0 ) + info.skills = info.skills.filter( ( s ) => staleNames.find( s.name ) > 0 ) + info.customSkills = [] // custom skills have no remote SHA, cannot be outdated printWarn( "#staleNames.len()# skill(s) have updates available:" ) print.line() } - if ( info.skills.isEmpty() ) { + if ( info.skills.isEmpty() && info.customSkills.isEmpty() ) { printWarn( "No skills installed. Run 'coldbox ai skills install --list' to browse the registry." ) return } - // Group by owner/repo (custom skills get bucket "custom") + // Group by owner/repo (custom skills in their own bucket from customSkills manifest section) var groups = {} info.skills.each( ( skill ) => { - var bucket = ( skill.type ?: "" ) == "custom" - ? "custom" - : ( ( skill.owner ?: "" ) != "" ? "#skill.owner#/#skill.repo#" : "unknown" ) + var bucket = ( skill.owner ?: "" ) != "" ? "#skill.owner#/#skill.repo#" : "unknown" if ( !groups.keyExists( bucket ) ) { groups[ bucket ] = [] } groups[ bucket ].append( skill ) } ) + // Add custom skills from manifest.customSkills + if ( info.customSkills.len() ) { + groups[ "custom" ] = info.customSkills + } + // Sort groups: custom last, then alphabetical var groupKeys = groups .keyArray() @@ -105,7 +109,7 @@ component extends="coldbox-cli.models.BaseAICommand" { // Summary print.line() - printInfo( "Total: #info.skills.len()# skill(s) installed" ) + printInfo( "Total: #info.skills.len() + info.customSkills.len()# skill(s) installed" ) print.line() if ( !outdated ) { diff --git a/commands/coldbox/ai/skills/override.cfc b/commands/coldbox/ai/skills/override.cfc index e16c1de..703526b 100644 --- a/commands/coldbox/ai/skills/override.cfc +++ b/commands/coldbox/ai/skills/override.cfc @@ -48,8 +48,8 @@ component extends="coldbox-cli.models.BaseAICommand" { var skill = existing[ 1 ] - // Check if override already exists (flat path) - var overridePath = skillManager.getSkillsDirectory( arguments.directory ) & "/#arguments.name#/SKILL.md" + // Check if override already exists (in skills-custom/) + var overridePath = skillManager.getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#/SKILL.md" if ( fileExists( overridePath ) ) { printWarn( "Skill '#arguments.name#' already exists at:" ) printWarn( " #overridePath#" ) diff --git a/commands/coldbox/ai/skills/remove.cfc b/commands/coldbox/ai/skills/remove.cfc index 5d0ee7d..a179c24 100644 --- a/commands/coldbox/ai/skills/remove.cfc +++ b/commands/coldbox/ai/skills/remove.cfc @@ -1,6 +1,6 @@ /** * Remove a skill from the project by name. - * Skills are stored in the flat .agents/skills/{name}/ directory. + * Checks both .agents/skills/{name}/ and .agents/skills-custom/{name}/ directories. * * Examples: * coldbox ai skills remove boxlang-syntax diff --git a/models/AIService.cfc b/models/AIService.cfc index 2af716b..25ce026 100644 --- a/models/AIService.cfc +++ b/models/AIService.cfc @@ -70,6 +70,7 @@ component singleton { "templateType" : templateType, "guidelines" : [], "skills" : [], + "customSkills" : [], "agents" : listToArray( arguments.agents ), "mcpServers" : { "core" : [], @@ -258,6 +259,7 @@ component singleton { "lastSync" : manifest.lastSync ?: "never", "guidelines" : manifest.guidelines ?: [], "skills" : manifest.skills ?: [], + "customSkills" : manifest.customSkills ?: [], "agents" : manifest.agents ?: [], "mcpServers" : manifest.mcpServers ?: { "core" : [], @@ -439,7 +441,8 @@ component singleton { "#aiDir#/guidelines", "#aiDir#/guidelines/core", "#aiDir#/guidelines/custom", - "#aiDir#/skills" + "#aiDir#/skills", + "#aiDir#/skills-custom" ]; dirs.each( ( dir ) => { @@ -474,10 +477,10 @@ component singleton { "onDemandSize" : 0 }, "skills" : { - "total" : info.skills.len(), + "total" : info.skills.len() + info.customSkills.len(), "core" : 0, "module" : 0, - "custom" : 0, + "custom" : info.customSkills.len(), "override" : 0, "totalSize" : 0, "avgSize" : 0 @@ -565,20 +568,23 @@ component singleton { stats.skills.override++; } else if ( source == "core" ) { stats.skills.core++; - } else if ( source == "custom" || type == "custom" ) { - stats.skills.custom++; } else { stats.skills.module++; } } ); - // Skills size (all on-demand) - var skillsDir = aiDir & "/skills"; + // Skills size (all on-demand) — includes both skills/ and skills-custom/ + var skillsDir = aiDir & "/skills"; + var customSkillsDir = aiDir & "/skills-custom"; + var skillsSize = 0; if ( directoryExists( skillsDir ) ) { - var skillsSize = calculateDirectorySize( skillsDir ); - stats.skills.totalSize = skillsSize; - stats.skills.avgSize = stats.skills.total > 0 ? int( skillsSize / stats.skills.total ) : 0; + skillsSize += calculateDirectorySize( skillsDir ); } + if ( directoryExists( customSkillsDir ) ) { + skillsSize += calculateDirectorySize( customSkillsDir ); + } + stats.skills.totalSize = skillsSize; + stats.skills.avgSize = stats.skills.total > 0 ? int( skillsSize / stats.skills.total ) : 0; // Count MCP servers var mcpServers = manifest.mcpServers ?: { @@ -607,10 +613,10 @@ component singleton { // Inlined guidelines (part of base context, shown separately for clarity) stats.contextEstimate.inlinedKB = int( stats.guidelines.inlinedSize / 1024 ); // On-demand resources (not in base context, but available) - stats.contextEstimate.onDemandKB = int( ( stats.guidelines.onDemandSize + stats.skills.totalSize ) / 1024 ); + stats.contextEstimate.onDemandKB = int( ( stats.guidelines.onDemandSize + skillsSize ) / 1024 ); // Total available if all resources were loaded stats.contextEstimate.totalAvailableKB = int( - ( stats.agents.filesSize + stats.guidelines.onDemandSize + stats.skills.totalSize ) / 1024 + ( stats.agents.filesSize + stats.guidelines.onDemandSize + skillsSize ) / 1024 ); return stats; diff --git a/models/AgentRegistry.cfc b/models/AgentRegistry.cfc index fefcb22..02da38a 100644 --- a/models/AgentRegistry.cfc +++ b/models/AgentRegistry.cfc @@ -710,7 +710,10 @@ component singleton { var aiService = variables.wirebox.getInstance( "AIService@coldbox-cli" ) var manifest = aiService.loadManifest( arguments.directory ) - if ( !structKeyExists( manifest, "skills" ) || !manifest.skills.len() ) { + var hasSkills = structKeyExists( manifest, "skills" ) && manifest.skills.len() + var hasCustomSkills = structKeyExists( manifest, "customSkills" ) && manifest.customSkills.len() + + if ( !hasSkills && !hasCustomSkills ) { return "No skills installed yet. Run 'coldbox ai install' to get started." } @@ -726,9 +729,9 @@ component singleton { } var content = [] - var coreSkills = manifest.skills.filter( ( s ) => s.source == "core" ) - var moduleSkills = manifest.skills.filter( ( s ) => s.source != "core" && s.source != "custom" ) - var customSkills = manifest.skills.filter( ( s ) => s.source == "custom" ) + var coreSkills = hasSkills ? manifest.skills.filter( ( s ) => s.source == "core" ) : [] + var moduleSkills = hasSkills ? manifest.skills.filter( ( s ) => s.source != "core" && s.source != "custom" ) : [] + var customSkills = manifest.customSkills ?: [] // Helper: group skills by prefix and append formatted output to content var appendGroupedSkills = ( skills, sectionLabel ) => { @@ -772,7 +775,7 @@ component singleton { appendGroupedSkills( moduleSkills, "Module Skills" ) appendGroupedSkills( customSkills, "Custom Skills" ) - content.append( "**To load a skill:** Use `read_file` on `.ai/skills/{skill-name}/SKILL.md` (e.g., `.ai/skills/coldbox-handler-development/SKILL.md`)." ) + content.append( "**To load a skill:** Use `read_file` on `.agents/skills/{skill-name}/SKILL.md` (e.g., `.agents/skills/coldbox-handler-development/SKILL.md`) for core skills, or `.agents/skills-custom/{skill-name}/SKILL.md` for custom project skills." ) return content.toList( chr( 10 ) ) } diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index fbb987d..eebce87 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -1,16 +1,26 @@ /** - * Manages AI skills — remote-first, SHA-locked, flat storage. + * Manages AI skills — remote-first, SHA-locked storage. * - * Skills are downloaded from skills.boxlang.io and stored at: + * Core/framework skills are downloaded from skills.boxlang.io and stored at: * {project}/.agents/skills/{name}/SKILL.md * - * The manifest records sha (from registry), owner, repo, path, and syncedAt. + * Project-authored (custom) skills are stored separately at: + * {project}/.agents/skills-custom/{name}/SKILL.md + * + * This separation allows .gitignore to ignore the auto-managed skills/ folder while + * safely committing project-specific skills in skills-custom/. + * + * The manifest records sha (from registry), owner, repo, path, and syncedAt for + * core/framework skills in manifest.skills[]. + * Custom skills are tracked in a separate manifest.customSkills[] array + * that is NOT SHA-hash-managed. + * * On refresh, stale skills (sha mismatch) are re-downloaded; orphaned module * skills (removed from box.json) are pruned automatically. * * Multi-directory lookup order for agent instructions: - * 1. .ai/skills/{name}/SKILL.md (coldbox-cli managed) - * 2. .agents/skills/{name}/SKILL.md + * 1. .agents/skills/{name}/SKILL.md (coldbox-cli managed) + * 2. .agents/skills-custom/{name}/SKILL.md (project custom skills) * 3. .claude/skills/{name}/SKILL.md */ component singleton { @@ -387,8 +397,16 @@ component singleton { var slug = skill.slug ?: "" if ( ( skill.type ?: "" ) != "custom" && owner.len() && repo.len() && slug.len() ) { missingRemoteSkills.append( skill ) - } else { - missingCustomSkills.append( skill.name ) + } + } + } + + // Check for deleted custom skills (in customSkills manifest section) + if ( structKeyExists( arguments.manifest, "customSkills" ) ) { + for ( var customSkill in arguments.manifest.customSkills ) { + var customSkillFile = getCustomSkillFilePath( arguments.directory, customSkill.name ) + if ( isNull( customSkillFile ) ) { + missingCustomSkills.append( customSkill.name ) } } } @@ -448,22 +466,28 @@ component singleton { // Remove custom skills whose files were deleted by the user missingCustomSkills.each( ( name ) => { variables.print.yellowLine( " 🧹 Removing deleted custom skill entry: #name#" ).toConsole() - manifest.skills = manifest.skills.filter( ( s ) => s.name != name ) + if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { + arguments.manifest[ "customSkills" ] = [] + } + arguments.manifest.customSkills = arguments.manifest.customSkills.filter( ( s ) => s.name != name ) changes.removed.append( name ) } ) // ------------------------------------------------------------------ - // 5. Sync custom skills from .ai/skills/ that aren't in manifest yet + // 5. Sync custom skills from .agents/skills-custom/ that aren't in manifest yet // ------------------------------------------------------------------ - var skillsDir = getSkillsDirectory( arguments.directory ) - if ( directoryExists( skillsDir ) ) { - directoryList( skillsDir, false, "name" ).each( ( dirName ) => { - var skillFilePath = skillsDir & "/" & dirName & "/SKILL.md" + if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { + arguments.manifest[ "customSkills" ] = [] + } + var customSkillsDir = getCustomSkillsDirectory( arguments.directory ) + if ( directoryExists( customSkillsDir ) ) { + directoryList( customSkillsDir, false, "name" ).each( ( dirName ) => { + var skillFilePath = customSkillsDir & "/" & dirName & "/SKILL.md" if ( !fileExists( skillFilePath ) ) { return; } - var alreadyInManifest = manifest.skills.filter( ( s ) => s.name == dirName ).len() > 0 + var alreadyInManifest = arguments.manifest.customSkills.filter( ( s ) => s.name == dirName ).len() > 0 if ( alreadyInManifest ) { return; } @@ -474,15 +498,9 @@ component singleton { var parsed = variables.utility.parseFrontmatter( content ) var description = parsed.frontmatter.description ?: "" - manifest.skills.append( { + arguments.manifest.customSkills.append( { "name" : dirName, - "owner" : "", - "repo" : "", - "path" : "", - "sha" : "", "description" : description, - "type" : "custom", - "source" : "custom", "syncedAt" : dateTimeFormat( now(), "iso" ) } ) changes.added.append( dirName ) @@ -664,8 +682,8 @@ component singleton { /** * Return the absolute path to a skill's SKILL.md file, checking three locations: - * 1. {directory}/.agents/skills/{name}/SKILL.md - * 2. {directory}/.agents/skills/{name}/SKILL.md + * 1. {directory}/.agents/skills/{name}/SKILL.md (core/framework skills) + * 2. {directory}/.agents/skills-custom/{name}/SKILL.md (project custom skills) * 3. {directory}/.claude/skills/{name}/SKILL.md * * @directory The project directory @@ -677,9 +695,11 @@ component singleton { required string directory, required string name ){ - var skillsDirectory = getSkillsDirectory( arguments.directory ) - var candidates = [ + var skillsDirectory = getSkillsDirectory( arguments.directory ) + var customSkillsDirectory = getCustomSkillsDirectory( arguments.directory ) + var candidates = [ skillsDirectory & "/#arguments.name#/SKILL.md", + customSkillsDirectory & "/#arguments.name#/SKILL.md", "#arguments.directory#/.claude/skills/#arguments.name#/SKILL.md" ] for ( var candidate in candidates ) { @@ -689,7 +709,24 @@ component singleton { } /** - * Create a custom skill from template in the flat .ai/skills/{name}/ directory. + * Return the absolute path to a custom skill's SKILL.md file. + * Only checks {directory}/.agents/skills-custom/{name}/SKILL.md. + * + * @directory The project directory + * @name The skill name (directory name) + * + * @return Absolute path string, or null if not found + */ + function getCustomSkillFilePath( + required string directory, + required string name + ){ + var candidate = getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#/SKILL.md" + return fileExists( candidate ) ? candidate : javacast( "null", "" ) + } + + /** + * Create a custom skill from template in the .agents/skills-custom/{name}/ directory. * * @directory The project directory * @name The custom skill name @@ -700,7 +737,7 @@ component singleton { required string name, string language = "boxlang" ){ - var targetDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" + var targetDir = getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#" var skillFile = "#targetDir#/SKILL.md" if ( !directoryExists( targetDir ) ) { @@ -719,22 +756,19 @@ component singleton { fileWrite( skillFile, template ) var manifest = variables.aiService.loadManifest( arguments.directory ); - manifest.skills.append( { + if ( !structKeyExists( manifest, "customSkills" ) ) { + manifest[ "customSkills" ] = [] + } + manifest.customSkills.append( { "name" : arguments.name, - "owner" : "", - "repo" : "", - "path" : "", - "sha" : "", "description" : "", - "type" : "custom", - "source" : "custom", "syncedAt" : dateTimeFormat( now(), "iso" ) } ) variables.aiService.saveManifest( arguments.directory, manifest ) } /** - * Check if a skill exists in any of the three skill directories. + * Check if a skill exists in any of the skill directories (skills/ or skills-custom/). * * @directory The project directory * @name The skill name (directory name) @@ -745,12 +779,13 @@ component singleton { required string directory, required string name ){ - var skillDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" - return directoryExists( skillDir ) + var skillDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" + var customSkillDir = getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#" + return directoryExists( skillDir ) || directoryExists( customSkillDir ) } /** - * Remove a skill from the project (flat path). + * Remove a skill from the project (checks skills/ then skills-custom/). * * @directory The project directory * @name The skill name to remove @@ -762,27 +797,37 @@ component singleton { required string directory, required string name ){ - var skillDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" + var skillDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" + var customSkillDir = getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#" - if ( !directoryExists( skillDir ) ) { + if ( !directoryExists( skillDir ) && !directoryExists( customSkillDir ) ) { throw( type = "SkillManager.SkillNotFound", - message = "Skill '#arguments.name#' not found at: #skillDir#" + message = "Skill '#arguments.name#' not found at: #skillDir# or #customSkillDir#" ) } - // Delete the skill directory and all its contents - directoryDelete( skillDir, true ) - // Remove from manifest + // Delete whichever directory exists + if ( directoryExists( skillDir ) ) { + directoryDelete( skillDir, true ) + } + if ( directoryExists( customSkillDir ) ) { + directoryDelete( customSkillDir, true ) + } + + // Remove from manifest (both skills and customSkills sections) var manifest = variables.aiService.loadManifest( arguments.directory ) manifest.skills = manifest.skills.filter( ( s ) => s.name != name ) + if ( structKeyExists( manifest, "customSkills" ) ) { + manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name ) + } variables.aiService.saveManifest( arguments.directory, manifest ) return true } /** - * Create a skill override (custom copy) in the flat .ai/skills/{name}/ directory. + * Create a skill override (custom copy) in the .agents/skills-custom/{name}/ directory. * Sets type=custom, empty owner/repo so refresh skips it. * * @directory The project directory @@ -798,7 +843,7 @@ component singleton { if ( isNull( sourcePath ) ) { throw( type = "SkillManager.SkillNotFound", - message = "Skill '#arguments.name#' not found in .ai/skills/, .agents/skills/, or .claude/skills/" + message = "Skill '#arguments.name#' not found in .agents/skills/, .agents/skills-custom/, or .claude/skills/" ) } @@ -826,36 +871,39 @@ component singleton { "all" ) - var targetDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" + var targetDir = getCustomSkillsDirectory( arguments.directory ) & "/#arguments.name#" var targetFile = "#targetDir#/SKILL.md" if ( !directoryExists( targetDir ) ) directoryCreate( targetDir, true ) fileWrite( targetFile, content ) - // Find existing manifest entry + // Ensure customSkills section exists + if ( !structKeyExists( manifest, "customSkills" ) ) { + manifest[ "customSkills" ] = [] + } + + // Find existing customSkills entry for this skill var existingIndex = 0 - for ( var i = 1; i <= manifest.skills.len(); i++ ) { - if ( manifest.skills[ i ].name == arguments.name ) { + for ( var i = 1; i <= manifest.customSkills.len(); i++ ) { + if ( manifest.customSkills[ i ].name == arguments.name ) { existingIndex = i; break } } - var skillEntry = { + // Also remove from manifest.skills if it was there (migrating from old location) + manifest.skills = manifest.skills.filter( ( s ) => s.name != arguments.name ) + + var existingEntry = existingIndex ? manifest.customSkills[ existingIndex ] : {} + var skillEntry = { "name" : arguments.name, - "owner" : "", - "repo" : "", - "path" : "", - "sha" : "", - "description" : existingIndex ? ( manifest.skills[ existingIndex ].description ?: "" ) : "", - "type" : "custom", - "source" : "custom", + "description" : existingEntry.description ?: "", "syncedAt" : dateTimeFormat( now(), "iso" ) } if ( existingIndex ) { - manifest.skills[ existingIndex ] = skillEntry + manifest.customSkills[ existingIndex ] = skillEntry } else { - manifest.skills.append( skillEntry ) + manifest.customSkills.append( skillEntry ) } variables.aiService.saveManifest( arguments.directory, manifest ) @@ -886,6 +934,18 @@ component singleton { } } + // Also check custom skills + var customSkills = arguments.manifest.customSkills ?: [] + for ( var customSkill in customSkills ) { + var customSkillFile = getCustomSkillFilePath( arguments.directory, customSkill.name ) + if ( isNull( customSkillFile ) ) { + issues.warnings.append( "Missing custom skill file: #customSkill.name#" ) + issues.recommendations.append( + "Restore or recreate '#customSkill.name#' in .agents/skills-custom/, or run 'coldbox ai skills refresh' to clean up the manifest entry" + ) + } + } + return issues; } @@ -1296,7 +1356,7 @@ component singleton { /** - * Gets the skills directory path (.agents/skills) + * Gets the skills directory path (.agents/skills) for core/framework skills. * * @directory The target directory * @@ -1306,6 +1366,18 @@ component singleton { return variables.aiService.getAIInstallDirectory( arguments.directory ) & "/skills" } + /** + * Gets the custom skills directory path (.agents/skills-custom) for project-authored skills. + * Custom skills in this directory are meant to be committed to source control. + * + * @directory The target directory + * + * @return The full path to the skills-custom directory + */ + string function getCustomSkillsDirectory( required string directory ){ + return variables.aiService.getAIInstallDirectory( arguments.directory ) & "/skills-custom" + } + /** * Delete a skill directory under .ai/skills/ if it exists. * diff --git a/templates/ai/agents/agent-flat-instructions.md b/templates/ai/agents/agent-flat-instructions.md index 07289f9..10dee36 100644 --- a/templates/ai/agents/agent-flat-instructions.md +++ b/templates/ai/agents/agent-flat-instructions.md @@ -139,20 +139,24 @@ This project includes AI-powered development assistance with guidelines, skills, /modules/ - Module-specific guidelines /custom/ - Your custom guidelines /overrides/ - Override core guidelines - /skills/ - Implementation cookbooks (how-to guides) + /skills/ - Framework/core implementation cookbooks (auto-managed, can be .gitignored) /{name}/ - One folder per skill (flat, no subdirectories) - SKILL.md - Skill content (fetched from registry or created locally) + SKILL.md - Skill content (fetched from registry) + /skills-custom/ - Project-specific skills (commit these to source control) + /{name}/ - One folder per custom skill + SKILL.md - Custom skill content (project-authored or overrides) /mcp-servers/ - MCP server configurations ``` ### Manifest -The `.ai/manifest.json` file contains the complete AI integration configuration: +The `.agents/manifest.json` file contains the complete AI integration configuration: - **language**: Project language mode (boxlang, cfml, hybrid) - **templateType**: Application template (modern, flat) - **guidelines**: Array of installed guideline names -- **skills**: Array of installed skill names +- **skills**: Array of core/framework skill names (auto-managed) +- **customSkills**: Array of project-specific skill names (in `skills-custom/`) - **agents**: Array of configured AI agents - **mcpServers**: Configured MCP documentation servers (core, module, custom) - **activeAgent**: Currently active AI agent (if set) @@ -162,18 +166,20 @@ The `.ai/manifest.json` file contains the complete AI integration configuration: ### Using Guidelines & Skills -Guidelines and skills are stored locally in `.ai/` and loaded via `read_file` when needed: +Guidelines and skills are stored locally in `.agents/` and loaded via `read_file` when needed: -**Core Guidelines** (`.ai/guidelines/core/`) — framework fundamentals: -- `read_file` on `.ai/guidelines/core/coldbox.md` — ColdBox conventions, handler/routing/DI reference -- `read_file` on `.ai/guidelines/core/boxlang.md` — BoxLang syntax, classes, lambdas (or `cfml.md` for CFML) +**Core Guidelines** (`.agents/guidelines/core/`) — framework fundamentals: +- `read_file` on `.agents/guidelines/core/coldbox.md` — ColdBox conventions, handler/routing/DI reference +- `read_file` on `.agents/guidelines/core/boxlang.md` — BoxLang syntax, classes, lambdas (or `cfml.md` for CFML) -**Module/Custom Guidelines** — load by name on request from `.ai/guidelines/modules/` or `.ai/guidelines/custom/`. +**Module/Custom Guidelines** — load by name on request from `.agents/guidelines/modules/` or `.agents/guidelines/custom/`. -**Skills** (`.ai/skills/{name}/SKILL.md`) — step-by-step implementation patterns. Examples: -- Implement a CRUD handler: `read_file` on `.ai/skills/coldbox-handler-development/SKILL.md` -- Build a REST API: `read_file` on `.ai/skills/coldbox-rest-api-development/SKILL.md` -- Write tests: `read_file` on `.ai/skills/coldbox-testing-handler/SKILL.md` +**Skills** (`.agents/skills/{name}/SKILL.md`) — step-by-step implementation patterns. Examples: +- Implement a CRUD handler: `read_file` on `.agents/skills/coldbox-handler-development/SKILL.md` +- Build a REST API: `read_file` on `.agents/skills/coldbox-rest-api-development/SKILL.md` +- Write tests: `read_file` on `.agents/skills/coldbox-testing-handler/SKILL.md` + +**Custom Skills** (`.agents/skills-custom/{name}/SKILL.md`) — project-specific patterns. Load by name from `.agents/skills-custom/`. **To load any skill or guideline:** use `read_file` on the path shown above or in the inventories below. diff --git a/templates/ai/agents/agent-modern-instructions.md b/templates/ai/agents/agent-modern-instructions.md index c0df357..d5108e5 100644 --- a/templates/ai/agents/agent-modern-instructions.md +++ b/templates/ai/agents/agent-modern-instructions.md @@ -176,20 +176,24 @@ This project includes AI-powered development assistance with guidelines, skills, /modules/ - Module-specific guidelines /custom/ - Your custom guidelines /overrides/ - Override core guidelines - /skills/ - Implementation cookbooks (how-to guides) + /skills/ - Framework/core implementation cookbooks (auto-managed, can be .gitignored) /{name}/ - One folder per skill (flat, no subdirectories) - SKILL.md - Skill content (fetched from registry or created locally) + SKILL.md - Skill content (fetched from registry) + /skills-custom/ - Project-specific skills (commit these to source control) + /{name}/ - One folder per custom skill + SKILL.md - Custom skill content (project-authored or overrides) /mcp-servers/ - MCP server configurations ``` ### Manifest -The `.ai/manifest.json` file contains the complete AI integration configuration: +The `.agents/manifest.json` file contains the complete AI integration configuration: - **language**: Project language mode (boxlang, cfml, hybrid) - **templateType**: Application template (modern, flat) - **guidelines**: Array of installed guideline names -- **skills**: Array of installed skill names +- **skills**: Array of core/framework skill names (auto-managed) +- **customSkills**: Array of project-specific skill names (in `skills-custom/`) - **agents**: Array of configured AI agents - **mcpServers**: Configured MCP documentation servers (core, module, custom) - **activeAgent**: Currently active AI agent (if set) @@ -199,7 +203,7 @@ The `.ai/manifest.json` file contains the complete AI integration configuration: ### Using Guidelines & Skills -Guidelines and skills are stored locally in `.ai/` and loaded via `read_file` when needed: +Guidelines and skills are stored locally in `.agents/` and loaded via `read_file` when needed: **Core Guidelines** (`.ai/guidelines/core/`) — framework fundamentals: - `read_file` on `.ai/guidelines/core/coldbox.md` — ColdBox conventions, handler/routing/DI reference @@ -207,10 +211,12 @@ Guidelines and skills are stored locally in `.ai/` and loaded via `read_file` wh **Module/Custom Guidelines** — load by name on request from `.ai/guidelines/modules/` or `.ai/guidelines/custom/`. -**Skills** (`.ai/skills/{name}/SKILL.md`) — step-by-step implementation patterns. Examples: -- Implement a CRUD handler: `read_file` on `.ai/skills/coldbox-handler-development/SKILL.md` -- Build a REST API: `read_file` on `.ai/skills/coldbox-rest-api-development/SKILL.md` -- Write tests: `read_file` on `.ai/skills/coldbox-testing-handler/SKILL.md` +**Skills** (`.agents/skills/{name}/SKILL.md`) — step-by-step implementation patterns. Examples: +- Implement a CRUD handler: `read_file` on `.agents/skills/coldbox-handler-development/SKILL.md` +- Build a REST API: `read_file` on `.agents/skills/coldbox-rest-api-development/SKILL.md` +- Write tests: `read_file` on `.agents/skills/coldbox-testing-handler/SKILL.md` + +**Custom Skills** (`.agents/skills-custom/{name}/SKILL.md`) — project-specific patterns. Load by name from `.agents/skills-custom/`. **To load any skill or guideline:** use `read_file` on the path shown above or in the inventories below. From 2b0099113e624eb29d5a9aca32235f849eaa6804 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:14:08 +0000 Subject: [PATCH 10/42] fix: address code review issues - path consistency, redundant checks, cleaner iteration Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/bd6c5d76-044f-4462-b8fb-459c2651fc07 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- models/SkillManager.cfc | 33 ++++++++----------- .../ai/agents/agent-modern-instructions.md | 8 ++--- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index eebce87..7e4d553 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -183,6 +183,11 @@ component singleton { "removed" : [] }; + // Ensure customSkills key exists (backwards compatibility with old manifests) + if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { + arguments.manifest[ "customSkills" ] = [] + } + // ------------------------------------------------------------------ // 0. Install missing desired skills (core + module) not yet in manifest // ------------------------------------------------------------------ @@ -466,9 +471,6 @@ component singleton { // Remove custom skills whose files were deleted by the user missingCustomSkills.each( ( name ) => { variables.print.yellowLine( " 🧹 Removing deleted custom skill entry: #name#" ).toConsole() - if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { - arguments.manifest[ "customSkills" ] = [] - } arguments.manifest.customSkills = arguments.manifest.customSkills.filter( ( s ) => s.name != name ) changes.removed.append( name ) } ) @@ -476,9 +478,6 @@ component singleton { // ------------------------------------------------------------------ // 5. Sync custom skills from .agents/skills-custom/ that aren't in manifest yet // ------------------------------------------------------------------ - if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { - arguments.manifest[ "customSkills" ] = [] - } var customSkillsDir = getCustomSkillsDirectory( arguments.directory ) if ( directoryExists( customSkillsDir ) ) { directoryList( customSkillsDir, false, "name" ).each( ( dirName ) => { @@ -828,7 +827,7 @@ component singleton { /** * Create a skill override (custom copy) in the .agents/skills-custom/{name}/ directory. - * Sets type=custom, empty owner/repo so refresh skips it. + * Records in manifest.customSkills (no owner/repo, so refresh skips it). * * @directory The project directory * @name The name of the skill to override @@ -881,29 +880,23 @@ component singleton { manifest[ "customSkills" ] = [] } - // Find existing customSkills entry for this skill - var existingIndex = 0 - for ( var i = 1; i <= manifest.customSkills.len(); i++ ) { - if ( manifest.customSkills[ i ].name == arguments.name ) { - existingIndex = i; - break - } - } - // Also remove from manifest.skills if it was there (migrating from old location) manifest.skills = manifest.skills.filter( ( s ) => s.name != arguments.name ) - var existingEntry = existingIndex ? manifest.customSkills[ existingIndex ] : {} + // Find existing customSkills entry for this skill (for preserving description) + var existing = manifest.customSkills.filter( ( s ) => s.name == arguments.name ) + var existingEntry = existing.len() ? existing[ 1 ] : {} var skillEntry = { "name" : arguments.name, "description" : existingEntry.description ?: "", "syncedAt" : dateTimeFormat( now(), "iso" ) } - if ( existingIndex ) { - manifest.customSkills[ existingIndex ] = skillEntry - } else { + // Upsert into customSkills + if ( existingEntry.isEmpty() ) { manifest.customSkills.append( skillEntry ) + } else { + manifest.customSkills = manifest.customSkills.map( ( s ) => s.name == arguments.name ? skillEntry : s ) } variables.aiService.saveManifest( arguments.directory, manifest ) diff --git a/templates/ai/agents/agent-modern-instructions.md b/templates/ai/agents/agent-modern-instructions.md index d5108e5..78d103a 100644 --- a/templates/ai/agents/agent-modern-instructions.md +++ b/templates/ai/agents/agent-modern-instructions.md @@ -205,11 +205,11 @@ The `.agents/manifest.json` file contains the complete AI integration configurat Guidelines and skills are stored locally in `.agents/` and loaded via `read_file` when needed: -**Core Guidelines** (`.ai/guidelines/core/`) — framework fundamentals: -- `read_file` on `.ai/guidelines/core/coldbox.md` — ColdBox conventions, handler/routing/DI reference -- `read_file` on `.ai/guidelines/core/boxlang.md` — BoxLang syntax, classes, lambdas (or `cfml.md` for CFML) +**Core Guidelines** (`.agents/guidelines/core/`) — framework fundamentals: +- `read_file` on `.agents/guidelines/core/coldbox.md` — ColdBox conventions, handler/routing/DI reference +- `read_file` on `.agents/guidelines/core/boxlang.md` — BoxLang syntax, classes, lambdas (or `cfml.md` for CFML) -**Module/Custom Guidelines** — load by name on request from `.ai/guidelines/modules/` or `.ai/guidelines/custom/`. +**Module/Custom Guidelines** — load by name on request from `.agents/guidelines/modules/` or `.agents/guidelines/custom/`. **Skills** (`.agents/skills/{name}/SKILL.md`) — step-by-step implementation patterns. Examples: - Implement a CRUD handler: `read_file` on `.agents/skills/coldbox-handler-development/SKILL.md` From d47f3eece864a95fdcfac40b1fdfceeb9fac1018 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:17:17 +0000 Subject: [PATCH 11/42] fix: address additional code review issues - null checks, performance, string interpolation Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/bd6c5d76-044f-4462-b8fb-459c2651fc07 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- commands/coldbox/ai/skills/list.cfc | 2 +- models/SkillManager.cfc | 57 +++++++++++++++++++---------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/commands/coldbox/ai/skills/list.cfc b/commands/coldbox/ai/skills/list.cfc index f5fa40f..2dd0cfc 100644 --- a/commands/coldbox/ai/skills/list.cfc +++ b/commands/coldbox/ai/skills/list.cfc @@ -109,7 +109,7 @@ component extends="coldbox-cli.models.BaseAICommand" { // Summary print.line() - printInfo( "Total: #info.skills.len() + info.customSkills.len()# skill(s) installed" ) + printInfo( "Total: #(info.skills.len() + info.customSkills.len())# skill(s) installed" ) print.line() if ( !outdated ) { diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index 7e4d553..d10fb1a 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -184,9 +184,7 @@ component singleton { }; // Ensure customSkills key exists (backwards compatibility with old manifests) - if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { - arguments.manifest[ "customSkills" ] = [] - } + ensureCustomSkillsSection( arguments.manifest ) // ------------------------------------------------------------------ // 0. Install missing desired skills (core + module) not yet in manifest @@ -409,6 +407,9 @@ component singleton { // Check for deleted custom skills (in customSkills manifest section) if ( structKeyExists( arguments.manifest, "customSkills" ) ) { for ( var customSkill in arguments.manifest.customSkills ) { + if ( !isStruct( customSkill ) || !structKeyExists( customSkill, "name" ) ) { + continue; + } var customSkillFile = getCustomSkillFilePath( arguments.directory, customSkill.name ) if ( isNull( customSkillFile ) ) { missingCustomSkills.append( customSkill.name ) @@ -486,7 +487,7 @@ component singleton { return; } - var alreadyInManifest = arguments.manifest.customSkills.filter( ( s ) => s.name == dirName ).len() > 0 + var alreadyInManifest = !arguments.manifest.customSkills.filter( ( s ) => s.name == dirName ).isEmpty() if ( alreadyInManifest ) { return; } @@ -755,9 +756,7 @@ component singleton { fileWrite( skillFile, template ) var manifest = variables.aiService.loadManifest( arguments.directory ); - if ( !structKeyExists( manifest, "customSkills" ) ) { - manifest[ "customSkills" ] = [] - } + ensureCustomSkillsSection( manifest ) manifest.customSkills.append( { "name" : arguments.name, "description" : "", @@ -876,27 +875,33 @@ component singleton { fileWrite( targetFile, content ) // Ensure customSkills section exists - if ( !structKeyExists( manifest, "customSkills" ) ) { - manifest[ "customSkills" ] = [] - } + ensureCustomSkillsSection( manifest ) // Also remove from manifest.skills if it was there (migrating from old location) - manifest.skills = manifest.skills.filter( ( s ) => s.name != arguments.name ) + var skillName = arguments.name + manifest.skills = manifest.skills.filter( ( s ) => s.name != skillName ) - // Find existing customSkills entry for this skill (for preserving description) - var existing = manifest.customSkills.filter( ( s ) => s.name == arguments.name ) - var existingEntry = existing.len() ? existing[ 1 ] : {} - var skillEntry = { - "name" : arguments.name, - "description" : existingEntry.description ?: "", + // Find existing customSkills entry index for this skill (for preserving description and upsert) + var existingIndex = 0 + for ( var i = 1; i <= manifest.customSkills.len(); i++ ) { + if ( manifest.customSkills[ i ].name == skillName ) { + existingIndex = i; + break + } + } + + var existingDescription = existingIndex ? ( manifest.customSkills[ existingIndex ].description ?: "" ) : "" + var skillEntry = { + "name" : skillName, + "description" : existingDescription, "syncedAt" : dateTimeFormat( now(), "iso" ) } // Upsert into customSkills - if ( existingEntry.isEmpty() ) { - manifest.customSkills.append( skillEntry ) + if ( existingIndex ) { + manifest.customSkills[ existingIndex ] = skillEntry } else { - manifest.customSkills = manifest.customSkills.map( ( s ) => s.name == arguments.name ? skillEntry : s ) + manifest.customSkills.append( skillEntry ) } variables.aiService.saveManifest( arguments.directory, manifest ) @@ -1371,6 +1376,18 @@ component singleton { return variables.aiService.getAIInstallDirectory( arguments.directory ) & "/skills-custom" } + /** + * Ensure manifest has a customSkills array (backwards compatibility). + * Mutates manifest in place. + * + * @manifest The manifest struct to ensure has a customSkills key + */ + private function ensureCustomSkillsSection( required struct manifest ){ + if ( !structKeyExists( arguments.manifest, "customSkills" ) ) { + arguments.manifest[ "customSkills" ] = [] + } + } + /** * Delete a skill directory under .ai/skills/ if it exists. * From 9b54c8988a15c16efe5006b81f7ae78dca121627 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:45:09 +0000 Subject: [PATCH 12/42] Initial plan From c251cf5e36cfccb3c4b5475fd610bafa891f9489 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:46:49 +0000 Subject: [PATCH 13/42] fix: use .agents directory in doctor and uninstall commands instead of .ai Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/34266c38-e042-4cf3-bd14-32c40280e8a6 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- commands/coldbox/ai/uninstall.cfc | 8 ++++---- models/AIService.cfc | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/commands/coldbox/ai/uninstall.cfc b/commands/coldbox/ai/uninstall.cfc index 572cd9a..ada5e4c 100644 --- a/commands/coldbox/ai/uninstall.cfc +++ b/commands/coldbox/ai/uninstall.cfc @@ -1,6 +1,6 @@ /** * Uninstall AI integration from a ColdBox application - * Removes the .ai directory and all AI configuration + * Removes the .agents directory and all AI configuration * * Examples: * coldbox ai uninstall @@ -24,9 +24,9 @@ component extends="coldbox-cli.models.BaseAICommand" { showColdBoxBanner( "AI Integration Uninstaller" ) } - var aiDirectory = "#arguments.directory#/.ai" + var aiDirectory = "#arguments.directory#/.agents" - // Check if .ai directory exists + // Check if .agents directory exists if ( !directoryExists( aiDirectory ) ) { printWarn( "No AI integration found in this project." ) return @@ -35,7 +35,7 @@ component extends="coldbox-cli.models.BaseAICommand" { // Confirm uninstall unless force flag is set if ( !arguments.force ) { print.line() - printWarn( "⚠️ This will permanently delete the .ai directory and all AI configuration." ) + printWarn( "⚠️ This will permanently delete the .agents directory and all AI configuration." ) print.line() var confirmed = confirm( "Are you sure you want to uninstall AI integration? [y/N]: " ) diff --git a/models/AIService.cfc b/models/AIService.cfc index 25ce026..4f9d644 100644 --- a/models/AIService.cfc +++ b/models/AIService.cfc @@ -283,7 +283,7 @@ component singleton { }; // Check if AI integration is installed - var aiDir = arguments.directory & "/.ai" + var aiDir = arguments.directory & "/" & static.AI_DIR if ( !directoryExists( aiDir ) ) { issues.errors.append( "AI integration not installed. Run 'coldbox ai install' first." ) // Build summary for early return From 254536463940501efbebbc23fec750d0b56662f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 20:11:05 +0000 Subject: [PATCH 14/42] docs: add changelog entry for .ai vs .agents directory fix Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/74bcab47-6579-4cb3-baed-75661c6e28e9 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- changelog.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/changelog.md b/changelog.md index a80e382..24b9801 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`coldbox ai doctor` and `coldbox ai uninstall` checking wrong directory** + - Both commands were checking for a `.ai` directory instead of `.agents`, causing them to always report "not installed" even after a successful `coldbox ai install` + - `AIService.diagnose()` now uses the `static.AI_DIR` constant (`.agents`) instead of the hardcoded `/.ai` path + - `coldbox ai uninstall` now correctly checks, removes, and references the `.agents` directory + ### Added - **VSCode Copilot MCP Mirroring** From 45cbd451cd272db7c9cdf93dac6fe2ea3541356a Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 21:24:56 +0200 Subject: [PATCH 15/42] - `coldbox ai skills add slug --list` was not working. --- changelog.md | 1 + commands/coldbox/ai/skills/install.cfc | 53 +++++++++++++++----------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/changelog.md b/changelog.md index 24b9801..f0778e4 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Both commands were checking for a `.ai` directory instead of `.agents`, causing them to always report "not installed" even after a successful `coldbox ai install` - `AIService.diagnose()` now uses the `static.AI_DIR` constant (`.agents`) instead of the hardcoded `/.ai` path - `coldbox ai uninstall` now correctly checks, removes, and references the `.agents` directory +- `coldbox ai skills add slug --list` was not working. ### Added diff --git a/commands/coldbox/ai/skills/install.cfc b/commands/coldbox/ai/skills/install.cfc index a40b507..c5c87d3 100644 --- a/commands/coldbox/ai/skills/install.cfc +++ b/commands/coldbox/ai/skills/install.cfc @@ -235,18 +235,27 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills resolvedItems = _resolveSlugs( slugs, arguments.language ) } - // Fetch both repos in parallel - var bxList = variables.skillManager.fetchRepoSkillList( bxRepo.owner, bxRepo.repo ) - var cbList = variables.skillManager.fetchRepoSkillList( cbRepo.owner, cbRepo.repo ) var allSkills = [] - // Build skills list, filtering by resolved items if slug was provided - bxList.each( ( s ) => { - if ( - !resolvedItems.len() || resolvedItems - .filter( ( r ) => r.owner == bxRepo.owner && r.repo == bxRepo.repo && r.slug == s.slug ) - .len() - ) { + // If a slug filter is provided, the resolved list is the authoritative source + if ( arguments.slug.len() ) { + allSkills = resolvedItems.map( ( r ) => { + return { + display : "#r.owner#/#r.repo#/#r.slug#", + value : { + owner : r.owner, + repo : r.repo, + slug : r.slug, + name : r.name + }, + description : r.description ?: "" + } + } ) + } else { + var bxList = variables.skillManager.fetchRepoSkillList( bxRepo.owner, bxRepo.repo ) + var cbList = variables.skillManager.fetchRepoSkillList( cbRepo.owner, cbRepo.repo ) + + bxList.each( ( s ) => { allSkills.append( { display : "#bxRepo.owner#/#bxRepo.repo#/#s.slug#", value : { @@ -257,14 +266,8 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills }, description : s.description ?: "" } ) - } - } ) - cbList.each( ( s ) => { - if ( - !resolvedItems.len() || resolvedItems - .filter( ( r ) => r.owner == cbRepo.owner && r.repo == cbRepo.repo && r.slug == s.slug ) - .len() - ) { + } ) + cbList.each( ( s ) => { allSkills.append( { display : "#cbRepo.owner#/#cbRepo.repo#/#s.slug#", value : { @@ -275,8 +278,8 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills }, description : s.description ?: "" } ) - } - } ) + } ) + } if ( allSkills.isEmpty() ) { printError( "Could not retrieve skills from registry." ) @@ -297,8 +300,8 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills printInfo( "Installing #choices.len()# selected skill(s)..." ) print.line() - var resolvedItems = choices.map( ( c ) => c.value ) - var batchItems = resolvedItems.map( ( r ) => { + var selectedItems = choices.map( ( c ) => c.value ) + var batchItems = selectedItems.map( ( r ) => { return { owner : r.owner, repo : r.repo, @@ -314,7 +317,7 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills } var skill = result.skill var matchSlug = skill.skill_slug ?: "" - var matchItem = resolvedItems.filter( ( r ) => r.slug == matchSlug ).first( {} ) + var matchItem = selectedItems.filter( ( r ) => r.slug == matchSlug ).first( {} ) var localName = matchItem.name ?: skill.skill_dir.listLast( "/" ) localName = variables.skillManager.installRemoteSkill( directory = arguments.directory, @@ -381,6 +384,7 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills repo : slugRepo, slug : s.slug, name : s.name, + description : s.description ?: "", type : "core", source : "" } ) @@ -398,6 +402,7 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills repo : slugRepo, slug : dm.slug, name : dm.name, + description : dm.description ?: "", type : "core", source : "" } ) @@ -411,6 +416,7 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills repo : slugRepo, slug : cs.slug, name : cs.name, + description : cs.description ?: "", type : "core", source : "" } ) @@ -427,6 +433,7 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills repo : slugRepo, slug : skillSlug, name : parts.last(), + description : "", type : "core", source : "" } ) From e96ce171bb6477758455cf577e97b8b87daac709 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 21:29:53 +0200 Subject: [PATCH 16/42] - New progress bar when doing `coldbox ai skills list --outdated` to check for updates in the registry, providing better feedback during potentially long-running integrity checks --- changelog.md | 4 ++++ commands/coldbox/ai/skills/list.cfc | 21 ++++++++++++++++++++- models/SkillManager.cfc | 11 ++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index f0778e4..fe6e625 100644 --- a/changelog.md +++ b/changelog.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensures GitHub Copilot agents in VSCode can discover MCP servers registered via `coldbox ai mcp` commands - `.vscode/mcp.json` is written alongside the root `.mcp.json` whenever `generateMCPJson()` runs (install, refresh, MCP add/remove) +### Improvements + +- New progress bar when doing `coldbox ai skills list --outdated` to check for updates in the registry, providing better feedback during potentially long-running integrity checks + ## [8.11.0] - 2026-04-28 ### Changed diff --git a/commands/coldbox/ai/skills/list.cfc b/commands/coldbox/ai/skills/list.cfc index 2dd0cfc..1018796 100644 --- a/commands/coldbox/ai/skills/list.cfc +++ b/commands/coldbox/ai/skills/list.cfc @@ -9,6 +9,7 @@ component extends="coldbox-cli.models.BaseAICommand" { property name="skillManager" inject="SkillManager@coldbox-cli"; + property name="progressBarGeneric" inject="progressBarGeneric"; /** * Run the command @@ -31,7 +32,25 @@ component extends="coldbox-cli.models.BaseAICommand" { // --outdated: validate integrity and keep only stale skills if ( outdated ) { - var integrity = skillManager.validateSkillIntegrity( arguments.directory, info ) + printInfo( "Checking registry for skill updates..." ) + print.line().toConsole() + + var integrity = skillManager.validateSkillIntegrity( + arguments.directory, + info, + ( currentCount, totalCount, skillName ) => { + var percent = totalCount > 0 ? int( ( currentCount / totalCount ) * 100 ) : 100 + variables.progressBarGeneric.update( + percent = percent, + currentCount = currentCount, + totalCount = totalCount + ) + } + ) + + variables.progressBarGeneric.clear() + print.line().toConsole() + var staleNames = integrity.stale if ( staleNames.isEmpty() ) { printSuccess( "All skills are up to date." ) diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index d10fb1a..c64ad8a 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -636,16 +636,25 @@ component singleton { * * @directory The project directory * @manifest The manifest struct + * @onProgress Optional callback invoked per checked skill with (currentCount, totalCount, skillName) * * @return Struct: {valid[], stale[], missing[]} */ struct function validateSkillIntegrity( required string directory, - required struct manifest + required struct manifest, + any onProgress = javacast( "null", "" ) ){ var result = { valid : [], stale : [], missing : [] } + var total = arguments.manifest.skills.len() + var count = 0 for ( var skill in arguments.manifest.skills ) { + count++ + if ( !isNull( arguments.onProgress ) && isCustomFunction( arguments.onProgress ) ) { + arguments.onProgress( currentCount = count, totalCount = total, skillName = skill.name ?: "" ) + } + var skillFile = getSkillFilePath( arguments.directory, skill.name ) if ( isNull( skillFile ) ) { result.missing.append( skill.name ) From afc3c9231c6db9267a889efb5b5e38300095df9e Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 21:40:07 +0200 Subject: [PATCH 17/42] - `coldbox ai skills list --json` flag to output the skills manifest in JSON format for easier parsing in scripts and CI pipelines --- changelog.md | 1 + commands/coldbox/ai/skills/list.cfc | 22 +++++++++++++++++++++- models/BaseAICommand.cfc | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index fe6e625..31512c8 100644 --- a/changelog.md +++ b/changelog.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When Copilot is a configured agent, MCP server configuration is now mirrored to `.vscode/mcp.json` using the VSCode-specific schema (`"servers"` + `"inputs": []`) - Ensures GitHub Copilot agents in VSCode can discover MCP servers registered via `coldbox ai mcp` commands - `.vscode/mcp.json` is written alongside the root `.mcp.json` whenever `generateMCPJson()` runs (install, refresh, MCP add/remove) +- `coldbox ai skills list --json` flag to output the skills manifest in JSON format for easier parsing in scripts and CI pipelines ### Improvements diff --git a/commands/coldbox/ai/skills/list.cfc b/commands/coldbox/ai/skills/list.cfc index 1018796..908d2e9 100644 --- a/commands/coldbox/ai/skills/list.cfc +++ b/commands/coldbox/ai/skills/list.cfc @@ -5,6 +5,7 @@ * coldbox ai skills list * coldbox ai skills list --outdated * coldbox ai skills list --verbose + * coldbox ai skills list --json */ component extends="coldbox-cli.models.BaseAICommand" { @@ -16,20 +17,39 @@ component extends="coldbox-cli.models.BaseAICommand" { * * @outdated Only show skills that have a newer version available in the registry * @verbose Show extra columns (SHA, last synced) + * @json Output results as JSON * @directory The target directory (defaults to current directory) */ function run( boolean outdated = false, boolean verbose = false, + boolean json = false, string directory = getCwd() ){ - showColdBoxBanner( "Installed AI Skills" ) + if ( !arguments.json ) { + showColdBoxBanner( "Installed AI Skills" ) + } var info = ensureInstalled( arguments.directory ) if ( !info.installed ) { return } + if ( arguments.json ) { + var manifest = loadManifest( arguments.directory ) + manifest.installed = true + + if ( arguments.outdated ) { + var integrity = skillManager.validateSkillIntegrity( arguments.directory, info ) + manifest.outdated = integrity.stale.len() > 0 + manifest.outdatedCount = integrity.stale.len() + manifest.outdatedSkills = integrity.stale + } + + print.line( formatterUtil.formatJSON( manifest ) ) + return + } + // --outdated: validate integrity and keep only stale skills if ( outdated ) { printInfo( "Checking registry for skill updates..." ) diff --git a/models/BaseAICommand.cfc b/models/BaseAICommand.cfc index 9bfc6ac..00f92d1 100644 --- a/models/BaseAICommand.cfc +++ b/models/BaseAICommand.cfc @@ -7,6 +7,7 @@ component extends="coldbox-cli.models.BaseCommand" { // DI - All AI commands need these services property name="aiService" inject="AIService@coldbox-cli"; + property name="formatterUtil" inject="Formatter"; /** * Ensures AI integration is installed and returns info From b121dbba9e0cb63e255578de310ce5dc44ac8642 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 21:47:57 +0200 Subject: [PATCH 18/42] small fixes on case --- .gitignore | 4 ++++ commands/coldbox/ai/skills/list.cfc | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ec1a65e..50deebb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ modules/** # Test App testapp/** + +# Skills +.agents/** +.claude/** diff --git a/commands/coldbox/ai/skills/list.cfc b/commands/coldbox/ai/skills/list.cfc index 908d2e9..8aa61c9 100644 --- a/commands/coldbox/ai/skills/list.cfc +++ b/commands/coldbox/ai/skills/list.cfc @@ -37,13 +37,13 @@ component extends="coldbox-cli.models.BaseAICommand" { if ( arguments.json ) { var manifest = loadManifest( arguments.directory ) - manifest.installed = true + manifest[ "installed" ] = true if ( arguments.outdated ) { var integrity = skillManager.validateSkillIntegrity( arguments.directory, info ) - manifest.outdated = integrity.stale.len() > 0 - manifest.outdatedCount = integrity.stale.len() - manifest.outdatedSkills = integrity.stale + manifest[ "outdated" ] = integrity.stale.len() > 0 + manifest[ "outdatedCount" ] = integrity.stale.len() + manifest[ "outdatedSkills" ] = integrity.stale } print.line( formatterUtil.formatJSON( manifest ) ) From ca7d030921a79093e7934acc8653c52221c5d410 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 22:01:13 +0200 Subject: [PATCH 19/42] - `coldbox ai skills update` command to re-download and overwrite all installed registry skills with per-skill feedback and progress updates - `coldbox ai skills update ` command to re-download and overwrite a single installed registry skill by local name --- changelog.md | 2 + commands/coldbox/ai/skills/update.cfc | 407 ++++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 commands/coldbox/ai/skills/update.cfc diff --git a/changelog.md b/changelog.md index 31512c8..3ba6779 100644 --- a/changelog.md +++ b/changelog.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensures GitHub Copilot agents in VSCode can discover MCP servers registered via `coldbox ai mcp` commands - `.vscode/mcp.json` is written alongside the root `.mcp.json` whenever `generateMCPJson()` runs (install, refresh, MCP add/remove) - `coldbox ai skills list --json` flag to output the skills manifest in JSON format for easier parsing in scripts and CI pipelines +- `coldbox ai skills update` command to re-download and overwrite all installed registry skills with per-skill feedback and progress updates +- `coldbox ai skills update ` command to re-download and overwrite a single installed registry skill by local name ### Improvements diff --git a/commands/coldbox/ai/skills/update.cfc b/commands/coldbox/ai/skills/update.cfc new file mode 100644 index 0000000..ff36da0 --- /dev/null +++ b/commands/coldbox/ai/skills/update.cfc @@ -0,0 +1,407 @@ +/** + * Update (re-download) one or all installed AI skills from the registry. + * + * Examples: + * coldbox ai skills update + * coldbox ai skills update boxlang-syntax + */ +component extends="coldbox-cli.models.BaseAICommand" { + + // DI + property name="skillManager" inject="SkillManager@coldbox-cli"; + property name="agentRegistry" inject="AgentRegistry@coldbox-cli"; + property name="progressBarGeneric" inject="progressBarGeneric"; + + /** + * Run the command + * + * @name Optional skill name to update. Omit to update all installed skills. + * @directory The target directory (defaults to current directory). + */ + function run( + string name = "", + string directory = getCwd() + ){ + showColdBoxBanner( "Update AI Skills" ) + + var info = ensureInstalled( arguments.directory ) + if ( !info.installed ) { + return + } + + var manifest = loadManifest( arguments.directory ) + print.line() + + if ( arguments.name.len() ) { + _updateSingle( + name = arguments.name, + directory = arguments.directory, + manifest = manifest + ) + return + } + + _updateAll( + directory = arguments.directory, + manifest = manifest + ) + } + + /** + * Update a single skill by local name. + */ + private function _updateSingle( + required string name, + required string directory, + required struct manifest + ){ + var normalizedName = arguments.name.replaceAll( "\\s+", "-" ) + var entries = arguments.manifest.skills.filter( ( s ) => s.name == normalizedName ) + + if ( entries.isEmpty() ) { + printError( "Skill '#normalizedName#' is not installed." ) + print.line() + printTip( "Use 'coldbox ai skills list' to see installed skills." ) + return + } + + var entry = entries.first() + if ( ( entry.type ?: "" ) == "custom" ) { + printError( "Skill '#normalizedName#' is custom and cannot be updated from the registry." ) + return + } + + if ( !( entry.owner ?: "" ).len() || !( entry.repo ?: "" ).len() || !( entry.slug ?: "" ).len() ) { + printError( "Skill '#normalizedName#' is missing owner/repo/slug metadata and cannot be updated." ) + return + } + + printInfo( "Updating #normalizedName# (#entry.owner#/#entry.repo#/#entry.slug#)..." ) + print.toConsole() + + var result = variables.skillManager.downloadSkill( + entry.owner, + entry.repo, + entry.slug + ) + + if ( result.keyExists( "error" ) && result.error ) { + printError( "Failed to download '#normalizedName#': #result.message ?: 'unknown error'#" ) + return + } + + var skill = result.skill + var auditStatus = skill.audit_status ?: "skipped" + if ( auditStatus == "block" ) { + printError( "Update blocked for '#normalizedName#' by security audit." ) + return + } + + variables.skillManager.installRemoteSkill( + directory = arguments.directory, + name = normalizedName, + content = result.content, + owner = skill.owner, + repo = skill.repo, + path = skill.skill_dir, + sha = skill.file_sha, + description = skill.description ?: "", + auditStatus = auditStatus, + skillType = entry.type ?: "core", + source = entry.source ?: "", + manifest = arguments.manifest + ) + + saveManifest( arguments.directory, arguments.manifest ) + _regenerateAgents( arguments.directory, arguments.manifest ) + + print.line() + printSuccess( "✓ Updated #normalizedName#" ) + } + + /** + * Re-download all non-custom registry skills. + */ + private function _updateAll( + required string directory, + required struct manifest + ){ + var targets = arguments.manifest.skills.filter( ( s ) => { + return ( s.type ?: "" ) != "custom" && + ( s.owner ?: "" ).len() && + ( s.repo ?: "" ).len() && + ( s.slug ?: "" ).len() + } ) + + if ( targets.isEmpty() ) { + printInfo( "No registry skills installed to update." ) + print.line() + printTip( "Use 'coldbox ai skills install --all' to install default registry skills." ) + return + } + + var total = targets.len() + printInfo( "Updating #total# installed skill(s):" ) + targets.each( ( t ) => { + print.blueLine( " - #t.name# (#t.owner#/#t.repo#/#t.slug#)" ) + } ) + print.line().toConsole() + + var batchItems = targets.map( ( t ) => { + return { + owner : t.owner, + repo : t.repo, + skill : t.slug + } + } ) + + printInfo( "Downloading updated skills from registry..." ) + variables.progressBarGeneric.update( percent = 0, currentCount = 0, totalCount = total ) + var batchResults = variables.skillManager.downloadSkillBatch( batchItems ) + variables.progressBarGeneric.update( percent = 100, currentCount = total, totalCount = total ) + variables.progressBarGeneric.clear() + print.line().toConsole() + + var successCount = 0 + var failCount = 0 + var repoSkillCache = {} + + batchResults.each( ( result ) => { + if ( result.keyExists( "error" ) && result.error ) { + var failedCoords = _extractBatchCoordinates( result ) + var failedMessage = _extractBatchMessage( result ) + var targetMatch = targets.filter( ( t ) => { + return t.owner == failedCoords.owner && t.repo == failedCoords.repo && t.slug == failedCoords.slug + } ).first() ?: {} + + if ( failedCoords.owner.len() && failedCoords.repo.len() && failedCoords.slug.len() ) { + var retry = variables.skillManager.downloadSkill( + failedCoords.owner, + failedCoords.repo, + failedCoords.slug + ) + + if ( !( retry.keyExists( "error" ) && retry.error ) ) { + result = retry + } else { + var retryMessage = retry.message ?: failedMessage + + if ( _isSkillNotFoundMessage( retryMessage ) && !targetMatch.isEmpty() ) { + var replacement = _resolveReplacementSkill( targetMatch, repoSkillCache ) + + if ( !replacement.isEmpty() ) { + printInfo( " ↻ #targetMatch.name#: trying '#replacement.slug#'" ) + var replacementRetry = variables.skillManager.downloadSkill( + targetMatch.owner, + targetMatch.repo, + replacement.slug + ) + + if ( !( replacementRetry.keyExists( "error" ) && replacementRetry.error ) ) { + replacementRetry[ "_targetEntry" ] = targetMatch + result = replacementRetry + } else { + var replacementLabel = "#targetMatch.owner#/#targetMatch.repo#/#replacement.slug#" + printWarn( " ⚠ #replacementLabel#: #replacementRetry.message ?: retryMessage#" ) + failCount++ + return + } + } else { + var missingLabel = "#failedCoords.owner#/#failedCoords.repo#/#failedCoords.slug#" + printWarn( " ⚠ #missingLabel# not found in registry (possibly renamed/removed) — skipped" ) + failCount++ + return + } + } else { + var failedLabel = "#failedCoords.owner#/#failedCoords.repo#/#failedCoords.slug#" + printError( " ✗ #failedLabel#: #retryMessage#" ) + failCount++ + return + } + } + } else { + if ( _isSkillNotFoundMessage( failedMessage ) ) { + printWarn( " ⚠ #failedMessage#" ) + } else { + printError( " ✗ #failedMessage#" ) + } + failCount++ + return + } + } + + var skill = result.skill + var auditStatus = skill.audit_status ?: "skipped" + var matchSlug = skill.skill_slug ?: skill.skill_dir.listLast( "/" ) + + if ( auditStatus == "block" ) { + printWarn( " ⚠ #matchSlug# blocked by security audit and skipped" ) + failCount++ + return + } + + var matches = targets.filter( ( t ) => t.slug == matchSlug ) + var matchEntry = result.keyExists( "_targetEntry" ) ? result._targetEntry : ( matches.isEmpty() ? {} : matches.first() ) + var localName = matchEntry.keyExists( "name" ) ? matchEntry.name : skill.skill_dir.listLast( "/" ) + + print.toConsole( " Updating #localName#..." ) + + variables.skillManager.installRemoteSkill( + directory = directory, + name = localName, + content = result.content, + owner = skill.owner, + repo = skill.repo, + path = skill.skill_dir, + sha = skill.file_sha, + description = skill.description ?: "", + auditStatus = auditStatus, + skillType = matchEntry.keyExists( "type" ) ? matchEntry.type : "core", + source = matchEntry.keyExists( "source" ) ? matchEntry.source : "", + manifest = manifest + ) + + print.greenLine( " ✓" ) + successCount++ + } ) + + variables.progressBarGeneric.clear() + print.line().toConsole() + + saveManifest( directory, manifest ) + _regenerateAgents( directory, manifest ) + + if ( successCount ) { + printSuccess( "Updated #successCount# of #total# skill(s)." ) + } + if ( failCount ) { + printWarn( "#failCount# skill(s) failed to update." ) + } + print.line() + } + + /** + * Extract owner/repo/slug values from varying batch error payload shapes. + */ + private struct function _extractBatchCoordinates( required struct result ){ + var skillPayload = arguments.result.keyExists( "skill" ) ? arguments.result.skill : {} + + var owner = _extractBatchScalar( skillPayload.owner ?: skillPayload.OWNER ?: "" ) + var repo = _extractBatchScalar( skillPayload.repo ?: skillPayload.REPO ?: "" ) + var slug = _extractBatchScalar( skillPayload.skill ?: skillPayload.SKILL ?: "" ) + + return { + owner : owner, + repo : repo, + slug : slug + } + } + + /** + * Extract a readable message from varying batch error payload shapes. + */ + private string function _extractBatchMessage( required struct result ){ + if ( arguments.result.keyExists( "message" ) && len( arguments.result.message ?: "" ) ) { + return arguments.result.message + } + + if ( arguments.result.keyExists( "messages" ) && isArray( arguments.result.messages ) && arguments.result.messages.len() ) { + return arguments.result.messages[ 1 ] + } + + return "download failed" + } + + /** + * Detect not-found errors from registry responses. + */ + private boolean function _isSkillNotFoundMessage( required string message ){ + return findNoCase( "skill not found", arguments.message ?: "" ) > 0 + } + + /** + * Attempt to resolve a replacement slug for renamed skills in the same repo. + */ + private struct function _resolveReplacementSkill( required struct target, required struct repoSkillCache ){ + var cacheKey = "#arguments.target.owner#/#arguments.target.repo#" + if ( !arguments.repoSkillCache.keyExists( cacheKey ) ) { + arguments.repoSkillCache[ cacheKey ] = variables.skillManager.fetchRepoSkillList( + arguments.target.owner, + arguments.target.repo + ) + } + + var repoSkills = arguments.repoSkillCache[ cacheKey ] + if ( !isArray( repoSkills ) || repoSkills.isEmpty() ) { + return {} + } + + var needles = [ + ( arguments.target.slug ?: "" ).lcase(), + ( arguments.target.name ?: "" ).lcase(), + ( arguments.target.path ?: "" ).listLast( "/" ).lcase() + ].filter( ( n ) => n.len() ) + + for ( var needle in needles ) { + var exact = repoSkills.filter( ( s ) => { + var slug = ( s.slug ?: "" ).lcase() + var name = ( s.name ?: "" ).lcase() + return slug == needle || name == needle + } ) + if ( !exact.isEmpty() ) { + return exact.first() + } + } + + for ( var needle in needles ) { + var suffix = "-#needle#" + var fuzzy = repoSkills.filter( ( s ) => { + var slug = ( s.slug ?: "" ).lcase() + return findNoCase( needle, slug ) > 0 || right( slug, suffix.len() ) == suffix + } ) + if ( !fuzzy.isEmpty() ) { + return fuzzy.first() + } + } + + return {} + } + + /** + * Reduce nested/simple/list payload values to a scalar string. + */ + private string function _extractBatchScalar( required any value ){ + if ( isSimpleValue( arguments.value ) ) { + return trim( arguments.value & "" ) + } + + if ( isArray( arguments.value ) && arguments.value.len() ) { + return _extractBatchScalar( arguments.value[ 1 ] ) + } + + if ( isStruct( arguments.value ) ) { + for ( var key in arguments.value ) { + return _extractBatchScalar( arguments.value[ key ] ) + } + } + + return "" + } + + /** + * Regenerate all configured agent files after manifest changes. + */ + private function _regenerateAgents( + required string directory, + required struct manifest + ){ + if ( arguments.manifest.keyExists( "agents" ) && arguments.manifest.agents.len() ) { + var language = arguments.manifest.language ?: "boxlang" + printInfo( "Regenerating agent configuration files..." ) + arguments.manifest.agents.each( ( agent ) => { + variables.agentRegistry.configureAgent( directory, agent, language ) + } ) + } + } + +} From a53968c58780b1e980114ddac24c46b1845708f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:02:30 +0000 Subject: [PATCH 20/42] Initial plan From 1dbc68f8d8191c79344de6185da432451c47ff22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:05:20 +0000 Subject: [PATCH 21/42] fix: prevent re-installation of explicitly removed skills during refresh Add an `excludes` array to the manifest that tracks skill names the user has explicitly removed via `coldbox ai skills remove`. During `refresh()`, skills in the excludes list are filtered out of `missingDesiredSkills` so they are never auto-reinstalled. When a skill is explicitly re-installed via `coldbox ai skills install`, its name is removed from `excludes` so normal auto-management resumes. - SkillManager.refresh(): initialise excludes section; filter excluded skills from missingDesiredSkills (step 0) - SkillManager.removeSkillFromProject(): append removed skill name to manifest.excludes before saving - SkillManager.installRemoteSkill(): lift exclusion when skill is explicitly re-installed - SkillManager.ensureExcludesSection(): new private helper for backwards-compatible initialisation of manifest.excludes Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/92d13e96-d882-4c14-9d67-62579d852353 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- models/SkillManager.cfc | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index c64ad8a..cb582fc 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -186,6 +186,9 @@ component singleton { // Ensure customSkills key exists (backwards compatibility with old manifests) ensureCustomSkillsSection( arguments.manifest ) + // Ensure excludes key exists (backwards compatibility with old manifests) + ensureExcludesSection( arguments.manifest ) + // ------------------------------------------------------------------ // 0. Install missing desired skills (core + module) not yet in manifest // ------------------------------------------------------------------ @@ -195,6 +198,10 @@ component singleton { ); var missingDesiredSkills = desiredTargets.filter( ( t ) => { + // Skip skills the user has explicitly excluded + if ( arguments.manifest.excludes.findNoCase( t.name ) ) { + return false + } return !manifest.skills .filter( ( s ) => { return ( s.owner == t.owner && s.repo == t.repo && s.slug == t.slug ); @@ -828,6 +835,13 @@ component singleton { if ( structKeyExists( manifest, "customSkills" ) ) { manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name ) } + + // Track the explicit exclusion so refresh() does not auto-reinstall it + ensureExcludesSection( manifest ) + if ( !manifest.excludes.findNoCase( arguments.name ) ) { + manifest.excludes.append( arguments.name ) + } + variables.aiService.saveManifest( arguments.directory, manifest ) return true @@ -1345,6 +1359,10 @@ component singleton { arguments.manifest.skills.append( entry ) } + // If this skill was previously excluded, lift the exclusion now that it is being explicitly installed + ensureExcludesSection( arguments.manifest ) + arguments.manifest.excludes = arguments.manifest.excludes.filter( ( ex ) => !ex.equalsIgnoreCase( resolvedName ) ) + return resolvedName } @@ -1397,6 +1415,20 @@ component singleton { } } + /** + * Ensure manifest has an excludes array (backwards compatibility). + * The excludes array holds skill names that the user explicitly removed and + * should never be auto-reinstalled by refresh(). + * Mutates manifest in place. + * + * @manifest The manifest struct to ensure has an excludes key + */ + private function ensureExcludesSection( required struct manifest ){ + if ( !structKeyExists( arguments.manifest, "excludes" ) ) { + arguments.manifest[ "excludes" ] = [] + } + } + /** * Delete a skill directory under .ai/skills/ if it exists. * From 33ab6abe8e7ab97749c5e9ae626c9eb1f1ffe66a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:06:19 +0000 Subject: [PATCH 22/42] fix: address code review - semicolon consistency and rename closure param Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/92d13e96-d882-4c14-9d67-62579d852353 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- models/SkillManager.cfc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index cb582fc..dabd32e 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -200,7 +200,7 @@ component singleton { var missingDesiredSkills = desiredTargets.filter( ( t ) => { // Skip skills the user has explicitly excluded if ( arguments.manifest.excludes.findNoCase( t.name ) ) { - return false + return false; } return !manifest.skills .filter( ( s ) => { @@ -1361,7 +1361,7 @@ component singleton { // If this skill was previously excluded, lift the exclusion now that it is being explicitly installed ensureExcludesSection( arguments.manifest ) - arguments.manifest.excludes = arguments.manifest.excludes.filter( ( ex ) => !ex.equalsIgnoreCase( resolvedName ) ) + arguments.manifest.excludes = arguments.manifest.excludes.filter( ( excludedName ) => !excludedName.equalsIgnoreCase( resolvedName ) ) return resolvedName } From 029efb42cbebb1e73913de3ebebfcce6c1359598 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 20:11:43 +0000 Subject: [PATCH 23/42] docs: add changelog entry for skills remove exclusion fix Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/9646a1c2-ec9e-48db-9ef0-4d0d4d75f87a Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog.md b/changelog.md index 3ba6779..0f914e7 100644 --- a/changelog.md +++ b/changelog.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AIService.diagnose()` now uses the `static.AI_DIR` constant (`.agents`) instead of the hardcoded `/.ai` path - `coldbox ai uninstall` now correctly checks, removes, and references the `.agents` directory - `coldbox ai skills add slug --list` was not working. +- **`coldbox ai skills remove` reinstalling removed skills during refresh** + - When a skill was removed via `coldbox ai skills remove`, the subsequent agent config regeneration step (`refresh()`) would detect the skill as "missing" (because its module dependency was still present in `box.json`) and immediately reinstall it + - Removed skills are now tracked in a new `manifest.excludes[]` array so that `refresh()` will never auto-reinstall them + - Explicitly re-installing a previously excluded skill via `coldbox ai skills install` lifts the exclusion and restores normal auto-management ### Added From c9b4039a41a2033ebdc24776cf3854546fa56f79 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 22:13:48 +0200 Subject: [PATCH 24/42] more remove goodness --- models/SkillManager.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index dabd32e..8dce724 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -199,7 +199,7 @@ component singleton { var missingDesiredSkills = desiredTargets.filter( ( t ) => { // Skip skills the user has explicitly excluded - if ( arguments.manifest.excludes.findNoCase( t.name ) ) { + if ( manifest.excludes.findNoCase( t.name ) ) { return false; } return !manifest.skills From b482e77c80f6bfb6c1899a00b8a8134b78d1d91c Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Mon, 11 May 2026 22:25:04 +0200 Subject: [PATCH 25/42] formatting updates --- .bxformat.json => .cfformat.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .bxformat.json => .cfformat.json (100%) diff --git a/.bxformat.json b/.cfformat.json similarity index 100% rename from .bxformat.json rename to .cfformat.json From 64347d4a599ad3b358201617a7415f9652c93f77 Mon Sep 17 00:00:00 2001 From: lmajano <137111+lmajano@users.noreply.github.com> Date: Mon, 11 May 2026 20:25:42 +0000 Subject: [PATCH 26/42] Apply cfformat changes --- commands/coldbox/ai/skills/install.cfc | 48 ++++----- commands/coldbox/ai/skills/list.cfc | 12 +-- commands/coldbox/ai/skills/update.cfc | 140 ++++++++++++++----------- models/BaseAICommand.cfc | 4 +- models/SkillManager.cfc | 16 ++- 5 files changed, 126 insertions(+), 94 deletions(-) diff --git a/commands/coldbox/ai/skills/install.cfc b/commands/coldbox/ai/skills/install.cfc index c5c87d3..52bbe52 100644 --- a/commands/coldbox/ai/skills/install.cfc +++ b/commands/coldbox/ai/skills/install.cfc @@ -380,13 +380,13 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills var repoList = variables.skillManager.fetchRepoSkillList( slugOwner, slugRepo ) for ( var s in repoList ) { resolved.append( { - owner : slugOwner, - repo : slugRepo, - slug : s.slug, - name : s.name, + owner : slugOwner, + repo : slugRepo, + slug : s.slug, + name : s.name, description : s.description ?: "", - type : "core", - source : "" + type : "core", + source : "" } ) } } else if ( parts.len() == 3 ) { @@ -398,13 +398,13 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills if ( directMatch.len() ) { var dm = directMatch.first() resolved.append( { - owner : slugOwner, - repo : slugRepo, - slug : dm.slug, - name : dm.name, + owner : slugOwner, + repo : slugRepo, + slug : dm.slug, + name : dm.name, description : dm.description ?: "", - type : "core", - source : "" + type : "core", + source : "" } ) } else { // Fall back to category filter @@ -412,13 +412,13 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills if ( categoryMatches.len() ) { for ( var cs in categoryMatches ) { resolved.append( { - owner : slugOwner, - repo : slugRepo, - slug : cs.slug, - name : cs.name, + owner : slugOwner, + repo : slugRepo, + slug : cs.slug, + name : cs.name, description : cs.description ?: "", - type : "core", - source : "" + type : "core", + source : "" } ) } } @@ -429,13 +429,13 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills // Registry stores skill_slug with ~ as separator (not /), so join accordingly var skillSlug = parts.slice( 3 ).toList( "~" ) resolved.append( { - owner : slugOwner, - repo : slugRepo, - slug : skillSlug, - name : parts.last(), + owner : slugOwner, + repo : slugRepo, + slug : skillSlug, + name : parts.last(), description : "", - type : "core", - source : "" + type : "core", + source : "" } ) } } diff --git a/commands/coldbox/ai/skills/list.cfc b/commands/coldbox/ai/skills/list.cfc index 8aa61c9..8d2543d 100644 --- a/commands/coldbox/ai/skills/list.cfc +++ b/commands/coldbox/ai/skills/list.cfc @@ -9,7 +9,7 @@ */ component extends="coldbox-cli.models.BaseAICommand" { - property name="skillManager" inject="SkillManager@coldbox-cli"; + property name="skillManager" inject="SkillManager@coldbox-cli"; property name="progressBarGeneric" inject="progressBarGeneric"; /** @@ -36,13 +36,13 @@ component extends="coldbox-cli.models.BaseAICommand" { } if ( arguments.json ) { - var manifest = loadManifest( arguments.directory ) + var manifest = loadManifest( arguments.directory ) manifest[ "installed" ] = true if ( arguments.outdated ) { - var integrity = skillManager.validateSkillIntegrity( arguments.directory, info ) - manifest[ "outdated" ] = integrity.stale.len() > 0 - manifest[ "outdatedCount" ] = integrity.stale.len() + var integrity = skillManager.validateSkillIntegrity( arguments.directory, info ) + manifest[ "outdated" ] = integrity.stale.len() > 0 + manifest[ "outdatedCount" ] = integrity.stale.len() manifest[ "outdatedSkills" ] = integrity.stale } @@ -148,7 +148,7 @@ component extends="coldbox-cli.models.BaseAICommand" { // Summary print.line() - printInfo( "Total: #(info.skills.len() + info.customSkills.len())# skill(s) installed" ) + printInfo( "Total: #( info.skills.len() + info.customSkills.len() )# skill(s) installed" ) print.line() if ( !outdated ) { diff --git a/commands/coldbox/ai/skills/update.cfc b/commands/coldbox/ai/skills/update.cfc index ff36da0..fac853f 100644 --- a/commands/coldbox/ai/skills/update.cfc +++ b/commands/coldbox/ai/skills/update.cfc @@ -8,8 +8,8 @@ component extends="coldbox-cli.models.BaseAICommand" { // DI - property name="skillManager" inject="SkillManager@coldbox-cli"; - property name="agentRegistry" inject="AgentRegistry@coldbox-cli"; + property name="skillManager" inject="SkillManager@coldbox-cli"; + property name="agentRegistry" inject="AgentRegistry@coldbox-cli"; property name="progressBarGeneric" inject="progressBarGeneric"; /** @@ -19,7 +19,7 @@ component extends="coldbox-cli.models.BaseAICommand" { * @directory The target directory (defaults to current directory). */ function run( - string name = "", + string name = "", string directory = getCwd() ){ showColdBoxBanner( "Update AI Skills" ) @@ -34,16 +34,16 @@ component extends="coldbox-cli.models.BaseAICommand" { if ( arguments.name.len() ) { _updateSingle( - name = arguments.name, + name = arguments.name, directory = arguments.directory, - manifest = manifest + manifest = manifest ) return } _updateAll( directory = arguments.directory, - manifest = manifest + manifest = manifest ) } @@ -56,7 +56,7 @@ component extends="coldbox-cli.models.BaseAICommand" { required struct manifest ){ var normalizedName = arguments.name.replaceAll( "\\s+", "-" ) - var entries = arguments.manifest.skills.filter( ( s ) => s.name == normalizedName ) + var entries = arguments.manifest.skills.filter( ( s ) => s.name == normalizedName ) if ( entries.isEmpty() ) { printError( "Skill '#normalizedName#' is not installed." ) @@ -79,18 +79,14 @@ component extends="coldbox-cli.models.BaseAICommand" { printInfo( "Updating #normalizedName# (#entry.owner#/#entry.repo#/#entry.slug#)..." ) print.toConsole() - var result = variables.skillManager.downloadSkill( - entry.owner, - entry.repo, - entry.slug - ) + var result = variables.skillManager.downloadSkill( entry.owner, entry.repo, entry.slug ) if ( result.keyExists( "error" ) && result.error ) { - printError( "Failed to download '#normalizedName#': #result.message ?: 'unknown error'#" ) + printError( "Failed to download '#normalizedName#': #result.message ?: "unknown error"#" ) return } - var skill = result.skill + var skill = result.skill var auditStatus = skill.audit_status ?: "skipped" if ( auditStatus == "block" ) { printError( "Update blocked for '#normalizedName#' by security audit." ) @@ -98,22 +94,28 @@ component extends="coldbox-cli.models.BaseAICommand" { } variables.skillManager.installRemoteSkill( - directory = arguments.directory, - name = normalizedName, - content = result.content, - owner = skill.owner, - repo = skill.repo, - path = skill.skill_dir, - sha = skill.file_sha, + directory = arguments.directory, + name = normalizedName, + content = result.content, + owner = skill.owner, + repo = skill.repo, + path = skill.skill_dir, + sha = skill.file_sha, description = skill.description ?: "", auditStatus = auditStatus, - skillType = entry.type ?: "core", - source = entry.source ?: "", - manifest = arguments.manifest + skillType = entry.type ?: "core", + source = entry.source ?: "", + manifest = arguments.manifest ) - saveManifest( arguments.directory, arguments.manifest ) - _regenerateAgents( arguments.directory, arguments.manifest ) + saveManifest( + arguments.directory, + arguments.manifest + ) + _regenerateAgents( + arguments.directory, + arguments.manifest + ) print.line() printSuccess( "✓ Updated #normalizedName#" ) @@ -128,9 +130,9 @@ component extends="coldbox-cli.models.BaseAICommand" { ){ var targets = arguments.manifest.skills.filter( ( s ) => { return ( s.type ?: "" ) != "custom" && - ( s.owner ?: "" ).len() && - ( s.repo ?: "" ).len() && - ( s.slug ?: "" ).len() + ( s.owner ?: "" ).len() && + ( s.repo ?: "" ).len() && + ( s.slug ?: "" ).len() } ) if ( targets.isEmpty() ) { @@ -150,29 +152,39 @@ component extends="coldbox-cli.models.BaseAICommand" { var batchItems = targets.map( ( t ) => { return { owner : t.owner, - repo : t.repo, + repo : t.repo, skill : t.slug } } ) printInfo( "Downloading updated skills from registry..." ) - variables.progressBarGeneric.update( percent = 0, currentCount = 0, totalCount = total ) + variables.progressBarGeneric.update( + percent = 0, + currentCount = 0, + totalCount = total + ) var batchResults = variables.skillManager.downloadSkillBatch( batchItems ) - variables.progressBarGeneric.update( percent = 100, currentCount = total, totalCount = total ) + variables.progressBarGeneric.update( + percent = 100, + currentCount = total, + totalCount = total + ) variables.progressBarGeneric.clear() print.line().toConsole() - var successCount = 0 - var failCount = 0 + var successCount = 0 + var failCount = 0 var repoSkillCache = {} batchResults.each( ( result ) => { if ( result.keyExists( "error" ) && result.error ) { - var failedCoords = _extractBatchCoordinates( result ) + var failedCoords = _extractBatchCoordinates( result ) var failedMessage = _extractBatchMessage( result ) - var targetMatch = targets.filter( ( t ) => { - return t.owner == failedCoords.owner && t.repo == failedCoords.repo && t.slug == failedCoords.slug - } ).first() ?: {} + var targetMatch = targets + .filter( ( t ) => { + return t.owner == failedCoords.owner && t.repo == failedCoords.repo && t.slug == failedCoords.slug + } ) + .first() ?: {} if ( failedCoords.owner.len() && failedCoords.repo.len() && failedCoords.slug.len() ) { var retry = variables.skillManager.downloadSkill( @@ -199,7 +211,7 @@ component extends="coldbox-cli.models.BaseAICommand" { if ( !( replacementRetry.keyExists( "error" ) && replacementRetry.error ) ) { replacementRetry[ "_targetEntry" ] = targetMatch - result = replacementRetry + result = replacementRetry } else { var replacementLabel = "#targetMatch.owner#/#targetMatch.repo#/#replacement.slug#" printWarn( " ⚠ #replacementLabel#: #replacementRetry.message ?: retryMessage#" ) @@ -230,9 +242,9 @@ component extends="coldbox-cli.models.BaseAICommand" { } } - var skill = result.skill + var skill = result.skill var auditStatus = skill.audit_status ?: "skipped" - var matchSlug = skill.skill_slug ?: skill.skill_dir.listLast( "/" ) + var matchSlug = skill.skill_slug ?: skill.skill_dir.listLast( "/" ) if ( auditStatus == "block" ) { printWarn( " ⚠ #matchSlug# blocked by security audit and skipped" ) @@ -240,25 +252,27 @@ component extends="coldbox-cli.models.BaseAICommand" { return } - var matches = targets.filter( ( t ) => t.slug == matchSlug ) - var matchEntry = result.keyExists( "_targetEntry" ) ? result._targetEntry : ( matches.isEmpty() ? {} : matches.first() ) + var matches = targets.filter( ( t ) => t.slug == matchSlug ) + var matchEntry = result.keyExists( "_targetEntry" ) ? result._targetEntry : ( + matches.isEmpty() ? {} : matches.first() + ) var localName = matchEntry.keyExists( "name" ) ? matchEntry.name : skill.skill_dir.listLast( "/" ) print.toConsole( " Updating #localName#..." ) variables.skillManager.installRemoteSkill( - directory = directory, - name = localName, - content = result.content, - owner = skill.owner, - repo = skill.repo, - path = skill.skill_dir, - sha = skill.file_sha, + directory = directory, + name = localName, + content = result.content, + owner = skill.owner, + repo = skill.repo, + path = skill.skill_dir, + sha = skill.file_sha, description = skill.description ?: "", auditStatus = auditStatus, - skillType = matchEntry.keyExists( "type" ) ? matchEntry.type : "core", - source = matchEntry.keyExists( "source" ) ? matchEntry.source : "", - manifest = manifest + skillType = matchEntry.keyExists( "type" ) ? matchEntry.type : "core", + source = matchEntry.keyExists( "source" ) ? matchEntry.source : "", + manifest = manifest ) print.greenLine( " ✓" ) @@ -287,8 +301,8 @@ component extends="coldbox-cli.models.BaseAICommand" { var skillPayload = arguments.result.keyExists( "skill" ) ? arguments.result.skill : {} var owner = _extractBatchScalar( skillPayload.owner ?: skillPayload.OWNER ?: "" ) - var repo = _extractBatchScalar( skillPayload.repo ?: skillPayload.REPO ?: "" ) - var slug = _extractBatchScalar( skillPayload.skill ?: skillPayload.SKILL ?: "" ) + var repo = _extractBatchScalar( skillPayload.repo ?: skillPayload.REPO ?: "" ) + var slug = _extractBatchScalar( skillPayload.skill ?: skillPayload.SKILL ?: "" ) return { owner : owner, @@ -305,7 +319,9 @@ component extends="coldbox-cli.models.BaseAICommand" { return arguments.result.message } - if ( arguments.result.keyExists( "messages" ) && isArray( arguments.result.messages ) && arguments.result.messages.len() ) { + if ( + arguments.result.keyExists( "messages" ) && isArray( arguments.result.messages ) && arguments.result.messages.len() + ) { return arguments.result.messages[ 1 ] } @@ -316,13 +332,19 @@ component extends="coldbox-cli.models.BaseAICommand" { * Detect not-found errors from registry responses. */ private boolean function _isSkillNotFoundMessage( required string message ){ - return findNoCase( "skill not found", arguments.message ?: "" ) > 0 + return findNoCase( + "skill not found", + arguments.message ?: "" + ) > 0 } /** * Attempt to resolve a replacement slug for renamed skills in the same repo. */ - private struct function _resolveReplacementSkill( required struct target, required struct repoSkillCache ){ + private struct function _resolveReplacementSkill( + required struct target, + required struct repoSkillCache + ){ var cacheKey = "#arguments.target.owner#/#arguments.target.repo#" if ( !arguments.repoSkillCache.keyExists( cacheKey ) ) { arguments.repoSkillCache[ cacheKey ] = variables.skillManager.fetchRepoSkillList( @@ -355,7 +377,7 @@ component extends="coldbox-cli.models.BaseAICommand" { for ( var needle in needles ) { var suffix = "-#needle#" - var fuzzy = repoSkills.filter( ( s ) => { + var fuzzy = repoSkills.filter( ( s ) => { var slug = ( s.slug ?: "" ).lcase() return findNoCase( needle, slug ) > 0 || right( slug, suffix.len() ) == suffix } ) diff --git a/models/BaseAICommand.cfc b/models/BaseAICommand.cfc index 00f92d1..b48f430 100644 --- a/models/BaseAICommand.cfc +++ b/models/BaseAICommand.cfc @@ -6,8 +6,8 @@ component extends="coldbox-cli.models.BaseCommand" { // DI - All AI commands need these services - property name="aiService" inject="AIService@coldbox-cli"; - property name="formatterUtil" inject="Formatter"; + property name="aiService" inject="AIService@coldbox-cli"; + property name="formatterUtil" inject="Formatter"; /** * Ensures AI integration is installed and returns info diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index 8dce724..2fb0cc1 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -417,7 +417,10 @@ component singleton { if ( !isStruct( customSkill ) || !structKeyExists( customSkill, "name" ) ) { continue; } - var customSkillFile = getCustomSkillFilePath( arguments.directory, customSkill.name ) + var customSkillFile = getCustomSkillFilePath( + arguments.directory, + customSkill.name + ) if ( isNull( customSkillFile ) ) { missingCustomSkills.append( customSkill.name ) } @@ -659,7 +662,11 @@ component singleton { for ( var skill in arguments.manifest.skills ) { count++ if ( !isNull( arguments.onProgress ) && isCustomFunction( arguments.onProgress ) ) { - arguments.onProgress( currentCount = count, totalCount = total, skillName = skill.name ?: "" ) + arguments.onProgress( + currentCount = count, + totalCount = total, + skillName = skill.name ?: "" + ) } var skillFile = getSkillFilePath( arguments.directory, skill.name ) @@ -958,7 +965,10 @@ component singleton { // Also check custom skills var customSkills = arguments.manifest.customSkills ?: [] for ( var customSkill in customSkills ) { - var customSkillFile = getCustomSkillFilePath( arguments.directory, customSkill.name ) + var customSkillFile = getCustomSkillFilePath( + arguments.directory, + customSkill.name + ) if ( isNull( customSkillFile ) ) { issues.warnings.append( "Missing custom skill file: #customSkill.name#" ) issues.recommendations.append( From e4e43c9e8eb320e4290867a036eee5270d3cd2d5 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 11 May 2026 20:58:31 +0000 Subject: [PATCH 27/42] Version bump --- box.json | 2 +- changelog.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/box.json b/box.json index e737226..ea9399c 100644 --- a/box.json +++ b/box.json @@ -1,6 +1,6 @@ { "name":"ColdBox CLI", - "version":"8.12.0", + "version":"8.13.0", "location":"https://downloads.ortussolutions.com/ortussolutions/commandbox-modules/coldbox-cli/@build.version@/coldbox-cli-@build.version@.zip", "slug":"coldbox-cli", "author":"Ortus Solutions, Corp", diff --git a/changelog.md b/changelog.md index 0f914e7..15e1932 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.12.0] - 2026-05-11 + ### Fixed - **`coldbox ai doctor` and `coldbox ai uninstall` checking wrong directory** @@ -402,7 +404,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Eclipse support -[unreleased]: https://github.com/ColdBox/coldbox-cli/compare/v8.11.0...HEAD +[unreleased]: https://github.com/ColdBox/coldbox-cli/compare/v8.12.0...HEAD +[8.12.0]: https://github.com/ColdBox/coldbox-cli/compare/v8.11.0...v8.12.0 [8.11.0]: https://github.com/ColdBox/coldbox-cli/compare/v8.10.1...v8.11.0 [8.10.1]: https://github.com/ColdBox/coldbox-cli/compare/v8.10.0...v8.10.1 [8.10.0]: https://github.com/ColdBox/coldbox-cli/compare/v8.9.0...v8.10.0 From bf01983495f81598bf4a07e2763388d0036376bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:47:26 +0000 Subject: [PATCH 28/42] Initial plan From c94e74d2868fa5af0c45c9166146facba8b969b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:48:39 +0000 Subject: [PATCH 29/42] Add DB_SCHEMA key to env.example template Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/0aaba08f-24cc-42d5-a5ce-76b946533733 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- templates/env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/env.example b/templates/env.example index 362e197..bb9bf03 100644 --- a/templates/env.example +++ b/templates/env.example @@ -11,6 +11,7 @@ DB_DRIVER=MySQL DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=coldbox +DB_SCHEMA=coldbox DB_USER=root DB_PASSWORD= From 1a576a4c8598108105d5a1a2f49c827c34837e75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:50:59 +0000 Subject: [PATCH 30/42] Fix ai refresh custom skills closure scoping and preserve AGENTS sections Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/d4c4e167-5ab3-4436-a91f-9cd8a0a58a0e Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- models/AgentRegistry.cfc | 43 ++++++++++++++++++++-------------------- models/SkillManager.cfc | 6 +++--- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/models/AgentRegistry.cfc b/models/AgentRegistry.cfc index 02da38a..c155a3f 100644 --- a/models/AgentRegistry.cfc +++ b/models/AgentRegistry.cfc @@ -139,13 +139,13 @@ component singleton { * Merges newly generated managed content with any user-authored content from an existing file. * * The managed section is delimited by COLDBOX-CLI:START and COLDBOX-CLI:END HTML comment - * markers. On refresh, only the content between those markers is replaced; everything after - * the end marker (i.e. the user's custom documentation) is preserved unchanged. + * markers. On refresh, only the content between those markers is replaced; user-authored + * content before the start marker and after the end marker is preserved unchanged. * * Behavior: * - File does not exist → return newContent as-is (first-time write). - * - File exists but has no end marker → return newContent as-is (old format, no user section to preserve). - * - File exists with end marker → replace managed section, keep user section intact. + * - File exists but has no start/end marker pair → return newContent as-is. + * - File exists with a start/end marker pair → replace managed section, preserve user sections. * * @filePath Absolute path to the existing agent config file (may not exist yet). * @newContent Freshly generated content that includes both START and END markers. @@ -156,6 +156,7 @@ component singleton { required string filePath, required string newContent ){ + var startMarker = static.MANAGED_SECTION_START var endMarker = static.MANAGED_SECTION_END // Nothing to preserve — first-time write @@ -164,37 +165,37 @@ component singleton { } var existingContent = fileRead( filePath ) + var startPos = findNoCase( startMarker, existingContent ) + var endPos = findNoCase( endMarker, existingContent ) - // Find the end marker in the existing file - var endPos = findNoCase( endMarker, existingContent ) - - // Old-format file (no markers) — write fresh content, no user section to preserve - if ( !endPos ) { + // Old-format file (no marker pair) — write fresh content + if ( !startPos || !endPos || endPos <= startPos ) { return newContent } - // Extract user content: everything that comes after the end marker - var userStartPos = endPos + len( endMarker ) - var userContent = mid( + // Preserve user-authored content around managed section + var userContentBeforeManaged = left( existingContent, startPos - 1 ) + var userStartPos = endPos + len( endMarker ) + var userContentAfterManaged = mid( existingContent, userStartPos, len( existingContent ) - userStartPos + 1 ) - // Find the end marker position in the newly generated content - var newEndPos = findNoCase( endMarker, newContent ) - if ( !newEndPos ) { - // New template has no end marker — return new content plus preserved user section - return newContent & userContent + // Slice managed portion from the newly generated content + var newStartPos = findNoCase( startMarker, newContent ) + var newEndPos = findNoCase( endMarker, newContent ) + if ( !newStartPos || !newEndPos || newEndPos <= newStartPos ) { + return newContent & userContentAfterManaged } - // Slice off the managed portion of the new content (up to and including the end marker) - var managedContent = left( + var managedContent = mid( newContent, - newEndPos + len( endMarker ) - 1 + newStartPos, + newEndPos + len( endMarker ) - newStartPos ) - return managedContent & userContent + return userContentBeforeManaged & managedContent & userContentAfterManaged } /** diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index 2fb0cc1..8087b92 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -482,7 +482,7 @@ component singleton { // Remove custom skills whose files were deleted by the user missingCustomSkills.each( ( name ) => { variables.print.yellowLine( " 🧹 Removing deleted custom skill entry: #name#" ).toConsole() - arguments.manifest.customSkills = arguments.manifest.customSkills.filter( ( s ) => s.name != name ) + manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name ) changes.removed.append( name ) } ) @@ -497,7 +497,7 @@ component singleton { return; } - var alreadyInManifest = !arguments.manifest.customSkills.filter( ( s ) => s.name == dirName ).isEmpty() + var alreadyInManifest = !manifest.customSkills.filter( ( s ) => s.name == dirName ).isEmpty() if ( alreadyInManifest ) { return; } @@ -508,7 +508,7 @@ component singleton { var parsed = variables.utility.parseFrontmatter( content ) var description = parsed.frontmatter.description ?: "" - arguments.manifest.customSkills.append( { + manifest.customSkills.append( { "name" : dirName, "description" : description, "syncedAt" : dateTimeFormat( now(), "iso" ) From ee130d7f1ea62df5503ed3d48cd2f60702360acd Mon Sep 17 00:00:00 2001 From: lmajano <137111+lmajano@users.noreply.github.com> Date: Thu, 14 May 2026 20:58:31 +0000 Subject: [PATCH 31/42] Apply cfformat changes --- models/AgentRegistry.cfc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/models/AgentRegistry.cfc b/models/AgentRegistry.cfc index c155a3f..463855c 100644 --- a/models/AgentRegistry.cfc +++ b/models/AgentRegistry.cfc @@ -157,7 +157,7 @@ component singleton { required string newContent ){ var startMarker = static.MANAGED_SECTION_START - var endMarker = static.MANAGED_SECTION_END + var endMarker = static.MANAGED_SECTION_END // Nothing to preserve — first-time write if ( !fileExists( filePath ) ) { @@ -165,8 +165,8 @@ component singleton { } var existingContent = fileRead( filePath ) - var startPos = findNoCase( startMarker, existingContent ) - var endPos = findNoCase( endMarker, existingContent ) + var startPos = findNoCase( startMarker, existingContent ) + var endPos = findNoCase( endMarker, existingContent ) // Old-format file (no marker pair) — write fresh content if ( !startPos || !endPos || endPos <= startPos ) { From 17aa8df4b8f38e7a552e80f19a136f1ab7873859 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 18:04:04 +0000 Subject: [PATCH 32/42] Initial plan From fe70c41df6a18259cfe4f2131acffa9bf72c98bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 18:06:06 +0000 Subject: [PATCH 33/42] Propagate boxlang flag to handler-generated views Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/c18c79bb-9a9f-4f05-810e-28c19f365620 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- commands/coldbox/create/handler.cfc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/commands/coldbox/create/handler.cfc b/commands/coldbox/create/handler.cfc index 299dd02..11df458 100644 --- a/commands/coldbox/create/handler.cfc +++ b/commands/coldbox/create/handler.cfc @@ -214,7 +214,8 @@ component aliases="coldbox create controller" extends="coldbox-cli.models.BaseCo boolean open, boolean rest, boolean force, - boolean resource + boolean resource, + boolean boxlang ){ var results = { actions : "", tests : "" } var actionContent = fileRead( @@ -249,7 +250,8 @@ component aliases="coldbox create controller" extends="coldbox-cli.models.BaseCo content : "

#camelCaseHandlerName#.#thisAction#

", directory: arguments.viewsDirectory, force : arguments.force, - open : arguments.open + open : arguments.open, + boxlang : arguments.boxlang ) .run(); } From e4011667ce98ad6d10454812c77b0306074243ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 18:21:19 +0000 Subject: [PATCH 34/42] Add changelog entry for handler view language flag fix Agent-Logs-Url: https://github.com/ColdBox/coldbox-cli/sessions/55e138bc-afc1-409f-b814-c03cb604d2f0 Co-authored-by: lmajano <137111+lmajano@users.noreply.github.com> --- changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog.md b/changelog.md index 15e1932..b95b64f 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `coldbox create handler --boxlang` / `--noboxlang` now propagates language selection to auto-generated views so view extensions match the requested mode (`.bxm` for BoxLang, `.cfm` for CFML) + ## [8.12.0] - 2026-05-11 ### Fixed From 2477cd8f04d4fcf202c2d7529cd2c1f550374d68 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Tue, 19 May 2026 15:11:25 +0200 Subject: [PATCH 35/42] fix edge cases --- models/AgentRegistry.cfc | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/models/AgentRegistry.cfc b/models/AgentRegistry.cfc index 463855c..83dec94 100644 --- a/models/AgentRegistry.cfc +++ b/models/AgentRegistry.cfc @@ -164,17 +164,23 @@ component singleton { return newContent } - var existingContent = fileRead( filePath ) + // Read existing content and locate markers + var existingContent = fileRead( filePath ).trim() var startPos = findNoCase( startMarker, existingContent ) var endPos = findNoCase( endMarker, existingContent ) + // If existing content is empty or markers are not properly found, return new content as-is + if ( !len( existingContent ) ) { + return newContent + } + // Old-format file (no marker pair) — write fresh content if ( !startPos || !endPos || endPos <= startPos ) { return newContent } // Preserve user-authored content around managed section - var userContentBeforeManaged = left( existingContent, startPos - 1 ) + var userContentBeforeManaged = startPos > 1 ? left( existingContent, startPos - 1 ) : "" var userStartPos = endPos + len( endMarker ) var userContentAfterManaged = mid( existingContent, From d0acc28e04eb963f5cf95d411667938c8a6a067d Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Wed, 20 May 2026 16:46:08 +0200 Subject: [PATCH 36/42] introducing the tiered app template --- commands/coldbox/create/app-wizard.cfc | 4 ++-- commands/coldbox/create/app.cfc | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/commands/coldbox/create/app-wizard.cfc b/commands/coldbox/create/app-wizard.cfc index 553c42e..22660f9 100644 --- a/commands/coldbox/create/app-wizard.cfc +++ b/commands/coldbox/create/app-wizard.cfc @@ -176,8 +176,8 @@ component extends="app" { .options( [ { accessKey : 1, - value : "modern", - display : "Modern Template - Security-first CFML and BoxLang template with /app outside webroot", + value : "tiered", + display : "Tiered Template - Security-first CFML and BoxLang template with /app outside webroot", selected : true }, { diff --git a/commands/coldbox/create/app.cfc b/commands/coldbox/create/app.cfc index 5c1d626..3c8ba9a 100644 --- a/commands/coldbox/create/app.cfc +++ b/commands/coldbox/create/app.cfc @@ -13,7 +13,7 @@ * * - BoxLang (Default) * - BoxLang Desktop - * - Modern (CFML + BoxLang Default) + * - Tiered (CFML + BoxLang Default) * - flat (CFML + BoxLang Flat) * - rest (CFML + BoxLang RESTful API) * - rest-hmvc (HMVC + REST) @@ -21,7 +21,7 @@ * - vite (flat + vite) * . * {code:bash} - * coldbox create app skeleton=modern + * coldbox create app skeleton=tiered * // Same as * coldbox create app --cfml * {code} @@ -48,7 +48,7 @@ component extends="coldbox-cli.models.BaseCommand" { "flat" : "cbtemplate-flat", "boxlang" : "cbtemplate-boxlang", "desktop" : "cbtemplate-boxlang-desktop", - "modern" : "cbtemplate-modern", + "tiered" : "cbtemplate-tiered", "rest" : "cbtemplate-rest", "rest-hmvc" : "cbtemplate-rest-hmvc", "vite" : "cbtemplate-vite", @@ -75,7 +75,7 @@ component extends="coldbox-cli.models.BaseCommand" { * @migrations Run migration init after creation * @boxlang Set the language to BoxLang * @docker Include Docker files and setup Docker configuration - * @vite Setup Vite for frontend asset building (For BoxLang or Modern apps only) + * @vite Setup Vite for frontend asset building (For BoxLang or Tiered apps only) * @rest Is this a REST API project? (For BoxLang apps only) * @cfml Set the language to CFML explicitly (overrides boxlang) * @ai Enable AI integration for the application @@ -117,7 +117,7 @@ component extends="coldbox-cli.models.BaseCommand" { if ( arguments.cfml ) { arguments.boxlang = false; if ( arguments.skeleton == variables.defaultSkeleton ) { - arguments.skeleton = "modern"; + arguments.skeleton = "tiered"; } printInfo( "⚡Language set to CFML" ) } else { @@ -251,8 +251,8 @@ component extends="coldbox-cli.models.BaseCommand" { // VITE Setup if ( arguments.vite ) { - if ( !arguments.skeleton.reFindNoCase( "(modern|boxlang)" ) ) { - printWarn( "⚠️ Vite setup is only supported for 'modern' or 'boxlang' skeletons. Skipping Vite setup." ) + if ( !arguments.skeleton.reFindNoCase( "(tiered|boxlang)" ) ) { + printWarn( "⚠️ Vite setup is only supported for 'tiered' or 'boxlang' skeletons. Skipping Vite setup." ) } else { printInfo( "🥊 Setting up Vite for your frontend build system" ) fileCopy( From f02f100e56cfca2d5acaf5020562362cbf5a4627 Mon Sep 17 00:00:00 2001 From: Luis Majano Date: Wed, 20 May 2026 17:02:00 +0200 Subject: [PATCH 37/42] - `coldbox create model --tests` now prefixes the right location of the models --- changelog.md | 1 + commands/coldbox/create/model.cfc | 4 +++- models/BaseCommand.cfc | 13 +++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index b95b64f..1e0405e 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `coldbox create handler --boxlang` / `--noboxlang` now propagates language selection to auto-generated views so view extensions match the requested mode (`.bxm` for BoxLang, `.cfm` for CFML) +- `coldbox create model --tests` now prefixes the right location of the models ## [8.12.0] - 2026-05-11 diff --git a/commands/coldbox/create/model.cfc b/commands/coldbox/create/model.cfc index 554cca3..d352bcd 100644 --- a/commands/coldbox/create/model.cfc +++ b/commands/coldbox/create/model.cfc @@ -408,9 +408,11 @@ component extends="coldbox-cli.models.BaseCommand" { // Generate Tests if ( arguments.tests ) { + var appPrefix = getAppPrefixDot( getCWD() ) + var modelPrefix = appPrefix.isEmpty() ? "models." & arguments.name : appPrefix & "models." & arguments.name command( "coldbox create model-test" ) .params( - path : arguments.name, + path : modelPrefix, force : arguments.force, open : arguments.open, methods: arguments.methods, diff --git a/models/BaseCommand.cfc b/models/BaseCommand.cfc index d534de4..11bdda0 100644 --- a/models/BaseCommand.cfc +++ b/models/BaseCommand.cfc @@ -30,6 +30,19 @@ component accessors="true" { return variables.utility.detectTemplateType( cwd ) == "modern" ? "app/" : ""; } + /** + * Detects the application layout and returns the appropriate path prefix. + * In a modern layout (app/ and public/ directories exist), returns "app/". + * In a flat layout, returns "". + * + * @cwd The current working directory + * + * @return string "app." for modern layout, "" for flat layout + */ + function getAppPrefixDot( required cwd ){ + return variables.utility.detectTemplateType( cwd ) == "modern" ? "app." : ""; + } + /** * Detects the application layout and returns the appropriate modules directory path. * In a modern layout (or BoxLang project), modules live under lib/modules. From bb5867afb7dd501f81d8d07ae61e059fb275c1e7 Mon Sep 17 00:00:00 2001 From: lmajano <137111+lmajano@users.noreply.github.com> Date: Wed, 20 May 2026 15:02:40 +0000 Subject: [PATCH 38/42] Apply cfformat changes --- commands/coldbox/create/model.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/coldbox/create/model.cfc b/commands/coldbox/create/model.cfc index d352bcd..9893fda 100644 --- a/commands/coldbox/create/model.cfc +++ b/commands/coldbox/create/model.cfc @@ -408,7 +408,7 @@ component extends="coldbox-cli.models.BaseCommand" { // Generate Tests if ( arguments.tests ) { - var appPrefix = getAppPrefixDot( getCWD() ) + var appPrefix = getAppPrefixDot( getCWD() ) var modelPrefix = appPrefix.isEmpty() ? "models." & arguments.name : appPrefix & "models." & arguments.name command( "coldbox create model-test" ) .params( From 2422913ef624b55bf6b76ed7ecf6c33e4c66a6c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 07:56:15 +0000 Subject: [PATCH 39/42] Initial plan From 6efbd3247b59d5ea42ec196661130893290a94f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 07:59:09 +0000 Subject: [PATCH 40/42] fix: respect server.json webroot in watch-reinit --- commands/coldbox/watch-reinit.cfc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/commands/coldbox/watch-reinit.cfc b/commands/coldbox/watch-reinit.cfc index bdaf851..b21f810 100644 --- a/commands/coldbox/watch-reinit.cfc +++ b/commands/coldbox/watch-reinit.cfc @@ -76,7 +76,11 @@ component { "removed" : "red", "changed" : "yellow" } - var serverDetails = serverService.resolveServerDetails( {} ); + var defaultServer = serverService.getServerInfoByDiscovery( serverConfigFile = "server.json" ); + var serverDetails = serverService.resolveServerDetails( { + name : defaultServer.keyExists( "name" ) ? defaultServer.name : "", + directory : getCWD() + } ); var serverStatus = serverService.isServerRunning( serverDetails.serverInfo ); // Tabula rasa From 3eca856293a57a53d880e37dfd2ec1f70561fe7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 08:09:47 +0000 Subject: [PATCH 41/42] docs: add watch-reinit fix to changelog and changelog rule --- AGENTS.md | 1 + changelog.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 846537f..6e0211e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ Each command extends `BaseCommand.cfc` (or `BaseAICommand.cfc` for AI commands) - **Always lint markdown files after editing** - Run `npx markdownlint-cli -f {filename}` after any markdown file modifications - Markdown linting configuration is in `.markdownlint.json` - Fix any linting errors before committing changes +- **Always update `changelog.md`** for every fix, update, or addition ## Development Workflows diff --git a/changelog.md b/changelog.md index 1e0405e..0b1069a 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `coldbox create handler --boxlang` / `--noboxlang` now propagates language selection to auto-generated views so view extensions match the requested mode (`.bxm` for BoxLang, `.cfm` for CFML) - `coldbox create model --tests` now prefixes the right location of the models +- `coldbox watch-reinit` now honors `server.json` webroot-based server discovery so running apps in subdirectory webroots are correctly found and reinitialized ## [8.12.0] - 2026-05-11 From 627fee5f2186827a27b25d4ac6401884e70b2d24 Mon Sep 17 00:00:00 2001 From: lmajano <137111+lmajano@users.noreply.github.com> Date: Thu, 28 May 2026 08:12:51 +0000 Subject: [PATCH 42/42] Apply cfformat changes --- commands/coldbox/watch-reinit.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/coldbox/watch-reinit.cfc b/commands/coldbox/watch-reinit.cfc index b21f810..c51b680 100644 --- a/commands/coldbox/watch-reinit.cfc +++ b/commands/coldbox/watch-reinit.cfc @@ -81,7 +81,7 @@ component { name : defaultServer.keyExists( "name" ) ? defaultServer.name : "", directory : getCWD() } ); - var serverStatus = serverService.isServerRunning( serverDetails.serverInfo ); + var serverStatus = serverService.isServerRunning( serverDetails.serverInfo ); // Tabula rasa command( "cls" ).run();