diff --git a/.changeset/387-artifact-routed-cli.md b/.changeset/387-artifact-routed-cli.md new file mode 100644 index 000000000..d41adcd64 --- /dev/null +++ b/.changeset/387-artifact-routed-cli.md @@ -0,0 +1,16 @@ +--- +"agent-bundle": patch +--- + +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/diagnostics.md b/docs/diagnostics.md index 91f0ea35e..d808bc684 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), 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`) 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..9cbe31eb7 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,64 @@ 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`); 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. + #### 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..50a567979 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' }; @@ -35,6 +37,7 @@ import pluginSchema from './schemas/portable/plugin.schema.json' with { type: 'j import { createAdapterValidator, payloadCopyEntries, + routedCliBinLayout, schemaDescriptorsFrom, sourceInputs, validateJsonSchemaDocument, @@ -608,6 +611,7 @@ export const portableAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout: Object.freeze({ assets: 'assets', + 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']), @@ -616,6 +620,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..8bdc2f827 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -9,23 +9,24 @@ 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'; 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,11 +182,38 @@ 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. + // 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; - 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 @@ -237,6 +265,7 @@ const snapshotArtifactLayout = ( return Object.freeze({ ...(assets === undefined ? {} : { assets }), ...(bin === undefined ? {} : { bin }), + ...(cliBin === undefined ? {} : { cliBin }), ...(commands === undefined ? {} : { commands }), ...(hookWrappers === undefined ? {} : { hookWrappers }), ...(mcpApps === undefined ? {} : { mcpApps }), @@ -651,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/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index c99cb416d..9c295f70b 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; @@ -428,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 @@ -435,6 +455,7 @@ export interface TargetArtifactLayout { */ export const standardArtifactLayout: TargetArtifactLayout = Object.freeze({ assets: 'assets', + 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/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..9a9ef0907 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,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) { + // 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, @@ -364,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)), @@ -427,6 +468,7 @@ export const build = async (options: BuildOptions): Promise => { artifactRoot: stageRoot, outputs: outputCandidatesFor({ artifactRoot: stageRoot, + compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, @@ -466,6 +508,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..3b6f01c99 --- /dev/null +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -0,0 +1,239 @@ +import { resolve } from 'node:path'; + +import { cliBinCapability } from '../adapters/capability-state.ts'; +import type { TargetRegistry } from '../adapters/registry.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'; +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. + */ + +/** 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`; + +/** 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 — + * by the component judgment (`componentCapabilities ?? capabilities`), so + * emission and `inspect` accounting can never disagree. + */ +export const targetHostsCliBin = (registry: TargetRegistry, target: string): boolean => + registry.hostsComponent(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. 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 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)] : [])] + .flatMap((owned) => { + const emitted = planned.get(owned.toLowerCase()); + return emitted === undefined ? [] : [{ emitted, owned }]; + }) + .map(({ emitted, owned }): Diagnostic => ({ + code: 'AB4766', + 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/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..baa8dcea3 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,7 +1,9 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; +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'; @@ -2130,6 +2132,74 @@ 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[] = []; + // 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)) 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`; + } 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 +2218,8 @@ export const validateModel = ( } } + diagnostics.push(...routedCliBinTargetDiagnostics(model, registry)); + const ids = new Map(); const components = [ model.metadata, 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 c6617b9ca..ca2cd7c55 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -946,6 +946,70 @@ 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 — 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(); + + // 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', () => { 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..9045e9dec --- /dev/null +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -0,0 +1,316 @@ +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); + // 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({', + " claude: { bin: './host-bin' },", + ` plugin: { description: 'Artifact routed CLI fixture.', name: ${JSON.stringify(pluginName)}, version: '3.8.7' },`, + " targets: ['claude'],", + '});', + '', + ].join('\n')); + + 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' }); +}); 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',