From 0a0a6279076e507d98bbebafb4ec5f0b8a949ee2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:57:59 +0000 Subject: [PATCH 1/5] feat(build): emit the routed CLI into host artifacts as bin/.mjs (#387) Installed plugins ship skills, hooks, and script routes but had no way to run the routed CLI without a separate npm install. Every target whose adapter publishes the new `cli` capability (all built-in targets) now receives the compiled command graph as `bin/.mjs` (+ `-flight.mjs` worker), run as `node /bin/.mjs`. Script routes reach it as the `../bin/` sibling of their own `import.meta.url`; skills and hooks through the plugin-root token. Manifest, validation (`cliBin` layout), `inspect` (`cli` component), and `inspect --bundler` know the new directory; a target without the capability omits the bin with AB4765; a host file colliding with the bin path is AB4766. The npm package bin is unchanged. --- .changeset/387-artifact-routed-cli.md | 18 + docs/diagnostics.md | 16 + docs/entry-conventions.md | 64 +++- docs/framework-mode.md | 8 +- packages/agent-bundle/README.md | 9 + .../src/adapters/capability-state.ts | 11 + packages/agent-bundle/src/adapters/claude.ts | 4 + packages/agent-bundle/src/adapters/codex.ts | 4 + packages/agent-bundle/src/adapters/cursor.ts | 4 + packages/agent-bundle/src/adapters/plugin.ts | 14 +- .../agent-bundle/src/adapters/portable.ts | 7 + .../agent-bundle/src/adapters/registry.ts | 7 +- packages/agent-bundle/src/adapters/types.ts | 8 + packages/agent-bundle/src/api.ts | 23 +- packages/agent-bundle/src/build/build.ts | 47 ++- packages/agent-bundle/src/build/cli-bins.ts | 222 +++++++++++++ .../agent-bundle/src/build/entry-shell.ts | 25 +- .../agent-bundle/src/build/inspect-bundler.ts | 31 +- .../src/build/validate-artifact.ts | 1 + packages/agent-bundle/src/config/validate.ts | 54 +++ .../tests/adapter-capability-states.test.ts | 23 ++ .../tests/artifact-cli-bin.test.ts | 307 ++++++++++++++++++ rstest.integration-tests.ts | 1 + 23 files changed, 889 insertions(+), 19 deletions(-) create mode 100644 .changeset/387-artifact-routed-cli.md create mode 100644 packages/agent-bundle/src/build/cli-bins.ts create mode 100644 packages/agent-bundle/tests/artifact-cli-bin.test.ts diff --git a/.changeset/387-artifact-routed-cli.md b/.changeset/387-artifact-routed-cli.md new file mode 100644 index 000000000..deedd5d62 --- /dev/null +++ b/.changeset/387-artifact-routed-cli.md @@ -0,0 +1,18 @@ +--- +"agent-bundle": minor +--- + +Emit the routed CLI (`src/cli/**`) into host artifacts, not only the npm +package build. Every target whose adapter publishes the new `cli` capability +(all built-in targets: `claude`, `codex`, `cursor`, `portable`, `plugin`) +now receives `bin/.mjs` — the same compiled command graph as +`dist/bin/.js`, run as `node /bin/.mjs` +— plus `bin/-flight.mjs` when any command renders, so installed +skills, hooks, and script routes can invoke the CLI without a separate npm +install. Script routes reach it as the `../bin/.mjs` sibling of +their own `import.meta.url`; skills and hooks reach it through the plugin-root +token. The artifact manifest, validation (`cliBin` layout), `inspect` +component accounting (`cli` kind), and `inspect --bundler` all know the new +`bin/` directory. A target without the capability omits the bin and reports +`AB4765`; a host-emitted file colliding with the bin path is `AB4766`. The +package build's own bin emission is unchanged. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 91f0ea35e..a808a4115 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -24,6 +24,7 @@ gate a build, a validation, or a dev rebuild. | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | +| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); see below. | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | @@ -388,6 +389,21 @@ identity is never served as a real one. | --- | --- | --- | --- | | `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | +## Artifact-hosted routed CLI (`AB4765`–`AB4766`) + +A generated-mode `src/cli/**` surface compiles into the npm package bin +(`dist/bin/.js`) **and** into every host artifact whose adapter +publishes a supported `cli` capability, as `bin/.mjs` (plus +`bin/-flight.mjs` when any command renders). Every built-in +target hosts it; the two codes cover a target that does not and a host file +that claims the same path. See “The routed CLI shell” in +`docs/entry-conventions.md` for the layout and the sibling-path convention. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | +| `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name). The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | + ## Config beside a route-generated MCP server (`AB4340`) A `mcp.servers.` block for a server the route graph compiles in diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 8fea44177..3e1f5cec4 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -79,7 +79,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | -| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | +| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | | `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | @@ -150,9 +150,11 @@ for the generated worker or executable process. Workspace-durable generated MCP workers store under `$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent, the worker derives the artifact root from the parent of its own `mcp/` -directory. Routed CLI bins and rendered scripts use +directory. The npm package's routed CLI bin and rendered scripts use `$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise -`$PWD/.agent-bundle/state`. Notice authorization is deliberately permissive +`$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin +(`/bin/.mjs`) derives the artifact root from the parent of its +own `bin/` directory instead, like the MCP worker. Notice authorization is deliberately permissive in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger, while application authorization policy is deferred. @@ -381,6 +383,62 @@ machine output owns stdout. Rendered scripts (`src/scripts/.tsx`) share the same shell and output contract with `{ argv, signal }` component props and status-derived exit codes. +#### The routed CLI inside host artifacts + +The package bin only reaches users who install the npm package. Hooks, +skills, and script routes ship with the **host artifact**, so the build also +emits the same compiled command graph into every selected target whose +adapter publishes the `cli` capability — all built-in targets (`claude`, +`codex`, `cursor`, `portable`, `plugin`), because the artifact root is +already a plain directory Node executes `mcp/` and `scripts/` files from: + +```text +artifact// + bin/.mjs # the routed CLI: node bin/.mjs [args] + bin/-flight.mjs # react-server worker, present when any command renders + scripts/.mjs + mcp/… +``` + +The artifact bin is a self-contained ESM module with no shebang or +executable bit — invoke it as `node /bin/.mjs +`, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, +exit codes, and signals are identical to the package bin. One deliberate +difference: workspace-durable state without a host-supplied +`AGENT_BUNDLE_PLUGIN_ROOT` anchors on the **artifact root** (the parent of +`bin/`, the same fallback the generated MCP worker beside it uses) rather +than `$PWD/.agent-bundle/state`, so a co-installed CLI and server observe +one store. The npm package bin keeps its `cwd` fallback. + +Reaching the bin from the other surfaces: + +- **Skills and hooks** use the plugin-root token + (`agent-bundle:path:plugin-root`, or a host spelling such as + `${CLAUDE_PLUGIN_ROOT}`), lowered per host exactly like MCP entries: + `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` in Claude Skill Markdown and + hook commands, `${PLUGIN_ROOT}/…` in Codex hooks, `${CURSOR_PLUGIN_ROOT}/…` + in Cursor hooks. Only Claude documents Skill Markdown interpolation, so a + skill that spells the token is a Claude-only skill (`AB3008` elsewhere); + skills for other hosts describe the path relative to the plugin root + instead. +- **Script routes** use the sibling convention: from the compiled + `scripts/.mjs`, the bin is `new URL('../bin/.mjs', + import.meta.url)`, so a plain `src/scripts/.ts` can + `spawn(process.execPath, [fileURLToPath(binUrl), ...argv])` and forward + stdio. `import.meta.url` is rewritten to the artifact location by the + bundle, never left pointing at `src/`. + +`inspect` accounts for the bin as one `cli` component per target (selected, +or skipped with the host's `cli` capability judgment), `inspect --bundler` +dumps each target's `bin/` composition beside its scripts, and the artifact +manifest records both files with `bundle` provenance naming every command +route. Artifact validation admits the `bin/` layout only for adapters that +declare it (`cliBin`); an adapter that publishes a supported `cli` capability +without that layout is rejected at registration. A target without the +capability omits the bin and reports `AB4765`; a host-emitted file at the +same path (a Claude `claude.bin` directory shipping `.mjs`) is +`AB4766`. The package build's `dist/bin/.js` is unchanged. + #### Project generated MCP tools into the CLI (power tier) `routes.mcpCommands` adds tools from generated MCP servers to the same command diff --git a/docs/framework-mode.md b/docs/framework-mode.md index adcbf68d9..2c855b7ea 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -172,7 +172,11 @@ the handwritten `runRscCli` compatibility path still serializes validated results and never renders JSX. Routed `src/cli/**` commands and `src/scripts/**` scripts follow one sentence: `.tsx` renders through the Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` -is plain. +is plain. The routed CLI ships twice from one build: as the npm package bin +(`dist/bin/.js`) for users who install the package, and as +`bin/.mjs` inside every host artifact so the plugin's own skills, +hooks, and scripts can run it with `node` from the installed plugin root +(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-host-artifacts)). ## Release identity in source: `agent-bundle/meta` @@ -276,7 +280,7 @@ only the string shorthand, not Rsbuild 2.x's per-asset `DistPathConfig` for such paths as JavaScript, CSS, and SVG subdirectories. `output.filename` templates, `output.assetPrefix`, and `output.cleanDistPath` are also deliberately deferred: host packs have a framework-owned -`/skills|mcp|scripts|assets/...` layout content-addressed by the +`/skills|mcp|scripts|bin|assets/...` layout content-addressed by the artifact manifest. Unlike machine-local Rsbuild config, the hashed, portable release-identity config rejects absolute paths. The per-invocation CLI `--output` flag can override the configured relative artifact root, but it is diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 8248408f4..265125564 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -707,6 +707,15 @@ Skill Markdown are inert in the workbench renderer. Top-level `scripts` is a record of stable output names to an entry path or `{ entry, targets? }`. JavaScript/TypeScript entries bundle to `scripts/.mjs`; `.sh`, `.bash`, and `.py` entries copy byte-for-byte while preserving source modes. The generated `agent-bundle.manifest.json` records file digests for stable artifact validation. +A project with routed `src/cli/**` commands also ships that CLI inside every host artifact as +`/bin/.mjs` (plus `bin/-flight.mjs` when a command renders), a +self-contained module run as `node /bin/.mjs ` — so a script +route can spawn its `../bin/.mjs` sibling and a Claude skill can point at +`${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in target +publishes the `cli` capability that admits it; `inspect` accounts for it as a `cli` component, and +the manifest records both files with bundle provenance. The npm package bin under `dist/bin/` is +unchanged. See `docs/entry-conventions.md` for the layout and diagnostics (`AB4765`, `AB4766`). + ### What gets hashed Hash pins cover vendored external content whose ground truth lives outside this repository and can diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 28c3baa45..c15a55e54 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -17,6 +17,17 @@ export const supportedCapability = (evidence: CapabilityEvidence): CapabilitySta state: 'supported', }); +/** + * The capability that admits the compiled routed CLI (`src/cli/**`) into a + * target's host artifact as `bin/.mjs` (#387). It asks nothing of + * the host beyond what `scripts/` and `mcp/` entries already rely on — the + * artifact root is installed as a plain directory Node can execute from — so a + * target publishes it whenever its plugin root is such a directory. An adapter + * that publishes no row reads as an honest `unavailable`, and the bin is + * omitted from that target with an inspect entry naming the reason. + */ +export const cliBinCapability = 'cli'; + export const unavailableCapability = (reason: string): CapabilityState => Object.freeze({ reason, state: 'unavailable', diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 567ff169d..9f838afed 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -22,6 +22,7 @@ import { capabilityStateFromSupport, eventRouteCapabilitiesFrom, supportedEventRouteNamesFrom, + cliBinCapability, supportedCapability, unavailableCapability, } from './capability-state.ts'; @@ -3272,6 +3273,9 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not document message channel declarations.', ), + // The routed CLI bin rides the same plugin-root directory the pinned + // contract already executes `mcp/` and `scripts/` files from (#387). + [cliBinCapability]: supportedCapability(evidence), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, evidence, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 1bb6ab934..b49fca15d 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -21,6 +21,7 @@ import { capabilityStateFromSupport, eventRouteCapabilitiesFrom, supportedEventRouteNamesFrom, + cliBinCapability, supportedCapability, unavailableCapability, } from './capability-state.ts'; @@ -1259,6 +1260,9 @@ export const codexAdapter: TargetAdapter = Object.freeze({ artifactLayout: standardArtifactLayout, capabilities: Object.freeze({ ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), + // The routed CLI bin rides the same plugin-root directory the pinned + // contract already executes `mcp/` and `scripts/` files from (#387). + [cliBinCapability]: supportedCapability(evidence), commands: unavailableCapability( 'The pinned Codex plugin contract (0.147.0) defines no commands component.', ), diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index fd111a873..aea619005 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -18,6 +18,7 @@ import { capabilityEvidence, capabilityStateFromSupport, eventRouteCapabilitiesFrom, + cliBinCapability, supportedEventRouteNamesFrom, supportedCapability, unavailableCapability, @@ -700,6 +701,9 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ capabilities: Object.freeze({ ...contractCapabilities, ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), + // The routed CLI bin rides the same plugin-root directory the pinned + // contract already executes `mcp/` and `scripts/` files from (#387). + [cliBinCapability]: supportedCapability(evidence), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 019e2575f..ced4c38e8 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -9,6 +9,7 @@ import { } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; import { + cliBinCapability, intersectCapabilityStates, supportedEventRouteNamesFrom, unavailableCapability, @@ -263,6 +264,7 @@ const mcpRuntime = createTargetMcpRuntime({ const artifactLayout: TargetArtifactLayout = Object.freeze({ assets: standardArtifactLayout.assets, bin: 'bin', + cliBin: standardArtifactLayout.cliBin, commands: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands' }), hookWrappers: standardArtifactLayout.hookWrappers, mcpApps: standardArtifactLayout.mcpApps, @@ -280,6 +282,8 @@ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginNam interface AgentsDocumentOptions { /** True when the Claude half emitted plugin-root executables. */ readonly bin: boolean; + /** Routed-CLI executables the build compiles into the shared `bin/` (#387). */ + readonly cliBins: readonly string[]; /** True when the Claude half emitted conventional command prompts. */ readonly commands: boolean; /** True when the Claude half of this bundle emitted `.lsp.json`. */ @@ -338,6 +342,8 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '- `bin/` — Claude Code executables added to the Bash tool PATH while the plugin is enabled; Codex and Cursor have no declared bin surface.', ] : []), + ...options.cliBins.map((name) => + `- \`bin/${name}.mjs\` — the compiled routed CLI shared by every host; run it as \`node bin/${name}.mjs --help\` from this directory (skills and scripts reach it through the plugin root).`), ...(options.workflows ? [ '- `workflows/` — Claude Code workflow scripts. Codex and Cursor have no declared workflows surface.', @@ -563,6 +569,9 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { entries.push({ content: agentsDocument(model, { bin: entries.some((entry) => entry.relativePath.startsWith('bin/')), + cliBins: (model.packageBuild?.bins ?? []) + .filter((bin) => bin.generatedCli !== undefined) + .map((bin) => bin.name), commands: selectedCommands.length > 0, lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), outputStyles: entries.some((entry) => entry.relativePath.startsWith('output-styles/')), @@ -617,7 +626,7 @@ const compositeEventCapabilities = Object.freeze(Object.fromEntries( )); const componentCapabilities = Object.freeze(Object.fromEntries( - ['commands', 'hooks', 'mcp', 'rules', 'skills'].map((capability) => [ + [cliBinCapability, 'commands', 'hooks', 'mcp', 'rules', 'skills'].map((capability) => [ capability, unionCapabilityStates( unionCapabilityStates( @@ -704,6 +713,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ bin: unavailableCapability( 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', ), + // One shared plugin root serves every host, so the routed CLI bin is + // hosted exactly like the shared `scripts/` and `mcp/` surfaces (#387). + [cliBinCapability]: componentCapabilities[cliBinCapability]!, channels: unavailableCapability( 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', ), diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 2bcfca850..c58826c4e 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -17,7 +17,9 @@ import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; import { capabilityEvidence, capabilityStateFromSupport, + cliBinCapability, eventRouteCapabilitiesFrom, + supportedCapability, unavailableCapability, } from './capability-state.ts'; import capabilityTable from './capabilities/portable-1.0.0.json' with { type: 'json' }; @@ -608,6 +610,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout: Object.freeze({ assets: 'assets', + cliBin: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'bin' }), mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), rootDocuments: Object.freeze(['INSTALL.md', 'install.mjs']), @@ -616,6 +619,10 @@ export const portableAdapter: TargetAdapter = Object.freeze({ }), capabilities: Object.freeze({ ...eventRouteCapabilitiesFrom(capabilityTable.eventRoutes, evidence), + // The routed CLI bin is not an Agent Plugins component; like `scripts/` + // it rides the plugin-root directory the standard's stdio MCP servers + // already execute from (#387). + [cliBinCapability]: supportedCapability(evidence), commands: unavailableCapability( 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', ), diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index 25f67ed92..f847155cd 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -9,7 +9,7 @@ import type { NormalizationNativeHookSource, NormalizationTargetRegistry, } from '../core/types.ts'; -import { capabilityIsSupported } from './capability-state.ts'; +import { capabilityIsSupported, cliBinCapability } from './capability-state.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; import { cursorAdapter } from './cursor.ts'; @@ -186,6 +186,7 @@ const snapshotArtifactLayout = ( const layout = record(declaredLayout); if (layout === undefined) throw new Error('Target adapter artifact layout must be a record.'); + const cliBin = layout.cliBin === undefined ? undefined : snapshotOutputLayout(layout.cliBin, 'routed CLI bin'); const commands = layout.commands === undefined ? undefined : snapshotOutputLayout(layout.commands, 'commands'); const hookWrappers = layout.hookWrappers === undefined ? undefined @@ -234,9 +235,13 @@ const snapshotArtifactLayout = ( if (skills !== undefined && !capabilityIsSupported(adapter.capabilities.skills)) { throw new Error(`Target adapter "${adapter.name}" declares Skill layout without skills capability.`); } + if (cliBin === undefined && capabilityIsSupported(adapter.capabilities[cliBinCapability])) { + throw new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`); + } return Object.freeze({ ...(assets === undefined ? {} : { assets }), ...(bin === undefined ? {} : { bin }), + ...(cliBin === undefined ? {} : { cliBin }), ...(commands === undefined ? {} : { commands }), ...(hookWrappers === undefined ? {} : { hookWrappers }), ...(mcpApps === undefined ? {} : { mcpApps }), diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index c99cb416d..62e432d5f 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -414,7 +414,14 @@ const invalidMcpDocumentIssues: readonly TargetArtifactDocumentIssue[] = deepFre */ export interface TargetArtifactLayout { readonly assets?: string; + /** A host-native executable directory copied verbatim (Claude Code's `bin/`). */ readonly bin?: string; + /** + * The compiled routed-CLI executable and its Flight worker + * (`bin/.mjs`, `bin/-flight.mjs`); required + * whenever the adapter publishes a supported `cli` capability (#387). + */ + readonly cliBin?: TargetArtifactOutputLayout; readonly commands?: TargetArtifactOutputLayout; readonly hookWrappers?: TargetArtifactOutputLayout; readonly mcpApps?: TargetArtifactOutputLayout; @@ -435,6 +442,7 @@ export interface TargetArtifactLayout { */ export const standardArtifactLayout: TargetArtifactLayout = Object.freeze({ assets: 'assets', + cliBin: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'bin' }), hookWrappers: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'hooks' }), mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index b04dc82c7..161548cdf 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -3,10 +3,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts'; +import { capabilityIsSupported, cliBinCapability, unavailableCapability } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; +import { routedCliBins, targetHostsCliBin } from './build/cli-bins.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; import { packInventoryDiagnostics, @@ -274,12 +275,13 @@ export interface ValidateResult { export type InspectionSkipReason = 'excluded-by-targets' | 'unsupported-capability'; -export type InspectionComponentKind = 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill'; +export type InspectionComponentKind = 'cli' | 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill'; /** * The target's own four-state judgment of the capability a component needs, * named so a reader can find the pinned row. Scripts need no host capability - * and carry none. + * and carry none; the routed CLI bin (`cli`) needs the target's `cli` row + * (#387). */ export type InspectionComponentCapability = CapabilityState & { readonly name: string }; @@ -595,6 +597,15 @@ interface InspectableComponent { } const inspectableComponents = (model: NormalizedPlugin): readonly InspectableComponent[] => [ + // The routed CLI bin is offered to every selected target; the host's `cli` + // capability row decides whether the artifact hosts it (#387). + ...routedCliBins(model).map((bin) => ({ + capability: cliBinCapability, + id: bin.id, + kind: 'cli' as const, + name: bin.name, + targets: model.targets.map((target) => target.name), + })), ...(model.commands ?? []).map((command) => ({ capability: 'commands', id: command.id, kind: 'command' as const, name: command.name, targets: command.targets })), ...model.hooks.map((hook) => ({ capability: 'hooks', id: hook.id, kind: 'hook' as const, name: hook.event, targets: hook.targets })), ...(model.mcpApps ?? []).map((app) => ({ capability: 'mcp', id: app.id, kind: 'mcp-app' as const, name: app.name, targets: app.targets })), @@ -756,7 +767,11 @@ export const inspect = async (options: InspectOptions): Promise = try { bundler = await composeBundlerInspection({ model, - targets: plans.map((plan) => ({ hookEntries: plan.hookEntries, name: plan.target })), + targets: plans.map((plan) => ({ + cliBin: targetHostsCliBin(prepared.registry, plan.target), + hookEntries: plan.hookEntries, + name: plan.target, + })), ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); } catch { diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index dd970006f..086bb9718 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -21,6 +21,13 @@ import { type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; +import { + cliBinCollisionDiagnostics, + compileCliBins, + planCompiledCliBins, + targetHostsCliBin, + type CompiledCliBin, +} from './cli-bins.ts'; import { projectMeta } from './meta.ts'; import { compileMcpApps, planCompiledMcpApps, type CompiledMcpApp } from './mcp-apps.ts'; import { @@ -45,6 +52,8 @@ import { deepFreeze } from '../core/freeze.ts'; export interface BuildResult { + /** The routed-CLI executables emitted into host artifacts (#387), one per hosting target. */ + readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; @@ -65,12 +74,15 @@ export interface BuildOptions { } interface PlannedTarget { + /** True when the target's adapter publishes the `cli` capability, admitting the routed CLI bin. */ + readonly cliBin: boolean; readonly entries: readonly TargetArtifactEntry[]; readonly hookEntries: readonly TargetHookEntry[]; readonly name: string; } interface StagedTarget extends PlannedTarget { + readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; @@ -157,7 +169,10 @@ const planTargets = (options: BuildOptions): readonly PlannedTarget[] => { }); } } + const cliBin = targetHostsCliBin(options.registry, target.name); + if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(options.model, target.name, plan.entries)); planned.push({ + cliBin, entries: plan.entries, hookEntries, name: target.name, @@ -185,7 +200,10 @@ const planStagedTargets = (options: { outDir: root, target: target.name, }); - return { ...target, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; + const compiledCliBins = target.cliBin + ? planCompiledCliBins(options.model, { outDir: root, target: target.name }) + : Object.freeze([]); + return { ...target, compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; }); const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[] => @@ -193,6 +211,7 @@ const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[ ...target.entries.map((entry) => resolveArtifactDestination(target.root, entry.relativePath), ), + ...target.compiledCliBins.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ...target.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ...target.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ...target.compiledMcpApps.map((entry) => entry.output), @@ -212,6 +231,7 @@ const hookIndexSourceInputs = ( const outputCandidatesFor = (options: { readonly artifactRoot: string; + readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; @@ -226,6 +246,15 @@ const outputCandidatesFor = (options: { path: resolveArtifactDestination(target.root, entry.relativePath), sourceInputs: entry.sourceInputs, }))), + ...options.compiledCliBins.flatMap((entry) => [{ + kind: 'bundle' as const, + path: entry.output, + sourceInputs: entry.sourceInputs, + }, ...(entry.workerOutput === undefined ? [] : [{ + kind: 'bundle' as const, + path: entry.workerOutput, + sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, + }])]), ...options.compiledEntries.flatMap((entry) => [{ kind: entry.outputKind, path: entry.output, @@ -346,6 +375,7 @@ export const build = async (options: BuildOptions): Promise => { }); assertUniqueArtifactDestinations(plannedDestinations(stagedTargets)); + const compiledCliBins: CompiledCliBin[] = []; const compiledEntries: CompiledEntry[] = []; const compiledHooks: CompiledHookEntry[] = []; const compiledMcpApps: CompiledMcpApp[] = []; @@ -355,6 +385,15 @@ export const build = async (options: BuildOptions): Promise => { // manifest, `inspect`, and dev status report (issue #237). const meta = projectMeta(options.model.metadata); for (const target of stagedTargets) { + if (target.cliBin) { + compiledCliBins.push(...(await compileCliBins(options.model, { + cwd: options.projectRoot, + meta, + outDir: target.root, + target: target.name, + ...tools, + }))); + } const targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { cwd: options.projectRoot, meta, @@ -427,6 +466,7 @@ export const build = async (options: BuildOptions): Promise => { artifactRoot: stageRoot, outputs: outputCandidatesFor({ artifactRoot: stageRoot, + compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, @@ -466,6 +506,11 @@ export const build = async (options: BuildOptions): Promise => { } await publishArtifact({ outputRoot, stageRoot }); return Object.freeze({ + compiledCliBins: Object.freeze(compiledCliBins.map((entry) => Object.freeze({ + ...entry, + output: publishedOutput(entry), + ...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }), + }))), compiledEntries: publishedCompiledEntries, compiledHooks: Object.freeze(compiledHooks.map((entry) => Object.freeze({ ...entry, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts new file mode 100644 index 000000000..97f1adde1 --- /dev/null +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -0,0 +1,222 @@ +import { resolve } from 'node:path'; + +import { cliBinCapability } from '../adapters/capability-state.ts'; +import type { TargetRegistry } from '../adapters/registry.ts'; +import type { TargetArtifactEntry } from '../adapters/types.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import type { AgentBundleToolsConfig, NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; +import type { AgentBundleMeta } from '../meta.ts'; +import { resolveArtifactDestination } from './emit.ts'; +import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts'; +import { + cliEntryRuntimePath, + cliEntryRuntimeSpecifier, + generatedCliBinEntrySource, + generatedRenderedRouteWorkerSource, +} from './entry-shell.ts'; +import { buildWithRslib, type RslibEntry } from './rslib.ts'; + +/** + * The artifact-hosted routed CLI (#387). A generated-mode `src/cli/**` + * surface already compiles into the npm package bin (`dist/bin/.js`); + * this module emits the same compiled command graph into every host artifact + * whose adapter publishes the `cli` capability, as `bin/.mjs` beside + * an optional `bin/-flight.mjs` react-server worker. The bin is a + * plain self-contained ESM module invoked as `node /bin/.mjs`, + * exactly like the artifact's `scripts/*.mjs`, so hooks, skills, and script + * routes installed with the plugin can reach the routed CLI without a + * separate npm install. The package build's own bin emission is untouched. + */ + +export const cliBinDirectory = 'bin'; + +/** The artifact-relative executable path for one routed-CLI bin. */ +export const cliBinArtifactPath = (name: string): string => `${cliBinDirectory}/${name}.mjs`; + +/** The artifact-relative react-server worker path beside a rendered routed-CLI bin. */ +export const cliBinWorkerArtifactPath = (name: string): string => `${cliBinDirectory}/${name}-flight.mjs`; + +/** The framework-generated routed-CLI bins of a project (never hand-written `bin` entries). */ +export const routedCliBins = (model: NormalizedPlugin): readonly NormalizedBinEntry[] => + Object.freeze((model.packageBuild?.bins ?? []).filter((bin) => bin.generatedCli !== undefined)); + +/** True when the target's adapter admits the routed CLI bin into its artifact. */ +export const targetHostsCliBin = (registry: TargetRegistry, target: string): boolean => + registry.supports(target, cliBinCapability); + +export interface CompiledCliBin extends CompiledEntry { + readonly id: string; + readonly target: string; +} + +interface PlannedCliBin extends CompiledCliBin { + readonly bin: NormalizedBinEntry; + readonly rendered: boolean; +} + +const generatedCli = (bin: NormalizedBinEntry): NonNullable => { + if (bin.generatedCli === undefined) { + throw new Error(`Bin ${JSON.stringify(bin.name)} is not a framework-generated routed CLI.`); + } + return bin.generatedCli; +}; + +export const planCompiledCliBins = ( + model: NormalizedPlugin, + options: { readonly outDir: string; readonly target: string }, +): readonly PlannedCliBin[] => { + const binRoot = resolve(options.outDir, cliBinDirectory); + return Object.freeze(routedCliBins(model).map((bin): PlannedCliBin => { + const cli = generatedCli(bin); + const rendered = cli.commands.some((command) => command.rendered); + const sourceInputs = Object.freeze([...new Set([ + bin.provenance.sourcePath, + ...cli.routes.map((route) => route.source), + ...(model.providers ?? []).map((provider) => provider.source), + ...(model.state === undefined ? [] : [model.state.source]), + ])]); + return Object.freeze({ + bin, + id: bin.id, + name: bin.name, + output: resolveArtifactDestination(binRoot, `${bin.name}.mjs`), + outputKind: 'bundle' as const, + rendered, + source: bin.source, + sourceInputs, + target: options.target, + ...(rendered + ? { + workerOutput: resolveArtifactDestination(binRoot, `${bin.name}-flight.mjs`), + workerSourceInputs: sourceInputs, + } + : {}), + }); + })); +}; + +/** + * The Rslib entries one target's routed-CLI bins compile into. Shared with + * `inspect --bundler` so the dump cannot drift from what the build lowers. + */ +export const cliBinRslibEntries = ( + planned: readonly PlannedCliBin[], + model: NormalizedPlugin, +): readonly RslibEntry[] => planned.flatMap((entry) => { + const cli = generatedCli(entry.bin); + const workerFile = `${entry.name}-flight.mjs`; + const entries: RslibEntry[] = [{ + aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, + name: `bin-${entry.name}`, + outputRelativePath: cliBinArtifactPath(entry.name), + ...(entry.rendered ? { rscManifest: true as const } : {}), + source: entry.source, + sourceInputs: entry.sourceInputs, + virtualSource: generatedCliBinEntrySource({ + commands: cli.commands, + plugin: { + ...(model.metadata.description === undefined ? {} : { description: model.metadata.description }), + name: model.metadata.name, + version: model.metadata.version, + }, + providers: model.providers ?? [], + routes: cli.routes, + ...(model.state === undefined ? {} : { state: model.state }), + // Durable state anchors on the artifact root (the parent of `bin/`), + // the same fallback the generated MCP worker beside it uses, so a + // co-installed CLI and server observe one store. + stateFallback: 'artifact', + ...(entry.rendered ? { workerFile } : {}), + }), + }]; + if (entry.rendered) { + const renderedRoutes = cli.routes.filter((route) => + cli.commands.some((command) => command.rendered && command.routeId === route.id)); + entries.push({ + name: `bin-${entry.name}-flight`, + outputRelativePath: cliBinWorkerArtifactPath(entry.name), + reactServer: true, + rscManifest: true, + source: entry.source, + sourceInputs: entry.sourceInputs, + virtualSource: generatedRenderedRouteWorkerSource({ + providers: model.providers ?? [], + routes: renderedRoutes, + ...(model.state === undefined ? {} : { state: model.state }), + stateFallback: 'artifact', + }), + }); + } + return entries; +}); + +export const compileCliBins = async ( + model: NormalizedPlugin, + options: { + readonly cwd: string; + readonly meta: AgentBundleMeta; + readonly outDir: string; + readonly target: string; + readonly tools?: AgentBundleToolsConfig; + }, +): Promise => { + const planned = planCompiledCliBins(model, options); + if (planned.length === 0) return Object.freeze([]); + const evidence = await buildWithRslib({ + cwd: options.cwd, + entries: cliBinRslibEntries(planned, model), + ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath())], + logLevel: 'error', + meta: options.meta, + outputRoot: options.outDir, + ...(options.tools === undefined ? {} : { tools: options.tools }), + }); + const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + const bundledInputs = (path: string, label: string): readonly string[] => { + const inputs = evidenceByPath.get(path); + if (inputs === undefined) throw new Error(`Missing bundled routed CLI ${label} evidence for ${JSON.stringify(path)}.`); + return inputs; + }; + return Object.freeze(planned.map((entry): CompiledCliBin => Object.freeze({ + id: entry.id, + name: entry.name, + output: entry.output, + outputKind: entry.outputKind, + source: entry.source, + sourceInputs: bundledInputs(cliBinArtifactPath(entry.name), 'executable'), + target: entry.target, + ...(entry.workerOutput === undefined + ? {} + : { + workerOutput: entry.workerOutput, + workerSourceInputs: bundledInputs(cliBinWorkerArtifactPath(entry.name), 'worker'), + }), + }))); +}; + +/** + * AB4766: the routed CLI bin's artifact paths are framework-owned. A target + * plan that already places a file there (for example a Claude `claude.bin` + * directory shipping `.mjs`) cannot be merged silently, so the + * build refuses with the colliding paths named. + */ +export const cliBinCollisionDiagnostics = ( + model: NormalizedPlugin, + target: string, + entries: readonly TargetArtifactEntry[], +): readonly Diagnostic[] => { + const planned = new Set(entries.map((entry) => entry.relativePath)); + return Object.freeze(routedCliBins(model).flatMap((bin) => { + const rendered = generatedCli(bin).commands.some((command) => command.rendered); + return [cliBinArtifactPath(bin.name), ...(rendered ? [cliBinWorkerArtifactPath(bin.name)] : [])] + .filter((path) => planned.has(path)) + .map((path): Diagnostic => ({ + code: 'AB4766', + message: `Target ${JSON.stringify(target)} already emits ${JSON.stringify(path)}, which the routed CLI bin ${JSON.stringify(bin.name)} owns; the compiler never chooses silently.`, + recovery: `Rename or remove the host-emitted ${JSON.stringify(path)} (for example the file in the configured claude.bin directory), or set bin: false to keep the host file and drop the routed CLI executable.`, + severity: 'error', + sourcePath: bin.provenance.sourcePath, + target, + })); + })); +}; diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 428c8d5b2..07b3d1f69 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -128,6 +128,15 @@ export const generatedInstallBinEntrySource = (options: { '', ].join('\n'); +/** + * Where workspace-durable state anchors when the host supplies no + * `AGENT_BUNDLE_PLUGIN_ROOT`: `cwd` (the caller's `.agent-bundle/state`, the + * npm package bin's contract) or `artifact` (the parent of the executable's + * own directory — the target root — which the artifact-hosted routed CLI + * shares with the generated MCP worker beside it). + */ +export type GeneratedStateFallback = 'artifact' | 'cwd'; + export interface GeneratedCliBinEntryOptions { readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; @@ -135,12 +144,12 @@ export interface GeneratedCliBinEntryOptions { readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly state?: NormalizedStateDefinition; + /** Durable-state anchor fallback; defaults to `cwd` (the npm package bin). */ + readonly stateFallback?: GeneratedStateFallback; /** The sibling react-server worker bundle; required when any command is rendered. */ readonly workerFile?: string; } -type GeneratedStateFallback = 'artifact' | 'cwd'; - const generatedStateImports = ( state: NormalizedStateDefinition | undefined, fallback: GeneratedStateFallback, @@ -257,17 +266,18 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) } const providers = orderedProviders(options.providers ?? []); const plainIndent = options.state === undefined ? ' ' : ' '; + const stateFallback = options.stateFallback ?? 'cwd'; return [ `import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered ? "import { available, createAgentRenderDispatcher, runAgentRequest, unavailable } from '@agent-bundle/runtime';" : "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), - ...generatedStateImports(options.state, 'cwd'), + ...generatedStateImports(options.state, stateFallback), ...routeImports(commandRoutes), ...providerImports(providers), '', - ...generatedStateOwner(options.state, 'cwd'), + ...generatedStateOwner(options.state, stateFallback), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...providerRegistrySource(providers), 'const routes = Object.freeze({', @@ -372,6 +382,8 @@ export interface GeneratedRenderedRouteWorkerOptions { readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly state?: NormalizedStateDefinition; + /** Durable-state anchor fallback; defaults to `cwd` and must match the owning executable. */ + readonly stateFallback?: GeneratedStateFallback; } /** @@ -384,16 +396,17 @@ export const generatedRenderedRouteWorkerSource = ( options: GeneratedRenderedRouteWorkerOptions, ): string => { const providers = orderedProviders(options.providers ?? []); + const stateFallback = options.stateFallback ?? 'cwd'; return [ "import { parentPort } from 'node:worker_threads';", "import { createElement } from 'react';", "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", - ...generatedStateImports(options.state, 'cwd'), + ...generatedStateImports(options.state, stateFallback), ...routeImports(options.routes), ...providerImports(providers), '', - ...generatedStateOwner(options.state, 'cwd'), + ...generatedStateOwner(options.state, stateFallback), '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 33c151ea5..137b070f4 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -13,6 +13,7 @@ import { mcpServerRuntimePath, mcpServerRuntimeSpecifier, } from './entry-shell.ts'; +import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import { planCompiledMcpEntries } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; @@ -157,6 +158,28 @@ const scriptEntries = async ( })); }; +/** The artifact-hosted routed CLI bins of one target (#387), composed by the build's own planner. */ +const cliBinEntries = ( + model: NormalizedPlugin, + target: string, + tools: AgentBundleToolsConfig | undefined, +): readonly BundlerInspectionEntry[] => { + const meta = projectMeta(model.metadata); + const outputRoot = artifactOutputToken(target); + const planned = planCompiledCliBins(model, { outDir: outputRoot, target }); + return cliBinRslibEntries(planned, model).map((entry) => rslibInspectionEntry({ + entry, + kind: 'bin', + meta, + name: entry.name.replace(/^bin-/u, ''), + outputPath: `${target}/${entry.outputRelativePath}`, + outputRoot, + source: entry.source, + target, + ...(tools === undefined ? {} : { tools }), + })); +}; + const mcpEntryEntries = async ( model: NormalizedPlugin, target: string, @@ -335,13 +358,19 @@ const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry) export const composeBundlerInspection = async (options: { readonly model: NormalizedPlugin; - readonly targets: readonly { readonly hookEntries: readonly TargetHookEntry[]; readonly name: string }[]; + readonly targets: readonly { + /** True when the target hosts the routed CLI bin (its adapter publishes the `cli` capability). */ + readonly cliBin?: boolean; + readonly hookEntries: readonly TargetHookEntry[]; + readonly name: string; + }[]; readonly tools?: AgentBundleToolsConfig; }): Promise => { const entries: BundlerInspectionEntry[] = []; const meta = projectMeta(options.model.metadata); for (const target of options.targets) { entries.push( + ...(target.cliBin === true ? cliBinEntries(options.model, target.name, options.tools) : []), ...(await scriptEntries(options.model, target.name, options.tools)), ...(await mcpEntryEntries(options.model, target.name, options.tools)), ...hookEntries(target.hookEntries, meta, target.name, options.tools), diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 18871a50f..8dead9ac4 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -505,6 +505,7 @@ const isTargetArtifactPath = ( const mcpRuntime = registry.mcpRuntime(target); return isRecursiveArtifactPath(relativePath, layout.assets) || isRecursiveArtifactPath(relativePath, layout.bin) || + isDirectOutputLayoutPath(relativePath, layout.cliBin) || isDirectOutputLayoutPath(relativePath, layout.commands) || isDirectOutputLayoutPath(relativePath, layout.hookWrappers) || isDirectOutputLayoutPath(relativePath, layout.mcpApps) || diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index f702a6981..8c21c9dde 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; +import { cliBinCapability } from '../adapters/capability-state.ts'; import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; import { toPosixRelative } from '../core/paths.ts'; import { isPlainRecord, isRecord } from '../core/strict-json.ts'; @@ -2130,6 +2131,57 @@ export const validateSource = ( return diagnostics; }; +/** + * AB4765 (#387): the compiled routed CLI is offered to every selected target, + * but a host artifact receives `bin/.mjs` only when its adapter + * publishes a supported `cli` capability. Every built-in target does; a + * custom adapter that publishes no row (or a non-supported one) omits the + * bin, and that omission is reported here — never silently — so skills and + * scripts installed with that target are not written against a file that is + * not there. `inspect` lists the same omission as an `unsupported-capability` + * skip. + */ +const routedCliBinTargetDiagnostics = ( + model: NormalizedPlugin, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + for (const bin of model.packageBuild?.bins ?? []) { + if (bin.generatedCli === undefined) continue; + for (const target of model.targets) { + if (!registry.has(target.name) || registry.supports(target.name, cliBinCapability)) continue; + const capability = registry.capabilityState?.(target.name, cliBinCapability); + let judgment: string; + if (capability === undefined) { + judgment = `the target publishes no ${cliBinCapability} capability row`; + } else { + switch (capability.state) { + case 'supported': + continue; + case 'degraded': + case 'unavailable': + case 'prohibited': + judgment = `its ${cliBinCapability} capability is ${capability.state}: ${capability.reason}`; + break; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } + } + diagnostics.push({ + code: 'AB4765', + message: `Routed CLI ${JSON.stringify(bin.name)} is not emitted into target ${JSON.stringify(target.name)}: ${judgment}. Skills, hooks, and scripts in that artifact cannot invoke bin/${bin.name}.mjs.`, + recovery: `Publish a supported ${cliBinCapability} capability (and a cliBin artifact layout) on the ${target.name} adapter, or drop the target from the surfaces that reference the routed CLI.`, + severity: 'warning', + sourcePath: bin.provenance.sourcePath, + target: target.name, + }); + } + } + return diagnostics; +}; + export const validateModel = ( model: NormalizedPlugin, registry: NormalizationTargetRegistry, @@ -2148,6 +2200,8 @@ export const validateModel = ( } } + diagnostics.push(...routedCliBinTargetDiagnostics(model, registry)); + const ids = new Map(); const components = [ model.metadata, diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index c6617b9ca..bef94dded 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -946,6 +946,29 @@ it('rejects a malformed capability declaration when the adapter registers', () = expect(() => new TargetRegistry().register(source)).not.toThrow(); }); +it('publishes the routed CLI bin capability with its bin layout on every built-in target (#387)', () => { + const registry = createDefaultRegistry(); + for (const name of registry.names()) { + const adapter = registry.get(name); + expect(adapter.capabilities.cli?.state, name).toBe('supported'); + expect(registry.artifactLayout(name).cliBin, name).toEqual({ allowedSuffixes: ['.mjs'], directory: 'bin' }); + } + + // A supported `cli` row promises a place for the executable, so an adapter + // without the layout cannot register; one that publishes no row stays valid + // and simply hosts no bin. + const cursor = registry.get('cursor'); + const { cliBin: _cliBin, ...layoutWithoutBin } = cursor.artifactLayout!; + expect(() => new TargetRegistry().register({ ...cursor, artifactLayout: layoutWithoutBin })) + .toThrow(/supported cli capability without a routed CLI bin layout/u); + const { cli: _cli, ...capabilitiesWithoutCli } = cursor.capabilities; + expect(() => new TargetRegistry().register({ + ...cursor, + artifactLayout: layoutWithoutBin, + capabilities: capabilitiesWithoutCli, + })).not.toThrow(); +}); + it('rejects a malformed inspection component capability when the adapter registers', () => { const source = createDefaultRegistry().get('cursor'); diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts new file mode 100644 index 000000000..be2f7e7bd --- /dev/null +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -0,0 +1,307 @@ +import { execFile as executeFile } from 'node:child_process'; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; +import type { TargetAdapter } from '../src/adapters/types.ts'; +import { build, inspect } from '../src/api.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; + +const execFile = promisify(executeFile); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +const pluginName = 'cli-bin-artifact'; +const hostTargets = ['claude', 'codex', 'cursor', 'portable', 'plugin'] as const; + +/** + * A host adapter that publishes no `cli` capability row: it stands in for a + * third-party target whose plugin root is not a directory Node executes + * from. The routed CLI bin must be omitted there — reported, never silent. + */ +const legacyHostAdapter: TargetAdapter = Object.freeze({ + artifactLayout: Object.freeze({ + rootDocuments: Object.freeze(['plugin.json']), + scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), + }), + capabilities: Object.freeze({}), + metadata: Object.freeze({ adapterRevision: 'test', observedVersion: 'test', schemas: Object.freeze([]) }), + name: 'legacy-host', + plan: (model: NormalizedPlugin) => Object.freeze({ + diagnostics: Object.freeze([]), + entries: Object.freeze([{ + content: `${JSON.stringify({ name: model.metadata.name })}\n`, + kind: 'write' as const, + relativePath: 'plugin.json', + sourceInputs: Object.freeze([model.metadata.provenance.sourcePath]), + }]), + }), +}); + +const registryWithLegacyHost = (): TargetRegistry => createDefaultRegistry().register(legacyHostAdapter); + +const createFixture = async (options: { + /** Ship a Claude skill referencing the bin through the plugin-root token (Claude-only Skill Markdown syntax). */ + readonly skill?: boolean; + readonly targets: readonly string[]; +}): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cli-bin-artifact-')); + roots.push(root); + // The audiobook example's installed tree supplies @agent-bundle/runtime and zod. + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + zod: '4.4.3', + }, + name: pluginName, + type: 'module', + version: '3.8.7', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)}, version: '3.8.7' },`, + ` targets: ${JSON.stringify(options.targets)},`, + '});', + '', + ].join('\n')), + writeProjectFile(root, 'src/cli/status.ts', [ + "import { agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { description: 'Report the daemon status.', positionals: ['ticket'] };", + 'export const inputSchema = z.object({ ticket: z.string().min(1).optional(), verbose: z.boolean().optional() }).strict();', + "export const resultSchema = z.object({ invocation: z.literal('cli'), status: z.literal('idle'), surface: z.string(), ticket: z.string().optional() }).strict();", + 'export default async function status({ input, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' const context = await agent();', + ' return {', + " invocation: context.invocation.kind,", + " status: 'idle',", + " surface: input.verbose === true ? `${context.invocation.surface} (verbose)` : context.invocation.surface,", + ' ...(input.ticket === undefined ? {} : { ticket: input.ticket }),', + ' };', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/cli/report.tsx', [ + "import React from 'react';", + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { description: 'Render a build report.', positionals: ['root'] };", + 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', + 'export const resultSchema = z.object({ builds: z.number(), root: z.string() }).strict();', + 'export default async function Report({ input, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' const context = await agent();', + " await context.progress.report({ completed: 1, message: 'scanning', total: 1 });", + ' const result = { builds: 3, root: input.root };', + ' return (', + ' ', + ' {`Found **3** builds under ${input.root}.`}', + ' ', + ' );', + '}', + '', + ].join('\n')), + // A plain script route forwarding to the routed CLI through the + // documented sibling convention: `../bin/.mjs` relative to + // the script's own `import.meta.url` inside the artifact. + writeProjectFile(root, 'src/scripts/hauler.ts', [ + "import { spawnSync } from 'node:child_process';", + "import { fileURLToPath } from 'node:url';", + '', + 'export const main = async (argv: readonly string[]): Promise => {', + ` const bin = fileURLToPath(new URL('../bin/${pluginName}.mjs', import.meta.url));`, + " const child = spawnSync(process.execPath, [bin, ...argv], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });", + ' process.stdout.write(child.stdout);', + ' return child.status ?? 1;', + '};', + '', + ].join('\n')), + // A skill that reaches the bin through the plugin-root token; only Claude + // documents Skill Markdown interpolation, so the fixture ships it for + // Claude-only builds. + ...(options.skill === true + ? [writeProjectFile(root, 'src/skills/daemon-status/SKILL.md', [ + '---', + 'name: daemon-status', + 'description: Check the daemon status through the routed CLI.', + '---', + '# Daemon status', + '', + `Run \`node \${CLAUDE_PLUGIN_ROOT}/bin/${pluginName}.mjs status --json\` and report the \`status\` field.`, + '', + ].join('\n'))] + : []), + ]); + return root; +}; + +const parseJsonLine = (stdout: string): unknown => JSON.parse(stdout) as unknown; + +/** + * The artifact-hosted routed CLI proof (#387): one build ships the compiled + * `src/cli/**` command graph into every host artifact whose adapter publishes + * the `cli` capability, as `bin/.mjs` (+ Flight worker), the bin + * runs end to end under `node`, a script route reaches it as a sibling, a + * skill reaches it through the plugin-root token, validation accepts the new + * `bin/` layout, and a target without the capability omits it with an inspect + * entry and an AB4765 warning. + */ +it('emits the routed CLI bin into every capable host artifact and omits it elsewhere', { retry: 1, timeout: 300_000 }, async () => { + const root = await createFixture({ targets: [...hostTargets, 'legacy-host'] }); + const registry = registryWithLegacyHost(); + + const result = await build({ output: 'artifact', registry, root }); + const artifactRoot = join(root, 'artifact'); + + // Every capable target hosts the executable and its rendered-command worker. + expect(result.build.compiledCliBins.map((bin) => bin.target).sort()).toEqual([...hostTargets].sort()); + for (const target of hostTargets) { + const binPath = join(artifactRoot, target, 'bin', `${pluginName}.mjs`); + await expect(stat(binPath)).resolves.toMatchObject({}); + await expect(stat(join(artifactRoot, target, 'bin', `${pluginName}-flight.mjs`))).resolves.toMatchObject({}); + const binSource = await readFile(binPath, 'utf8'); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/meta['"]/u); + + // `node /bin/.mjs ` prints the routed CLI output. + const status = await execFile(process.execPath, [binPath, 'status', 'ticket-7', '--json']); + expect(parseJsonLine(status.stdout)).toEqual({ + invocation: 'cli', + status: 'idle', + surface: 'status', + ticket: 'ticket-7', + }); + } + // The target without the capability receives no `bin/` at all, while its + // other compiled surfaces are untouched. + await expect(stat(join(artifactRoot, 'legacy-host', 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(join(artifactRoot, 'legacy-host', 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB4765', + severity: 'warning', + target: 'legacy-host', + })); + expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + + // Help, version, and the rendered .tsx command ride the same executable. + const claudeBin = join(artifactRoot, 'claude', 'bin', `${pluginName}.mjs`); + const help = await execFile(process.execPath, [claudeBin, '--help']); + expect(help.stdout).toContain(`${pluginName} 3.8.7`); + expect(help.stdout).toContain('Artifact routed CLI fixture.'); + expect(help.stdout).toContain('status'); + expect(help.stdout).toContain('report'); + await expect(execFile(process.execPath, [claudeBin, '--version'])).resolves.toMatchObject({ stdout: `${pluginName} 3.8.7\n` }); + const piped = await execFile(process.execPath, [claudeBin, 'report', '/builds']); + expect(piped.stdout).toBe('Found **3** builds under /builds.\n'); + const reportJson = await execFile(process.execPath, [claudeBin, 'report', '/builds', '--json']); + expect(parseJsonLine(reportJson.stdout)).toEqual({ builds: 3, root: '/builds' }); + await expect(execFile(process.execPath, [claudeBin, 'unknown'])).rejects.toMatchObject({ code: 2, stdout: '' }); + + // A script route reaches the bin as its documented sibling and forwards argv. + const forwarded = await execFile(process.execPath, [join(artifactRoot, 'codex', 'scripts', 'hauler.mjs'), 'status', '--verbose', '--json']); + expect(parseJsonLine(forwarded.stdout)).toEqual({ invocation: 'cli', status: 'idle', surface: 'status (verbose)' }); + + // The composite bundle's AGENTS.md documents the shared executable. + const agents = await readFile(join(artifactRoot, 'plugin', 'AGENTS.md'), 'utf8'); + expect(agents).toContain(`\`bin/${pluginName}.mjs\``); + + // The manifest inventories the bin with bundle provenance naming every command route. + const manifestFile = result.build.manifest.files.find((file) => file.path === `portable/bin/${pluginName}.mjs`); + expect(manifestFile).toMatchObject({ kind: 'bundle' }); + expect(manifestFile?.sourceInputs).toEqual(expect.arrayContaining(['src/cli/report.tsx', 'src/cli/status.ts'])); + expect(result.build.manifest.files.find((file) => file.path === `portable/bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); + expect(result.build.manifest.files.some((file) => file.path.startsWith('legacy-host/bin/'))).toBe(false); + + // Artifact validation accepts the framework-owned `bin/` layout on every target. + const validation = await validateArtifact({ artifactRoot, registry }); + expect(validation.filter((entry) => entry.severity === 'error')).toEqual([]); + + // `inspect` accounts for the bin as a `cli` component per target. + const inspected = await inspect({ registry, root }); + expect(inspected.state).toBe('ready'); + if (inspected.state !== 'ready') throw new Error('unreachable'); + const claudePlan = inspected.plans.find((plan) => plan.target === 'claude'); + expect(claudePlan?.selected).toContainEqual({ + capability: { evidence: expect.objectContaining({ target: 'claude' }), name: 'cli', state: 'supported' }, + id: `bin:${pluginName}`, + kind: 'cli', + name: pluginName, + }); + const legacyPlan = inspected.plans.find((plan) => plan.target === 'legacy-host'); + expect(legacyPlan?.skipped).toContainEqual({ + capability: { name: 'cli', reason: expect.stringContaining('publishes no cli capability row'), state: 'unavailable' }, + id: `bin:${pluginName}`, + kind: 'cli', + name: pluginName, + reason: 'unsupported-capability', + }); + + // `inspect --bundler` dumps the per-target bin composition beside the + // scripts; the npm package bin (no target) keeps its own entry. + const bundler = await inspect({ focus: 'bundler', registry, root }); + if (bundler.state !== 'ready') throw new Error('unreachable'); + const binEntries = (bundler.selected?.bundler?.entries ?? []) + .filter((entry) => entry.kind === 'bin' && entry.target !== undefined); + expect((bundler.selected?.bundler?.entries ?? []).some((entry) => + entry.kind === 'bin' && entry.target === undefined && entry.outputPath === `dist/bin/${pluginName}.js`)).toBe(true); + expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual(hostTargets + .flatMap((target) => [`${target}/bin/${pluginName}.mjs`, `${target}/bin/${pluginName}-flight.mjs`]) + .sort()); +}); + +it('lets a skill reach the artifact bin through the plugin-root token', { retry: 1, timeout: 240_000 }, async () => { + const root = await createFixture({ skill: true, targets: ['claude'] }); + const result = await build({ output: 'artifact', root }); + const claudeRoot = join(root, 'artifact', 'claude'); + + // The skill's `${CLAUDE_PLUGIN_ROOT}` reference lowers to a path the same + // artifact really ships, and that file is the working routed CLI. + const skill = await readFile(join(claudeRoot, 'skills', 'daemon-status', 'SKILL.md'), 'utf8'); + const reference = `\${CLAUDE_PLUGIN_ROOT}/bin/${pluginName}.mjs`; + expect(skill).toContain(reference); + const binPath = join(claudeRoot, reference.slice('${CLAUDE_PLUGIN_ROOT}/'.length)); + await expect(stat(binPath)).resolves.toMatchObject({}); + const status = await execFile(process.execPath, [binPath, 'status', '--json']); + expect(parseJsonLine(status.stdout)).toEqual({ invocation: 'cli', status: 'idle', surface: 'status' }); + expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toEqual([]); +}); + +it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)', { timeout: 120_000 }, async () => { + const root = await createFixture({ targets: ['claude'] }); + // A configured Claude bin directory shipping the same file name the routed + // CLI owns: the compiler never chooses between them silently. + await writeProjectFile(root, `host-bin/${pluginName}.mjs`, "console.log('host bin');\n"); + await chmod(join(root, 'host-bin', `${pluginName}.mjs`), 0o755); + await writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " claude: { bin: './host-bin' },", + ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)}, version: '3.8.7' },`, + " targets: ['claude'],", + '});', + '', + ].join('\n')); + + await expect(build({ output: 'artifact', root })).rejects.toThrow( + new RegExp(`\\[AB4766\\] Target "claude" already emits "bin/${pluginName}\\.mjs"`, 'u'), + ); + await expect(stat(join(root, 'artifact'))).rejects.toMatchObject({ code: 'ENOENT' }); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index a85932a43..8f3ddb77e 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -14,6 +14,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/agent-api.test.ts', 'packages/agent-bundle/tests/api.test.ts', + 'packages/agent-bundle/tests/artifact-cli-bin.test.ts', 'packages/agent-bundle/tests/artifact-validator.test.ts', 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', 'packages/agent-bundle/tests/build.test.ts', From fc86157f3888a006d97b5b2f6411bb650a1cd317 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:10:42 +0000 Subject: [PATCH 2/5] fix(adapters): pin the cliBin layout to bin/.mjs at registration; changeset PR reference Codex review on #419: check the supported-`cli`-without-layout invariant before the empty-layout early return, reject a `cliBin` layout that names any directory but `bin` or omits `.mjs` (the compiler emits the routed CLI only there), and end the changeset summary with the PR reference. --- .changeset/387-artifact-routed-cli.md | 26 +++++------ docs/entry-conventions.md | 6 ++- .../agent-bundle/src/adapters/portable.ts | 3 +- .../agent-bundle/src/adapters/registry.ts | 45 +++++++++++++------ packages/agent-bundle/src/adapters/types.ts | 15 ++++++- packages/agent-bundle/src/build/cli-bins.ts | 5 ++- .../tests/adapter-capability-states.test.ts | 24 +++++++++- 7 files changed, 88 insertions(+), 36 deletions(-) diff --git a/.changeset/387-artifact-routed-cli.md b/.changeset/387-artifact-routed-cli.md index deedd5d62..8735c7ea1 100644 --- a/.changeset/387-artifact-routed-cli.md +++ b/.changeset/387-artifact-routed-cli.md @@ -2,17 +2,15 @@ "agent-bundle": minor --- -Emit the routed CLI (`src/cli/**`) into host artifacts, not only the npm -package build. Every target whose adapter publishes the new `cli` capability -(all built-in targets: `claude`, `codex`, `cursor`, `portable`, `plugin`) -now receives `bin/.mjs` — the same compiled command graph as -`dist/bin/.js`, run as `node /bin/.mjs` -— plus `bin/-flight.mjs` when any command renders, so installed -skills, hooks, and script routes can invoke the CLI without a separate npm -install. Script routes reach it as the `../bin/.mjs` sibling of -their own `import.meta.url`; skills and hooks reach it through the plugin-root -token. The artifact manifest, validation (`cliBin` layout), `inspect` -component accounting (`cli` kind), and `inspect --bundler` all know the new -`bin/` directory. A target without the capability omits the bin and reports -`AB4765`; a host-emitted file colliding with the bin path is `AB4766`. The -package build's own bin emission is unchanged. +Emit the routed CLI (`src/cli/**`) into every host artifact as +`/bin/.mjs` (plus `bin/-flight.mjs` when a +command renders), not only into the npm package build, so installed skills, +hooks, and script routes can run it with `node /bin/.mjs`. +Every built-in target publishes the new `cli` adapter capability that admits +the bin; `inspect` accounts for it as a `cli` component, `inspect --bundler` +and the artifact manifest list it, and artifact validation admits the `cliBin` +layout. A target without the capability omits the bin with `AB4765`; a +host-emitted file colliding with the bin path fails the build with `AB4766`. +Script routes reach the bin as their `../bin/.mjs` sibling; +skills and hooks reach it through the plugin-root token. The package build's +`dist/bin/.js` is unchanged. (#419) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 3e1f5cec4..9cbe31eb7 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -433,8 +433,10 @@ or skipped with the host's `cli` capability judgment), `inspect --bundler` dumps each target's `bin/` composition beside its scripts, and the artifact manifest records both files with `bundle` provenance naming every command route. Artifact validation admits the `bin/` layout only for adapters that -declare it (`cliBin`); an adapter that publishes a supported `cli` capability -without that layout is rejected at registration. A target without the +declare it (`cliBin`); because the compiler emits the CLI at exactly +`bin/.mjs`, an adapter that publishes a supported `cli` +capability without that layout, or with a `cliBin` layout naming another +directory or omitting `.mjs`, is rejected at registration. A target without the capability omits the bin and reports `AB4765`; a host-emitted file at the same path (a Claude `claude.bin` directory shipping `.mjs`) is `AB4766`. The package build's `dist/bin/.js` is unchanged. diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index c58826c4e..50a567979 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -37,6 +37,7 @@ import pluginSchema from './schemas/portable/plugin.schema.json' with { type: 'j import { createAdapterValidator, payloadCopyEntries, + routedCliBinLayout, schemaDescriptorsFrom, sourceInputs, validateJsonSchemaDocument, @@ -610,7 +611,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout: Object.freeze({ assets: 'assets', - cliBin: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'bin' }), + cliBin: routedCliBinLayout, mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), rootDocuments: Object.freeze(['INSTALL.md', 'install.mjs']), diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index f847155cd..90dd9e6ca 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -16,16 +16,17 @@ import { cursorAdapter } from './cursor.ts'; import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts'; import { portableAdapter } from './portable.ts'; import { pluginAdapter } from './plugin.ts'; -import type { - TargetAdapter, - TargetArtifactDocumentContract, - TargetArtifactDocumentValidator, - TargetArtifactLayout, - TargetArtifactOutputLayout, - TargetArtifactSchemaContract, - TargetArtifactValidationContract, - TargetAdapterMetadata, - TargetSchemaDescriptor, +import { + routedCliBinLayout, + type TargetAdapter, + type TargetArtifactDocumentContract, + type TargetArtifactDocumentValidator, + type TargetArtifactLayout, + type TargetArtifactOutputLayout, + type TargetArtifactSchemaContract, + type TargetArtifactValidationContract, + type TargetAdapterMetadata, + type TargetSchemaDescriptor, } from './types.ts'; import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -181,12 +182,31 @@ const snapshotArtifactLayout = ( hookContract: TargetHookContract | undefined, mcpRuntime: TargetMcpRuntimeContract | undefined, ): TargetArtifactLayout => { + // A supported `cli` capability promises a home for the compiled routed CLI, + // and the compiler emits it at exactly one place (`bin/.mjs`), so the + // promise is checked before any early return and against that fixed layout. + const cliSupported = capabilityIsSupported(adapter.capabilities[cliBinCapability]); + const missingCliBinLayout = (): Error => + new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`); const declaredLayout = adapter.artifactLayout; - if (declaredLayout === undefined) return emptyArtifactLayout; + if (declaredLayout === undefined) { + if (cliSupported) throw missingCliBinLayout(); + return emptyArtifactLayout; + } const layout = record(declaredLayout); if (layout === undefined) throw new Error('Target adapter artifact layout must be a record.'); const cliBin = layout.cliBin === undefined ? undefined : snapshotOutputLayout(layout.cliBin, 'routed CLI bin'); + if (cliBin === undefined && cliSupported) throw missingCliBinLayout(); + if ( + cliBin !== undefined && + (cliBin.directory !== routedCliBinLayout.directory || + !routedCliBinLayout.allowedSuffixes.every((suffix) => cliBin.allowedSuffixes.includes(suffix))) + ) { + throw new Error( + `Target adapter "${adapter.name}" routed CLI bin layout must use directory ${JSON.stringify(routedCliBinLayout.directory)} and admit ${routedCliBinLayout.allowedSuffixes.map((suffix) => JSON.stringify(suffix)).join(', ')}; the compiler emits the routed CLI only there.`, + ); + } const commands = layout.commands === undefined ? undefined : snapshotOutputLayout(layout.commands, 'commands'); const hookWrappers = layout.hookWrappers === undefined ? undefined @@ -235,9 +255,6 @@ const snapshotArtifactLayout = ( if (skills !== undefined && !capabilityIsSupported(adapter.capabilities.skills)) { throw new Error(`Target adapter "${adapter.name}" declares Skill layout without skills capability.`); } - if (cliBin === undefined && capabilityIsSupported(adapter.capabilities[cliBinCapability])) { - throw new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`); - } return Object.freeze({ ...(assets === undefined ? {} : { assets }), ...(bin === undefined ? {} : { bin }), diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 62e432d5f..9c295f70b 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -435,6 +435,19 @@ export interface TargetArtifactLayout { readonly workflows?: string; } +/** + * The one layout the compiler emits the routed CLI into (#387): + * `bin/.mjs` plus `bin/-flight.mjs`. Every adapter + * that publishes a supported `cli` capability must declare a `cliBin` layout + * naming this directory and admitting this suffix; the registry rejects any + * other spelling because the compiler would otherwise emit files its own + * artifact validation rejects. + */ +export const routedCliBinLayout: TargetArtifactOutputLayout = Object.freeze({ + allowedSuffixes: Object.freeze(['.mjs']), + directory: 'bin', +}); + /** * Direct-file artifact layout shared by every plugin-shaped target adapter * (Claude, Codex): hook wrappers, MCP apps/entries, scripts, and skills all @@ -442,7 +455,7 @@ export interface TargetArtifactLayout { */ export const standardArtifactLayout: TargetArtifactLayout = Object.freeze({ assets: 'assets', - cliBin: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'bin' }), + cliBin: routedCliBinLayout, hookWrappers: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'hooks' }), mcpApps: Object.freeze({ allowedSuffixes: Object.freeze(['.html']), directory: 'mcp-apps' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 97f1adde1..240522159 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import { cliBinCapability } from '../adapters/capability-state.ts'; import type { TargetRegistry } from '../adapters/registry.ts'; -import type { TargetArtifactEntry } from '../adapters/types.ts'; +import { routedCliBinLayout, type TargetArtifactEntry } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import type { AgentBundleToolsConfig, NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; import type { AgentBundleMeta } from '../meta.ts'; @@ -28,7 +28,8 @@ import { buildWithRslib, type RslibEntry } from './rslib.ts'; * separate npm install. The package build's own bin emission is untouched. */ -export const cliBinDirectory = 'bin'; +/** The one directory the compiler emits the routed CLI into; the registry pins every `cliBin` layout to it. */ +export const cliBinDirectory: string = routedCliBinLayout.directory; /** The artifact-relative executable path for one routed-CLI bin. */ export const cliBinArtifactPath = (name: string): string => `${cliBinDirectory}/${name}.mjs`; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index bef94dded..bb716fece 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -955,18 +955,38 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i } // A supported `cli` row promises a place for the executable, so an adapter - // without the layout cannot register; one that publishes no row stays valid - // and simply hosts no bin. + // without the layout — or with no artifact layout at all — cannot register; + // one that publishes no row stays valid and simply hosts no bin. const cursor = registry.get('cursor'); const { cliBin: _cliBin, ...layoutWithoutBin } = cursor.artifactLayout!; expect(() => new TargetRegistry().register({ ...cursor, artifactLayout: layoutWithoutBin })) .toThrow(/supported cli capability without a routed CLI bin layout/u); + const { artifactLayout: _layout, ...cursorWithoutLayout } = cursor; + expect(() => new TargetRegistry().register(cursorWithoutLayout)) + .toThrow(/supported cli capability without a routed CLI bin layout/u); const { cli: _cli, ...capabilitiesWithoutCli } = cursor.capabilities; expect(() => new TargetRegistry().register({ ...cursor, artifactLayout: layoutWithoutBin, capabilities: capabilitiesWithoutCli, })).not.toThrow(); + + // The compiler emits the routed CLI at exactly `bin/.mjs`, so a + // `cliBin` layout naming any other directory or omitting `.mjs` is rejected + // instead of producing files artifact validation would reject. + for (const cliBin of [ + { allowedSuffixes: ['.mjs'], directory: 'cli' }, + { allowedSuffixes: ['.js'], directory: 'bin' }, + ]) { + expect(() => new TargetRegistry().register({ + ...cursor, + artifactLayout: { ...layoutWithoutBin, cliBin }, + })).toThrow(/routed CLI bin layout must use directory "bin" and admit "\.mjs"/u); + } + expect(() => new TargetRegistry().register({ + ...cursor, + artifactLayout: { ...layoutWithoutBin, cliBin: { allowedSuffixes: ['.js', '.mjs'], directory: 'bin' } }, + })).not.toThrow(); }); it('rejects a malformed inspection component capability when the adapter registers', () => { From de5afa4020c9495d3ad63c7846c1e238f1844b97 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:19:45 +0000 Subject: [PATCH 3/5] fix(build): gate the artifact routed CLI on the component capability judgment; changeset patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on #419: emission, the AB4765 warning, and the registration invariant now use `componentCapabilities ?? capabilities` — the same judgment `inspect` reports — via the new `TargetRegistry.componentCapabilityState` / `hostsComponent` accessors, so a component override can never disagree with what the artifact ships. The changeset is `patch` per the pre-1.0 policy (minor is reserved for breaking changes). --- .changeset/387-artifact-routed-cli.md | 2 +- .../agent-bundle/src/adapters/registry.ts | 19 ++++++++++++++- packages/agent-bundle/src/build/cli-bins.ts | 8 +++++-- packages/agent-bundle/src/config/validate.ts | 24 ++++++++++++++++--- packages/agent-bundle/src/core/types.ts | 7 ++++++ .../tests/adapter-capability-states.test.ts | 21 ++++++++++++++++ 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/.changeset/387-artifact-routed-cli.md b/.changeset/387-artifact-routed-cli.md index 8735c7ea1..d41adcd64 100644 --- a/.changeset/387-artifact-routed-cli.md +++ b/.changeset/387-artifact-routed-cli.md @@ -1,5 +1,5 @@ --- -"agent-bundle": minor +"agent-bundle": patch --- Emit the routed CLI (`src/cli/**`) into every host artifact as diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index 90dd9e6ca..8bdc2f827 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -185,7 +185,14 @@ const snapshotArtifactLayout = ( // A supported `cli` capability promises a home for the compiled routed CLI, // and the compiler emits it at exactly one place (`bin/.mjs`), so the // promise is checked before any early return and against that fixed layout. - const cliSupported = capabilityIsSupported(adapter.capabilities[cliBinCapability]); + // The judgment is the component one (`componentCapabilities ?? capabilities`) + // because that is what decides emission; malformed declarations are + // reported by the capability validators, not here. + const componentCapabilities = adapter.componentCapabilities === undefined + ? undefined + : record(adapter.componentCapabilities); + const cliJudgment = (componentCapabilities ?? adapter.capabilities)[cliBinCapability]; + const cliSupported = isCapabilityState(cliJudgment) && capabilityIsSupported(cliJudgment); const missingCliBinLayout = (): Error => new Error(`Target adapter "${adapter.name}" declares a supported ${cliBinCapability} capability without a routed CLI bin layout.`); const declaredLayout = adapter.artifactLayout; @@ -673,10 +680,20 @@ export class TargetRegistry implements NormalizationTargetRegistry { return this.#adapters.get(name)?.capabilities[capability]; } + componentCapabilityState(name: string, capability: string): CapabilityState | undefined { + const adapter = this.#adapters.get(name); + return adapter === undefined ? undefined : (adapter.componentCapabilities ?? adapter.capabilities)[capability]; + } + supports(name: string, capability: string): boolean { return capabilityIsSupported(this.capabilityState(name, capability)); } + /** True when the target emits components needing `capability`, by the same judgment `inspect` reports. */ + hostsComponent(name: string, capability: string): boolean { + return capabilityIsSupported(this.componentCapabilityState(name, capability)); + } + names(): readonly string[] { return Object.freeze([...this.#adapters.keys()]); } diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 240522159..b658d6161 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -41,9 +41,13 @@ export const cliBinWorkerArtifactPath = (name: string): string => `${cliBinDirec export const routedCliBins = (model: NormalizedPlugin): readonly NormalizedBinEntry[] => Object.freeze((model.packageBuild?.bins ?? []).filter((bin) => bin.generatedCli !== undefined)); -/** True when the target's adapter admits the routed CLI bin into its artifact. */ +/** + * True when the target's adapter admits the routed CLI bin into its artifact — + * by the component judgment (`componentCapabilities ?? capabilities`), so + * emission and `inspect` accounting can never disagree. + */ export const targetHostsCliBin = (registry: TargetRegistry, target: string): boolean => - registry.supports(target, cliBinCapability); + registry.hostsComponent(target, cliBinCapability); export interface CompiledCliBin extends CompiledEntry { readonly id: string; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 8c21c9dde..baa8dcea3 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,8 +1,9 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; -import { cliBinCapability } from '../adapters/capability-state.ts'; +import { capabilityIsSupported, cliBinCapability } from '../adapters/capability-state.ts'; import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; +import type { CapabilityState } from '../core/capabilities.ts'; import { toPosixRelative } from '../core/paths.ts'; import { isPlainRecord, isRecord } from '../core/strict-json.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -2146,11 +2147,28 @@ const routedCliBinTargetDiagnostics = ( registry: NormalizationTargetRegistry, ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; + // The judgment must be the one emission and `inspect` use: the adapter's + // component override when published, otherwise its plain capabilities. A + // registry exposing neither accessor falls back to its boolean view. + const judgmentFor = (target: string): { readonly known: true; readonly state: CapabilityState | undefined } | { readonly known: false } => { + if (registry.componentCapabilityState !== undefined) { + return { known: true, state: registry.componentCapabilityState(target, cliBinCapability) }; + } + if (registry.capabilityState !== undefined) { + return { known: true, state: registry.capabilityState(target, cliBinCapability) }; + } + return { known: false }; + }; for (const bin of model.packageBuild?.bins ?? []) { if (bin.generatedCli === undefined) continue; for (const target of model.targets) { - if (!registry.has(target.name) || registry.supports(target.name, cliBinCapability)) continue; - const capability = registry.capabilityState?.(target.name, cliBinCapability); + if (!registry.has(target.name)) continue; + const judged = judgmentFor(target.name); + const supported = judged.known + ? capabilityIsSupported(judged.state) + : registry.supports(target.name, cliBinCapability); + if (supported) continue; + const capability = judged.known ? judged.state : undefined; let judgment: string; if (capability === undefined) { judgment = `the target publishes no ${cliBinCapability} capability row`; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 6f416f997..5126b6ce6 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -705,6 +705,13 @@ export interface NormalizationTargetRegistry { targetNames: readonly string[], ): readonly NormalizationHostBinSource[]; capabilityState?(name: string, capability: string): CapabilityState | undefined; + /** + * The judgment that decides component emission for one target: the + * adapter's `componentCapabilities` override when it publishes one (a key + * it omits reads as no row), otherwise its `capabilities`. Emission and + * `inspect` accounting must consult the same judgment. + */ + componentCapabilityState?(name: string, capability: string): CapabilityState | undefined; configExtensions(): readonly NormalizationConfigExtension[]; defaultTargetNames(): readonly string[]; has(name: string): boolean; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index bb716fece..ca2cd7c55 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -987,6 +987,27 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i ...cursor, artifactLayout: { ...layoutWithoutBin, cliBin: { allowedSuffixes: ['.js', '.mjs'], directory: 'bin' } }, })).not.toThrow(); + + // Emission follows the component judgment `inspect` reports + // (`componentCapabilities ?? capabilities`), so an override that withdraws + // `cli` hosts no bin (and needs no layout), while an override that grants it + // needs the layout even if the top-level row is absent. + const withdrawn = new TargetRegistry().register({ + ...cursor, + artifactLayout: layoutWithoutBin, + componentCapabilities: { ...cursor.componentCapabilities, cli: unavailableCapability('withdrawn for this host') }, + }); + expect(withdrawn.supports('cursor', 'cli')).toBe(true); + expect(withdrawn.hostsComponent('cursor', 'cli')).toBe(false); + expect(withdrawn.componentCapabilityState('cursor', 'cli')).toEqual({ reason: 'withdrawn for this host', state: 'unavailable' }); + expect(() => new TargetRegistry().register({ + ...cursor, + artifactLayout: layoutWithoutBin, + capabilities: capabilitiesWithoutCli, + componentCapabilities: { cli: cursor.capabilities.cli! }, + })).toThrow(/supported cli capability without a routed CLI bin layout/u); + expect(registry.hostsComponent('cursor', 'cli')).toBe(true); + expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false); }); it('rejects a malformed inspection component capability when the adapter registers', () => { From ecce21cb3e3dea944bf63916e2b9852bf2608a33 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:03:17 +0000 Subject: [PATCH 4/5] fix(build): compile the artifact routed CLI after MCP Apps The MCP App Rsbuild pass asserts the target root holds nothing but its own HTML, so the routed CLI bin (like scripts, hooks, and MCP entries) must follow it; otherwise a project combining MCP Apps with a generated CLI failed with AB5000 (caught by the packed stdio projection release gate). --- packages/agent-bundle/src/build/build.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 086bb9718..9a9ef0907 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -385,15 +385,8 @@ export const build = async (options: BuildOptions): Promise => { // manifest, `inspect`, and dev status report (issue #237). const meta = projectMeta(options.model.metadata); for (const target of stagedTargets) { - if (target.cliBin) { - compiledCliBins.push(...(await compileCliBins(options.model, { - cwd: options.projectRoot, - meta, - outDir: target.root, - target: target.name, - ...tools, - }))); - } + // MCP Apps compile first: their Rsbuild pass asserts the target root + // holds nothing but its own HTML, so every other surface follows it. const targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { cwd: options.projectRoot, meta, @@ -403,6 +396,15 @@ export const build = async (options: BuildOptions): Promise => { }); compiledMcpApps.push(...targetMcpApps); await emitPlanEntries({ entries: target.entries, root: target.root }); + if (target.cliBin) { + compiledCliBins.push(...(await compileCliBins(options.model, { + cwd: options.projectRoot, + meta, + outDir: target.root, + target: target.name, + ...tools, + }))); + } compiledEntries.push( ...(await compileEntries( options.model.scripts.filter((script) => script.targets.includes(target.name)), From 9edd7ef25342cf942bc33de69ea5d83aa86c2a41 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:42:59 +0000 Subject: [PATCH 5/5] fix(build): compare routed CLI bin collisions case-insensitively (AB4766) Codex review on #419: a host-emitted `bin/MyPlugin.mjs` is the same file as the generated `bin/myplugin.mjs` on macOS and Windows, so the collision check folds case and names both paths in the diagnostic. --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/build/cli-bins.ts | 24 ++++++++++++++----- .../tests/artifact-cli-bin.test.ts | 11 ++++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a808a4115..d808bc684 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -402,7 +402,7 @@ that claims the same path. See “The routed CLI shell” in | Code | Severity | Trigger | | --- | --- | --- | | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | -| `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name). The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | +| `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | ## Config beside a route-generated MCP server (`AB4340`) diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index b658d6161..3b6f01c99 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -203,22 +203,34 @@ export const compileCliBins = async ( * AB4766: the routed CLI bin's artifact paths are framework-owned. A target * plan that already places a file there (for example a Claude `claude.bin` * directory shipping `.mjs`) cannot be merged silently, so the - * build refuses with the colliding paths named. + * build refuses with the colliding paths named. Paths are compared + * case-folded: on the case-insensitive filesystems most plugins are developed + * and installed on, `bin/MyPlugin.mjs` and `bin/myplugin.mjs` are one file, + * and a name that differs only by case is a hazard everywhere else. */ export const cliBinCollisionDiagnostics = ( model: NormalizedPlugin, target: string, entries: readonly TargetArtifactEntry[], ): readonly Diagnostic[] => { - const planned = new Set(entries.map((entry) => entry.relativePath)); + const planned = new Map(); + for (const entry of entries) { + const folded = entry.relativePath.toLowerCase(); + if (!planned.has(folded)) planned.set(folded, entry.relativePath); + } return Object.freeze(routedCliBins(model).flatMap((bin) => { const rendered = generatedCli(bin).commands.some((command) => command.rendered); return [cliBinArtifactPath(bin.name), ...(rendered ? [cliBinWorkerArtifactPath(bin.name)] : [])] - .filter((path) => planned.has(path)) - .map((path): Diagnostic => ({ + .flatMap((owned) => { + const emitted = planned.get(owned.toLowerCase()); + return emitted === undefined ? [] : [{ emitted, owned }]; + }) + .map(({ emitted, owned }): Diagnostic => ({ code: 'AB4766', - message: `Target ${JSON.stringify(target)} already emits ${JSON.stringify(path)}, which the routed CLI bin ${JSON.stringify(bin.name)} owns; the compiler never chooses silently.`, - recovery: `Rename or remove the host-emitted ${JSON.stringify(path)} (for example the file in the configured claude.bin directory), or set bin: false to keep the host file and drop the routed CLI executable.`, + message: emitted === owned + ? `Target ${JSON.stringify(target)} already emits ${JSON.stringify(emitted)}, which the routed CLI bin ${JSON.stringify(bin.name)} owns; the compiler never chooses silently.` + : `Target ${JSON.stringify(target)} already emits ${JSON.stringify(emitted)}, which differs only by case from ${JSON.stringify(owned)} owned by the routed CLI bin ${JSON.stringify(bin.name)}; on a case-insensitive filesystem they are one file, so the compiler never chooses silently.`, + recovery: `Rename or remove the host-emitted ${JSON.stringify(emitted)} (for example the file in the configured claude.bin directory), or set bin: false to keep the host file and drop the routed CLI executable.`, severity: 'error', sourcePath: bin.provenance.sourcePath, target, diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index be2f7e7bd..9045e9dec 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -290,6 +290,11 @@ it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)', // CLI owns: the compiler never chooses between them silently. await writeProjectFile(root, `host-bin/${pluginName}.mjs`, "console.log('host bin');\n"); await chmod(join(root, 'host-bin', `${pluginName}.mjs`), 0o755); + // A second entry differing only by case is the same file on macOS and + // Windows, so it is a collision too and is named beside the owned path. + const caseVariant = `${pluginName.toUpperCase()}-flight.mjs`; + await writeProjectFile(root, `host-bin/${caseVariant}`, "console.log('host worker');\n"); + await chmod(join(root, 'host-bin', caseVariant), 0o755); await writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', @@ -300,8 +305,12 @@ it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)', '', ].join('\n')); - await expect(build({ output: 'artifact', root })).rejects.toThrow( + const failure = build({ output: 'artifact', root }); + await expect(failure).rejects.toThrow( new RegExp(`\\[AB4766\\] Target "claude" already emits "bin/${pluginName}\\.mjs"`, 'u'), ); + await expect(failure).rejects.toThrow( + new RegExp(`\\[AB4766\\] Target "claude" already emits "bin/${caseVariant}", which differs only by case from "bin/${pluginName}-flight\\.mjs"`, 'u'), + ); await expect(stat(join(root, 'artifact'))).rejects.toMatchObject({ code: 'ENOENT' }); });