diff --git a/.changeset/596-cli-surface-projection.md b/.changeset/596-cli-surface-projection.md new file mode 100644 index 000000000..ddda0e248 --- /dev/null +++ b/.changeset/596-cli-surface-projection.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Reserve `.cli.{ts,tsx}` under `src/mcp/**` for the opt-in CLI surface projection of a generated tool: a colocated `.cli.ts` exporting `CliProjectionConfig` (`agent-bundle/routes`) and an optional synchronous `mapInput` compiles to an idiomatic command (`inspect --routes` shows `cli.commands[].projection`) and excludes that tool from the bulk `routes.mcpCommands` projection. The bulk `routes.mcpCommands` projection now runs tools with `invocation.kind: 'cli'` (same as explicit projections; the generated MCP server still passes `kind: 'tool'`). Orphan or misplaced modules are `AB4843`, an invalid projection contract is `AB4844`, and a grammar that does not bind to the tool's contract is `AB4845` (#616). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 56eadb58f..8cb1a76a3 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -30,7 +30,7 @@ even when no error diagnostic was reported. | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | -| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), and provider conventions (see below). | +| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains behind that compile-time check and reports residual import, syntax, and relative-target findings; a `dist` finding names `dist/`; `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). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | @@ -973,12 +973,15 @@ framework-owned plugin twice by accident. | `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. | | `AB4725` | error | `tools` externalizes a non-built-in (`tools.rsbuild.output.autoExternal` not `false`, or a string/object `externals` entry that names a package — neither a Node built-in, `pnpapi`, nor a relative path — in `tools.rsbuild.output` or an object-form `tools.rspack`). | Remove the externalization; RegExp, function, and relative externals are judged by the compilation's evidence instead (AB6005), where the emitted siblings are known. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4839`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4845`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, `src/providers/*`, `src/cli/**`, `src/scripts/**`) and the shared layout modules (`src/layout.*`, `src/mcp//layout.*`) into one immutable IR. +A `.cli.{ts,tsx}` module under `src/mcp/**` is reserved as a CLI surface +projection of a sibling tool, not a route: discovery records it and pairs +it, and never derives a `tool:/.cli` identity from it. Discovery is not a packaging choice, so every collision is a hard **error** and the compiler never silently picks a side. Modules that explicit `scripts`, `hooks`, `bin`, `lib`, or `mcp` configuration references are @@ -1177,14 +1180,17 @@ outside the project or one that cannot be read, a target module without a top-level `export const `, a `let`/`var`, destructured, function, class, default-import, or namespace-import binding, an unknown identifier, and a dynamic initializer (a bare call, a function, a template literal with -substitutions). On a CLI route such a reference is `AB4838`, whose message -prints the chain (`inputSchema -> statusInputSchema +substitutions). On a CLI route, or a tool route with a CLI projection, such a reference is +`AB4838`, whose message prints the chain (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`) and the boundary (`imported from "@shared/protocol", which is not a relative module path`); a -cyclic chain is `AB4839`, whose message prints the cycle. Only CLI routes -raise them, because there the argv grammar is load-bearing and the command -cannot compile without it; an MCP tool, resource, prompt, script, or event -route whose schema the resolver cannot follow compiles silently without a +cyclic chain is `AB4839`, whose message prints the cycle. On a tool route +with a CLI projection the message prefix is `Tool route (CLI +projection )` instead of `CLI route `. Only CLI routes, or a +tool route with a CLI projection, raise them, because there the argv +grammar is load-bearing and the command cannot compile without it; an MCP +tool without a projection, or a resource, prompt, script, or event route, +whose schema the resolver cannot follow compiles silently without a static contract, exactly as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. `resultSchema` may be imported the same way: the route contract check (`AB4810`/`AB4815`) @@ -1210,6 +1216,15 @@ graphs whose schemas are all inline keep their recorded digests. Workbench route detail shows a route's contract origin and the other routes sharing it. +A generated tool may also carry an opt-in CLI surface projection: a +colocated `.cli.{ts,tsx}` beside the tool route. The module is never +a route — `RouteContract.routes` does not list it — and the compiled +command's `routeId` stays the tool id. `inspect --routes` prints +`cli.commands[].projection` (`module`, `mapInput`, `relaxed?`) and the +mapped `options[]` (`key`, `option`, `aliases`). A projection that cannot +compile has no correct partial output, so every finding is an error +(`AB4843`–`AB4845`). + An event route (`src/events//*`) may add a **preflight gate** (#595): a named `preflight` export the generated hook entry runs after envelope decoding, host validation, and canonical event construction, and before any @@ -1266,7 +1281,7 @@ resolving a provider set the author did not write. | `AB4801` | error | The conventional `src/cli.ts` entry and `src/cli/` command route modules both exist without an explicit `routes.cli` mode. | | `AB4802` | error | Two route modules derive the same route id (for example `.ts` and `.tsx` siblings with one stem). | | `AB4803` | error | A route path derives an unsafe identity segment (each segment must match `^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$`). | -| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. | +| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI; or `routes.cli: 'conventional'` is set while the project has generated commands to carry (`routes.mcpCommands`, or a `.cli.{ts,tsx}` projection module — the message names the modules). | | `AB4805` | error | A route module exports `config` through a rejected declaration shape (`let`/`var`, destructuring, `export { config }`, a function or class, a missing initializer), or the extracted value is not an object. | | `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position (for a reference the static resolver could not follow, the boundary it stopped at: a non-relative specifier, a module outside the project, a missing `export const`, a non-`const` binding, a non-literal initializer), and the recovery names the two accepted reference forms (a top-level `const` string literal declared locally or reached through `export const` alias hops across any number of relative modules inside the project, and `appResourceUri('')` from `agent-bundle/routes`). | | `AB4807` | retired | The stage-1 rendered-script gate. Rendered script routes ship through the Agent renderer pipeline since #102 stage 3; the code is never reused. | @@ -1276,7 +1291,7 @@ resolving a provider set the author did not write. | `AB4811` | error | A generated MCP route exports `execute` or `render`; route mode accepts only the async default Server Component contract. | | `AB4812` | error | A generated MCP App route has no non-empty static `config.resourceUri`. | | `AB4813` | error | The command graph collides: a route is both a command module and a command group, an alias collides with a sibling command, group, or alias, an alias is unsafe or duplicated, or an explicit `bin` entry claims the generated CLI executable's name. | -| `AB4814` | error | A CLI route's `inputSchema` leaves the bounded argv grammar wherever the schema is declared — inline, or in a relative module the route imports (the message names the offending construct and position, qualified by the declaring module for a resolved import: `z.object at src/lib/protocol-schemas.ts:12:5 is outside the bounded argv grammar`) — a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. A reference the static resolver cannot follow is `AB4838`, and a cyclic one `AB4839`, not `AB4814`. | +| `AB4814` | error | A CLI route's, or a tool route with a CLI projection's, `inputSchema` leaves the bounded argv grammar wherever the schema is declared — inline, or in a relative module the route imports (the message names the offending construct and position, qualified by the declaring module for a resolved import: `z.object at src/lib/protocol-schemas.ts:12:5 is outside the bounded argv grammar`) — a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. A reference the static resolver cannot follow is `AB4838`, and a cyclic one `AB4839`, not `AB4814`. On a tool route with a CLI projection the message prefix is `Tool route (CLI projection )` instead of `CLI route `. | | `AB4815` | error | A CLI route does not satisfy the routed command contract: missing named `inputSchema`/`resultSchema` exports, a default export that is not an async function, or malformed `config.description`/`aliases`/`exitCode` fields. | | `AB4816` | retired | The stage-2 rendered-command gate. Rendered command routes render through the dispatcher since #102 stage 3; the code is never reused. | | `AB4817` | error | An event route requires the shared runtime for a target, but no generated MCP entry hosts that runtime and the route does not allow standalone fallback. | @@ -1300,10 +1315,13 @@ resolving a provider set the author did not write. | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | | `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Keep framework calls in a host process: expose an MCP App with `web.apps` and open it from the installed artifact with ` web`; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/web-host`. | -| `AB4838` | error | A CLI route's `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route inputSchema: .` — the chain is the reference path from `inputSchema`, each step ``, or ` ()` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const `, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes raise it, because only there the static contract is load-bearing: an MCP, script, or event route whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. | -| `AB4839` | error | A CLI route's `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `#` is recorded and revisiting one stops the walk. The message is `CLI route inputSchema: is a reference cycle.` and prints the cycle; it is reported on the route module, with the same recovery and the same CLI-only rule as `AB4838`. | +| `AB4838` | error | A CLI route's, or a tool route with a CLI projection's, `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route inputSchema: .` — or, on a tool route with a CLI projection, `Tool route (CLI projection ) inputSchema: .` — the chain is the reference path from `inputSchema`, each step ``, or ` ()` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const `, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes, or a tool route with a CLI projection, raise it, because only there the static contract is load-bearing: an MCP tool without a projection, or a script or event route, whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. | +| `AB4839` | error | A CLI route's, or a tool route with a CLI projection's, `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `#` is recorded and revisiting one stops the walk. The message is `CLI route inputSchema: is a reference cycle.` — or, on a tool route with a CLI projection, `Tool route (CLI projection ) inputSchema: is a reference cycle.` — and prints the cycle; it is reported on the route module, with the same recovery as `AB4838` and the same rule that only CLI routes, or a tool route with a CLI projection, raise it. | | `AB4840` | error | An event route's `preflight` gate (#595) is not the one physically cheap form the compiler can bundle on its own. Rejected: `preflight` declared inline in the route module (`export const preflight = …`, `export function preflight`) or exported more than once; re-exported under a binding other than `default` (`export { gate as preflight } from './gate.js'`, `export { preflight } from './gate.js'`); re-exported from a non-relative specifier (a bare package such as `'@scope/gate'`); a relative target that is missing, unreadable, or part of a re-export cycle; a target default export that cannot be followed through an acyclic chain of relative default re-exports; or a target default export that is not a function the scan can see. The message names the route module and, once a re-export was found, its specifier. Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per route with the route module as `sourcePath`; the route compiles without a gate beside the error, and the build fails rather than silently taking the expensive rendered path. Write exactly `export { default as preflight } from './.js'` in the route module, and make that module default-export one sync or async function receiving `{ canonical, host, signal, terminal }` and returning `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }`. | | `AB4841` | error | An event route's static required-provider declaration (#595) does not select a known set of conventional providers: `config.providers` is not an array of provider-key strings; a key is declared more than once; a key is the reserved `processLifetime`; or a key matches no conventional provider the route graph discovered under `src/providers/`. The message names the route and every offending key. Declare each key exactly once, spelled as the camel-cased stem of its `src/providers/.*` module, drop `processLifetime`, declare `[]` to mount `processLifetime` alone, or omit `config.providers` to preserve the all-provider compatibility default. | +| `AB4843` | error | A `.cli.{ts,tsx}` module under `src/mcp//tools/` has no sibling tool route `.{ts,tsx}` (orphan), a `.cli.{ts,tsx}` module sits under `resources/`, `prompts/`, or `apps/`, or a second projection module (`.cli.ts` beside `.cli.tsx`) names the same tool — the first in path order wins and the second is reported. The suffix is reserved under `src/mcp/**` only. The message is `CLI projection for tool:/: .` (`has no sibling tool route …`, ` already projects this tool …`); a misplaced module names no tool, so its message is `CLI projection : sits under resources/, prompts/, or apps/ …`. `sourcePath` is the projection module's absolute path. Recovery: rename the file to match the sibling tool, or prefix `_` to park it, then inspect again. It is an error because a projection that cannot compile has no correct partial output. | +| `AB4844` | error | A CLI projection module's contract is invalid: `config` is missing or outside the static grammar (the message includes the `AB4805`/`AB4806` reason), a key sits outside the closed set (`command`, `description`, `positionals`, `flags`, `aliases`, `confirm`, `exitCode`), a field has the wrong shape, `mapInput` is exported but is not a synchronous, non-generator function with a runtime binding — rejected forms are an ambient declaration (`declare function mapInput` / `declare const mapInput`, which emits no binding for the shell to call), a generator or async generator (`function*`, `async function*`), an async function or arrow (the shell applies `mapInput` synchronously before `inputSchema.parse`, so a Promise would reach the schema), a binding that is not statically a function (`export const mapInput = pipe(identity)`, or an overload signature with no implementation body), or an `export { mapInput } from '…'` re-export the scan cannot follow to a function (a bare specifier, an unreadable file, or a re-export cycle; a relative re-export it can follow is judged where the function is declared) — or `flags..required: false` / `flags..default` appears on a canonical-required key without `mapInput`. The message is `CLI projection for tool:/: .` and `sourcePath` is the projection module's absolute path. Recovery names the rejected field and the accepted form, then says to inspect again. It is an error because a projection that cannot compile has no correct partial output. | +| `AB4845` | error | A CLI projection's grammar does not bind to the tool's contract: `flags`/`positionals` name a key absent from the tool's `RouteContract.input`; a `name`/alias is not kebab-case, is reserved (`help`, `json`, `ndjson`, `version`, and `yes` when confirm), or collides with another option's spelling or alias; `flags..name` or `flags..aliases` is declared on a key `positionals` consumes as a bare argument (`description`, `default`, and `required: false` still apply there); the tool's contract has a key `yes` while the command confirms — the shell keys parsed values by canonical key and strips `yes` as the confirmation, so no `name` override reaches the tool (`set confirm: false or rename the key`); or a `command` segment is not a safe identity segment. The message is `CLI projection for tool:/: .` and `sourcePath` is the projection module's absolute path. Recovery names the offending key or spelling and the accepted form, then says to inspect again. It is an error because a projection that cannot compile has no correct partial output. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 715da7dae..cf47e250a 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -823,7 +823,7 @@ export default async function inspect({ input, signal }: CliRouteProps.js` with the shebang and executable bit through the same Rslib synthesis as every other bin. At run time the shell resolves the @@ -845,8 +845,9 @@ hops, the `.js` specifier mapping onto its `.ts`/`.tsx` source) is resolved statically, parsed in the declaring module's scope under the same grammar, and normalized once into a `RouteContract` shared by every route — CLI command or MCP tool — that binds it; a reference the resolver cannot follow -is `AB4838` and a cyclic one `AB4839`, both documented in the same -[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4839-ab4940ab4942) +is `AB4838` and a cyclic one `AB4839` (CLI routes, or a tool route with a +CLI projection), both documented in the same +[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4842-ab4940ab4942) section. When the module's `inputSchema` rejects the parsed argv, the shell reports @@ -962,16 +963,19 @@ export default defineConfig({ }); ``` -`true` selects every eligible tool. Object-form `include` defaults to every +`true` selects every eligible tool. A tool that already has a colocated +`.cli.{ts,tsx}` is not eligible — the explicit projection is the +command for that operation. Object-form `include` defaults to every eligible tool when omitted; `exclude` removes matches afterward. Patterns match the `:` identity and support only literal text plus `*` (zero or more characters). Every declared pattern must match at least one eligible tool; `include: []` and misspellings fail with `AB4822`, whose -diagnostic lists the available identities. Excluding every selected tool is -legal. +diagnostic lists the available identities (and names the projection module +when the only matches were excluded by a `.cli.ts`). Excluding every +selected tool is legal. -Each projected tool runs as ` ` with the protocol -tool name preserved verbatim. Its only input option is +Each bulk-projected tool runs as ` ` with the +protocol tool name preserved verbatim. Its only input option is `--input ''`; omission supplies `{}`, while invalid JSON, arrays, `null`, and scalars exit 2 before route execution. A tool is read-only only when its static MCP annotations explicitly set `readOnlyHint: true`. @@ -1006,6 +1010,62 @@ does not depend on MCPorter or introduce a second command model. MCPorter can still be pointed independently at the generated MCP server when a live-server client is desired. +The explicit form is a colocated `.cli.ts` (or `.cli.tsx`) projection +module beside `src/mcp//tools/.tsx` (or `.ts`). It is never a +route: discovery excludes `.cli.{ts,tsx}` before identity derivation, pairs +the file with the sibling tool, and does not list it on +`RouteContract.routes`. The suffix is reserved under `src/mcp/**`; prefix +`_` parks a file the same way as any other conventional module. An orphan +or a `.cli.*` under `resources/`, `prompts/`, or `apps/` is `AB4843`. + +The module exports a static `config` that satisfies `CliProjectionConfig` +from `agent-bundle/routes` (the same extract grammar as a route `config`) +and, optionally, a synchronous `mapInput`: + +- `command` — path segments; default `[tool]`; each must pass + `safeIdentitySegment`. +- `description` — help text; default: the tool's `config.description`. +- `positionals` — canonical keys consumed as bare arguments, in order + (same rules as a `src/cli` route). +- `flags` — keyed by the canonical key of the tool's + `RouteContract.input`. Each entry may set `name` (CLI spelling, + kebab-case, no leading dashes; default `kebab(key)`), `aliases` (extra + long-form spellings), `description` (overrides schema `.describe()`), + `default` (CLI-only, applied by the shell before `mapInput`), and + `required: false` (relax a canonical-required key; legal only when + `mapInput` is exported). +- `aliases` — command aliases (same rules as a `src/cli` route). +- `confirm` — default `!(tool config.annotations.readOnlyHint === true)`. +- `exitCode` — `'result'` or `'zero'`; default: the tool's + `config.exitCode ?? 'zero'`. + +`mapInput` receives the parsed CLI input (canonical keys, after +projection defaults) and must return `z.input`. It +is recorded statically (`scanRouteModuleExports`) and loaded only by the +CLI bin; the MCP worker never sees the module. `mapInput` is a surface +adapter, not domain logic: it only reshapes or defaults argv into the +canonical input (renames, splitting lists, deriving a working directory). +Domain validation and behaviour stay in the operation — its +`inputSchema` refinements and its component. A mapper that recreates +command logic is the duplication the projection exists to remove. A +contract problem is `AB4844`; a grammar that does not bind to the tool's +contract is `AB4845`. Message shape: +`CLI projection for tool:/: .` + +The explicit projection takes precedence over the bulk `mcpCommands` +projection: that tool is removed from the eligible set so one operation +never becomes two commands. The compiled command's `routeId` is the tool +id; at run time the tool runs with +`invocation.kind: 'cli'` and `operationId` equal to that tool id +(`tool:/`), so a route can pick surface wording from +`agent().invocation.kind` while the operation stays the tool. A route +observes `kind: 'cli'` whenever it runs from the generated CLI +executable, whichever projection mechanism produced the command — the +bulk `--input` projection is a CLI surface too. The generated MCP +server still passes `kind: 'tool'`. +`inspect --routes` prints `cli.commands[].projection` +(`module`, `mapInput`, `defaults?`, `relaxed?`) and `options[].{key,option,aliases}`. + ### The stdio MCP lifecycle shell An MCP server entry that **default-exports a server factory** is served under diff --git a/packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts b/packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts new file mode 100644 index 000000000..f69eb737d --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts @@ -0,0 +1 @@ +export const dedupe = (values: readonly Value[]): readonly Value[] => [...new Set(values)]; diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx index 83fbda5f6..ce74a0f2b 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/mutation-probe.tsx @@ -14,7 +14,7 @@ export const inputSchema = z.object({ export const resultSchema = z.object({ executions: z.number().int().positive(), - invocation: z.literal('tool'), + invocation: z.enum(['cli', 'tool']), marker: z.string().nullable(), operationId: z.string(), }).strict(); @@ -28,7 +28,7 @@ export default async function MutationProbe({ const context = await agent(); const result = { executions, - invocation: context.invocation.kind as 'tool', + invocation: context.invocation.kind as 'cli' | 'tool', marker: input.marker ?? null, operationId: context.invocation.operationId!, }; diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.tsx new file mode 100644 index 000000000..e546dcd09 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.tsx @@ -0,0 +1,32 @@ +import type { CliProjectionConfig } from 'agent-bundle/routes'; +import type { z } from 'zod'; + +import { dedupe } from '../../../lib/submit-helpers.js'; +import type { inputSchema } from './submit.js'; + +export const config = { + command: ['submit'], + confirm: false, + flags: { + cwd: { description: 'Working directory of the command (default: the current directory).', required: false }, + laneKey: { name: 'lane' }, + tags: { description: 'Tag attached to the request (repeatable; duplicates are dropped).', name: 'tag' }, + }, + positionals: ['argv'], +} satisfies CliProjectionConfig; + +type CliInput = Omit, 'cwd'> & { readonly cwd?: string }; + +// Leading "!" tags exercise projection mapping failures. +export const mapInput = (input: CliInput): z.input => { + const tags = input.tags === undefined ? undefined : dedupe(input.tags); + const rejected = tags?.find((tag) => tag.startsWith('!')); + if (rejected !== undefined) { + throw new Error(`Tag ${JSON.stringify(rejected)} must not start with "!".`); + } + return { + ...input, + cwd: input.cwd ?? process.cwd(), + ...(tags === undefined ? {} : { tags: [...tags] }), + }; +}; diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx new file mode 100644 index 000000000..e2b4b1375 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx @@ -0,0 +1,42 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + annotations: { readOnlyHint: false }, + description: 'Submits one command line as lane work and echoes the accepted request.', + title: 'Submit', +}; + +export const inputSchema = z.object({ + argv: z.array(z.string()).min(1).describe('The command line to run.'), + cwd: z.string().min(1).describe('Working directory of the command.'), + laneKey: z.string().min(1).optional().describe('Lane the work is queued under.'), + tags: z.array(z.string()).optional().describe('Tags attached to the request.'), +}); + +export const resultSchema = z.object({ + argv: z.array(z.string()).min(1), + cwd: z.string().min(1), + laneKey: z.string().optional(), + operation: z.literal('submit'), + tags: z.array(z.string()).optional(), +}); + +export default async function Submit({ input }: { readonly input: z.infer }) { + const context = await agent(); + const value = { + argv: input.argv, + cwd: input.cwd, + ...(input.laneKey === undefined ? {} : { laneKey: input.laneKey }), + operation: 'submit' as const, + ...(input.tags === undefined ? {} : { tags: input.tags }), + }; + const { invocation, providers } = context; + return ( + + {`submit: ${input.argv.join(' ')}`} + {`invocation: ${invocation.kind} ${invocation.operationId ?? '(no operation)'} ${invocation.surface ?? '(no surface)'}`} + {`provider: ${JSON.stringify(providers['libraryTooling'])}`} + + ); +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/providers/library-tooling.ts b/packages/agent-bundle/fixtures/route-harness/src/providers/library-tooling.ts index 2ea925c5a..60ce16553 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/providers/library-tooling.ts +++ b/packages/agent-bundle/fixtures/route-harness/src/providers/library-tooling.ts @@ -9,7 +9,12 @@ import type { AgentProviderContext } from 'agent-bundle'; export default async function libraryTooling({ invocation, signal }: AgentProviderContext) { if (signal.aborted) throw new DOMException('aborted', 'AbortError'); const input = invocation.kind === 'tool' ? invocation.props.input : undefined; - if (typeof input === 'object' && input !== null && (input as { readonly failProvider?: unknown }).failProvider === true) { + const failProvider = typeof input === 'object' + && input !== null + && (input as { readonly failProvider?: unknown }).failProvider === true; + const failCliProvider = invocation.kind === 'cli' + && invocation.props.args.includes('{"failProvider":true}'); + if (failProvider || failCliProvider) { throw new Error('ffprobe is not installed'); } const surface = invocation.kind === 'tool' diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index e7c69e631..4e9c14d7b 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -74,6 +74,7 @@ export type { AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, + CliProjectionConfig, EventPreflight, EventPreflightContext, EventPreflightResult, @@ -121,6 +122,7 @@ export type { CapabilityState, CompiledAgentRoute, CompiledCliMode, + CompiledCliProjection, CompiledCliSurface, CompiledProvider, CompiledRouteGraph, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index c78ba2096..e7960facf 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -93,6 +93,23 @@ const generatedCli = (bin: NormalizedBinEntry): NonNullable { + const cli = generatedCli(bin); + return Object.freeze([...new Set([ + bin.provenance.sourcePath, + model.metadata.provenance.sourcePath, + ...cli.routes.map((route) => route.source), + ...Object.values(cli.projectionSources ?? {}), + ...(model.layouts ?? []).map((layout) => layout.source), + ...(model.providers ?? []).map((provider) => provider.source), + ...(model.state === undefined ? [] : [model.state.source]), + ])]); +}; + export const planCompiledCliBins = ( model: NormalizedPlugin, options: { readonly outDir: string; readonly target: string }, @@ -101,14 +118,7 @@ export const planCompiledCliBins = ( 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, - model.metadata.provenance.sourcePath, - ...cli.routes.map((route) => route.source), - ...(model.layouts ?? []).map((layout) => layout.source), - ...(model.providers ?? []).map((provider) => provider.source), - ...(model.state === undefined ? [] : [model.state.source]), - ])]); + const sourceInputs = cliBinSourceInputs(model, bin); return Object.freeze({ bin, id: bin.id, @@ -169,6 +179,7 @@ export const cliBinRslibEntries = ( }, ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], + ...(cli.projectionSources === undefined ? {} : { projectionSources: cli.projectionSources }), routes: cli.routes, ...(model.state === undefined ? {} : { state: model.state }), // Durable state anchors on the artifact root (the parent of `bin/`), diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index c9ec1bfee..f2d513adb 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -200,6 +200,8 @@ export type GeneratedStateFallback = 'artifact' | 'cwd'; export interface GeneratedCliBinEntryOptions { readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; + /** Absolute projection-module sources keyed by their backing tool route id. */ + readonly projectionSources?: Readonly>; /** Conventional request context providers, mounted for plain commands in this process (#313). */ readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; @@ -393,6 +395,16 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): // starting. const runtimeBacked = commandRoutes.length > 0; const options: GeneratedCliBinEntryOptions = runtimeBacked ? input : { ...input, providers: [], state: undefined }; + const projectedCommands = options.commands.flatMap((command) => + command.projection === undefined ? [] : [{ command, projection: command.projection }]); + const projectionSources = projectedCommands.map(({ command, projection }) => { + const source = options.projectionSources?.[command.routeId]; + if (source === undefined) { + throw new Error(`Generated CLI projection ${JSON.stringify(projection.module)} for ${command.routeId} requires an absolute source path.`); + } + return source; + }); + const projectionIndexByRoute = new Map(projectedCommands.map(({ command }, index) => [command.routeId, index])); const rendered = options.commands.some((command) => command.rendered); if (rendered && options.workerFile === undefined) { throw new Error('A generated CLI with rendered commands requires a worker file.'); @@ -407,7 +419,7 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): // module-level `process.env` read sees the composed environment. The // npm package bin runs from the operator's own shell and reads none. ...(stateFallback === 'artifact' ? [operatorEnvLayerImport] : []), - `import { cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + `import { CliInputError, cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, ...(options.web === undefined ? [] : [ @@ -423,6 +435,8 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), ...generatedStateImports(options.state), ...routeImports(commandRoutes), + ...projectionSources.map((source, index) => + `import * as projection${String(index)} from ${JSON.stringify(source)};`), ...providerImports(providers), '', ...(runtimeBacked ? [pluginRootDeclaration(stateFallback, options.web?.pluginRootRelativeUrl)] : []), @@ -434,19 +448,33 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', ...providerRegistrySource(providers), 'const routes = Object.freeze({', - ...commandRoutes.map((route, index) => - ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), + ...commandRoutes.map((route, index) => { + const projectionIndex = projectionIndexByRoute.get(route.id); + return ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)}${projectionIndex === undefined ? '' : `, projection: projection${String(projectionIndex)}`} }),`; + }), '});', '', `const commands = Object.freeze(${stableJson(options.commands)});`, '', - // A schema failure becomes a CliInputError whose issues name the CLI - // argument, the expectation, and the received value (#465). 'const parseInput = (command, route, input) => {', + ' let mapped = { ...input };', + ' if (command.projection?.defaults !== undefined) {', + ' for (const [key, value] of Object.entries(command.projection.defaults)) {', + ' if (!Object.hasOwn(mapped, key)) mapped[key] = value;', + ' }', + ' }', + ' if (command.projection?.mapInput === true) {', + " if (typeof route.projection?.mapInput !== 'function') throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`);", + ' try {', + ' mapped = route.projection.mapInput(mapped);', + ' } catch (error) {', + ' throw new CliInputError(error instanceof Error ? error.message : String(error));', + ' }', + ' }', ' try {', - ' return route.module.inputSchema.parse(input);', + ' return route.module.inputSchema.parse(mapped);', ' } catch (error) {', - ' throw cliInputError(command, input, error);', + ' throw cliInputError(command, mapped, error);', ' }', '};', '', @@ -501,18 +529,6 @@ export const generatedCliBinEntrySource = (input: GeneratedCliBinEntryOptions): 'const render = (command, input, context) => {', ' const route = routes[command.routeId];', ' const parsed = parseInput(command, route, input);', - ' if (command.mcp !== undefined) {', - ' return openRenderedSession({', - " invocation: { kind: 'tool', props: { input: parsed, operationId: command.routeId } },", - ' limits: command.render,', - ' props: { input: parsed },', - ` request: { artifactEpoch: ${JSON.stringify(generatedRouteArtifactEpoch(options.plugin))}, kind: 'tool', operationId: command.routeId, surface: command.mcp.tool },`, - ' routeId: command.routeId,', - ' signal: context.signal,', - ' terminal: context.terminal,', - ' validate: (value) => route.module.resultSchema.parse(value),', - ' });', - ' }', ' return openRenderedSession({', " invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },", ' limits: command.render,', diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 8bc2362dc..c61d34aaf 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -5,6 +5,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; +import { cliBinSourceInputs } from './cli-bins.ts'; import type { CompileResult } from './compile-result.ts'; import { buildWithRslib } from './compiler.ts'; import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts'; @@ -123,13 +124,8 @@ export const planPackageEntries = async ( // Rendered commands add one sibling react-server Flight worker. const rendered = bin.generatedCli.commands.some((command) => command.rendered); const workerFile = `${bin.name}-flight.mjs`; - const sourceInputs = Object.freeze([...new Set([ - bin.provenance.sourcePath, - ...bin.generatedCli.routes.map((route) => route.source), - ...(model.layouts ?? []).map((layout) => layout.source), - ...(model.providers ?? []).map((provider) => provider.source), - ...(model.state === undefined ? [] : [model.state.source]), - ])]); + const sourceInputs = cliBinSourceInputs(model, bin); + const generatedCli = bin.generatedCli; entries.push({ aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, banner: binShebang, @@ -140,7 +136,7 @@ export const planPackageEntries = async ( source: bin.source, sourceInputs, virtualSource: generatedCliBinEntrySource({ - commands: bin.generatedCli.commands, + commands: generatedCli.commands, plugin: { ...(model.metadata.description === undefined ? {} : { description: model.metadata.description }), name: model.metadata.name, @@ -148,7 +144,10 @@ export const planPackageEntries = async ( }, ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), providers: model.providers ?? [], - routes: bin.generatedCli.routes, + ...(generatedCli.projectionSources === undefined + ? {} + : { projectionSources: generatedCli.projectionSources }), + routes: generatedCli.routes, ...(model.state === undefined ? {} : { state: model.state }), ...(rendered ? { workerFile } : {}), }), diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index a68f06291..3ce16b54f 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -84,6 +84,10 @@ export class CliUsageError extends Error { } } +/** The fail-closed confirmation diagnostic shared by bulk and explicit MCP tool projections. */ +export const confirmationRequiredMessage = (server: string, tool: string): string => + `MCP tool ${server}:${tool} is mutation-capable per its MCP annotations and requires --yes.`; + /** * One input-validation failure of a routed command, already spelled in CLI * terms (#465): the argument the user typed rather than the schema path. @@ -246,7 +250,7 @@ const pathSuffix = (path: readonly PropertyKey[]): string => */ const targetOf = (command: CompiledCliCommand, path: readonly PropertyKey[]): string => { if (path.length === 0) return 'input'; - if (command.mcp !== undefined) return `--input${pathSuffix(path)}`; + if (command.mcp !== undefined && command.projection === undefined) return `--input${pathSuffix(path)}`; const [head, ...rest] = path; const option = command.options.find((candidate) => candidate.key === head); if (option === undefined) return `input${pathSuffix(path)}`; @@ -269,7 +273,7 @@ export const cliInputIssueLine = (issue: CliInputIssue): string => */ export const cliInputError = ( command: CompiledCliCommand, - input: Readonly>, + input: unknown, error: unknown, ): CliInputError => { const schemaIssues = schemaIssuesOf(error); @@ -346,7 +350,7 @@ export interface RunGeneratedCliOptions { command: CompiledCliCommand, input: Readonly>, context: GeneratedCliRenderContext, - ) => GeneratedCliRenderSession; + ) => GeneratedCliRenderSession | Promise; readonly signal?: AbortSignal; /** * The terminal capability to report and select the output mode from (#511). @@ -453,6 +457,7 @@ const commandHelp = (name: string, command: CompiledCliCommand): string => { lines.push('', `MCP tool: ${command.mcp.server}:${command.mcp.tool}`); if (command.mcp.confirm) lines.push('Mutation-capable; requires --yes.'); } + if (command.projection !== undefined) lines.push(`Projection: ${command.projection.module}`); const positionals = sortedPositionals(command); if (positionals.length > 0) { lines.push('', 'Arguments:', helpColumns(positionals.map((option) => [ @@ -466,7 +471,7 @@ const commandHelp = (name: string, command: CompiledCliCommand): string => { } const options = namedOptions(command); const optionRows: (readonly [string, string])[] = options.map((option) => [ - ` --${option.option}${optionPlaceholder(option)}${option.repeated ? ' ...' : ''}`, + ` ${[option.option, ...(option.aliases ?? [])].map((spelling) => `--${spelling}`).join(', ')}${optionPlaceholder(option)}${option.repeated ? ' ...' : ''}`, [ option.description ?? '', ...(option.required ? ['(required)'] : []), @@ -569,7 +574,11 @@ const coercePositional = (option: CompiledCliOption, value: string): unknown => /** Parses one resolved command's remaining argv against its compiled option surface. */ const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): ParsedArgv => { - const options = new Map(namedOptions(command).map((option) => [option.option, option])); + const options = new Map(); + for (const option of namedOptions(command)) { + options.set(option.option, option); + for (const alias of option.aliases ?? []) options.set(alias, option); + } const positionals = sortedPositionals(command); const values = new Map(); const bare: string[] = []; @@ -667,6 +676,15 @@ const parseMcpCommandInput = ( parsed: ParsedArgv, ): ParsedArgv => { if (command.mcp === undefined) return parsed; + if (command.mcp.confirm && parsed.input['yes'] !== true) { + throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); + } + if (command.projection !== undefined) { + if (!command.mcp.confirm) return parsed; + const input = { ...parsed.input }; + delete input['yes']; + return { ...parsed, input }; + } const raw = parsed.input['input']; let input: unknown = {}; if (raw !== undefined) { @@ -679,11 +697,6 @@ const parseMcpCommandInput = ( if (typeof input !== 'object' || input === null || Array.isArray(input)) { throw new CliUsageError('--input must be a JSON object; arrays, null, and scalar values are not accepted.'); } - if (command.mcp.confirm && parsed.input['yes'] !== true) { - throw new CliUsageError( - `MCP tool ${command.mcp.server}:${command.mcp.tool} is mutation-capable per its MCP annotations and requires --yes.`, - ); - } return { ...parsed, input: input as Readonly> }; }; @@ -963,7 +976,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro : terminal.stdout.kind === 'tty' ? 'tty' : 'markdown'; - const session = options.render(command, parsed.input, { args: rest, signal, terminal }); + const session = await options.render(command, parsed.input, { args: rest, signal, terminal }); try { return await runRenderedInvocation({ exitCode: command.exitCode, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 4461eda25..1451494f5 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -239,8 +239,18 @@ const generatedCliBinEntry = ( ? routeCli.routes.filter((route) => commandRouteIds.has(route.id)) : []; const source = routes[0]?.source ?? configPath; + // A tool's projection module (#596) is a source of the bin like the route + // it projects; the graph keeps the absolute paths off its digest, so they + // travel here, not through `commands`. + const projectionSources = Object.fromEntries( + Object.entries(generatedMode ? routeCli.projectionSources ?? {} : {}).filter(([routeId]) => commandRouteIds.has(routeId)), + ); return { - generatedCli: { commands, routes }, + generatedCli: { + commands, + ...(Object.keys(projectionSources).length === 0 ? {} : { projectionSources }), + routes, + }, id: `bin:${config.plugin.name}`, name: config.plugin.name, provenance: { kind: 'conventional', sourcePath: source }, diff --git a/packages/agent-bundle/src/contracts/routes.ts b/packages/agent-bundle/src/contracts/routes.ts index bc8b60315..ca0c6af25 100644 --- a/packages/agent-bundle/src/contracts/routes.ts +++ b/packages/agent-bundle/src/contracts/routes.ts @@ -8,6 +8,7 @@ export type { RouteManifestCliCommand, RouteManifestCliMode, RouteManifestCliOption, + RouteManifestCliProjection, RouteManifestCliSurface, RouteManifestConfigEntry, RouteManifestContract, @@ -20,6 +21,7 @@ export type { RouteManifestServerMode, RouteManifestState, } from '../dev/routes/route-manifest.ts'; +export type { CliProjectionFlagDefault } from '../routes/public.ts'; export type { RouteInputArrayItemSchema, RouteInputArraySchema, diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index b402492b6..d35895def 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -537,6 +537,8 @@ export interface NormalizedBinEntry { /** The compiled routed-CLI surface a framework-generated bin executes (#102 stage 2). */ readonly generatedCli?: { readonly commands: readonly CompiledCliCommand[]; + /** routeId → absolute path of the tool's CLI projection module (#596); the bin bundles it beside the route. */ + readonly projectionSources?: Readonly>; readonly routes: readonly CompiledAgentRoute[]; }; readonly id: string; diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index b7733ac6e..30a89fa52 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -94,6 +94,7 @@ export type { RouteManifest, RouteManifestCliCommand, RouteManifestCliOption, + RouteManifestCliProjection, RouteManifestCliSurface, RouteManifestConfigEntry, RouteManifestContract, diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 28b62a9f6..5bf7e37fd 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -5,11 +5,13 @@ import { type StateDefinitionProjection, } from '../../core/state-inspection.ts'; import type { NormalizedNotices, NormalizedStateDefinition } from '../../core/types.ts'; +import type { CliProjectionFlagDefault } from '../../routes/public.ts'; import type { CompiledAgentRoute, CompiledCliCommand, CompiledCliMode, CompiledCliOption, + CompiledCliProjection, CompiledCliSurface, CompiledProvider, CompiledRouteGraph, @@ -94,6 +96,7 @@ export interface RouteManifestServer { /** One argv projection of a CLI route's input schema, without editor defaults. */ export interface RouteManifestCliOption { + readonly aliases?: readonly string[]; readonly choices?: readonly string[]; readonly description?: string; readonly key: string; @@ -104,6 +107,15 @@ export interface RouteManifestCliOption { readonly required: boolean; } +/** Mirrors {@link CompiledCliProjection}: the explicit CLI surface projection of one tool. */ +export interface RouteManifestCliProjection { + /** Canonical key → the projection's `flags..default` literal (schema defaults are not listed); keys sorted. */ + readonly defaults?: Readonly>; + readonly mapInput: boolean; + readonly module: string; + readonly relaxed?: readonly string[]; +} + /** One executable command compiled from a custom CLI route or projected MCP tool. */ export interface RouteManifestCliCommand { readonly aliases: readonly string[]; @@ -112,6 +124,7 @@ export interface RouteManifestCliCommand { readonly mcp?: NonNullable; readonly options: readonly RouteManifestCliOption[]; readonly path: readonly string[]; + readonly projection?: RouteManifestCliProjection; readonly routeId: string; } @@ -221,6 +234,7 @@ const manifestServer = (server: CompiledServerSurface): RouteManifestServer => ( }); const manifestCliOption = (option: CompiledCliOption): RouteManifestCliOption => ({ + ...(option.aliases === undefined ? {} : { aliases: [...option.aliases] }), ...(option.choices === undefined ? {} : { choices: [...option.choices] }), ...(option.description === undefined ? {} : { description: option.description }), key: option.key, @@ -231,6 +245,18 @@ const manifestCliOption = (option: CompiledCliOption): RouteManifestCliOption => required: option.required, }); +const manifestCliProjection = (projection: CompiledCliProjection): RouteManifestCliProjection => ({ + ...(projection.defaults === undefined + ? {} + : { + defaults: Object.fromEntries(Object.entries(projection.defaults) + .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value])), + }), + mapInput: projection.mapInput, + module: projection.module, + ...(projection.relaxed === undefined ? {} : { relaxed: [...projection.relaxed] }), +}); + const manifestCliCommand = (command: CompiledCliCommand): RouteManifestCliCommand => ({ aliases: [...command.aliases], ...(command.description === undefined ? {} : { description: command.description }), @@ -238,6 +264,7 @@ const manifestCliCommand = (command: CompiledCliCommand): RouteManifestCliComman ...(command.mcp === undefined ? {} : { mcp: { ...command.mcp } }), options: command.options.map(manifestCliOption), path: [...command.path], + ...(command.projection === undefined ? {} : { projection: manifestCliProjection(command.projection) }), routeId: command.routeId, }); diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index 4ec278213..73eee697e 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -9,6 +9,7 @@ import { type ScalarBase, type StaticInputSchemaProperty, } from './input-schema.ts'; +import type { CliProjectionFlagDefault } from './public.ts'; import type { CompiledCliOption, RouteInputArrayItemSchema, @@ -39,15 +40,61 @@ export const reservedCliOptionNames: ReadonlySet = Object.freeze(new Set * `inputSchema` export. `found` is false when the module has no extractable * `export const inputSchema` declaration (the route-contract diagnostic owns * that state); `options` is absent whenever a diagnostic fired; `origin` is - * where the schema is declared whenever that is known. + * where the schema is declared whenever that is known; `relaxed` is the + * projection's, as `ProjectedCliOptions` documents. */ -export interface ExtractedCliArgv { - readonly diagnostics: readonly Diagnostic[]; +export interface ExtractedCliArgv extends ProjectedCliOptions { readonly found: boolean; - readonly options?: readonly CompiledCliOption[]; readonly origin?: ResolvedSchemaOrigin; } +/** + * How a CLI projection module (`.cli.{ts,tsx}`, #596) respells one + * canonical key on argv: the validated `flags.` entry, applied inside + * the one option policy so the kebab-case, reserved-name, and collision rules + * judge the final spellings. + */ +export interface CliOptionOverride { + readonly aliases?: readonly string[]; + readonly default?: CliProjectionFlagDefault; + readonly description?: string; + readonly name?: string; + readonly required?: false; +} + +/** + * Why one canonical key cannot appear on a command at all, whatever it is + * spelled: the shell keys parsed values by canonical key, so a key it owns + * (`yes` on a confirming command, which the shell reads and strips) is + * unreachable by the route even under a `name` override. Reported through + * `CliOptionPolicy.overrideError`; `detail` continues the message subject + * without a final period. + */ +export interface CliReservedKey { + readonly detail: string; + readonly recovery?: string; +} + +/** + * What one caller adds to the default argv policy. `label` names the schema's + * owner in AB4814/AB4838/AB4839 messages (`CLI route ` when absent); a + * projected tool relabels them so the tool module, not a CLI route, is named. + * `overrides` are the projection's per-key `flags`; a failure they cause — + * a spelling that is not kebab-case, reserved, or claimed twice, a default + * outside the key's kind — is reported through `overrideError`, whose detail + * continues `flags....`, instead of as a grammar error of the schema. + * `reserved` extends the shell-owned spellings (`yes` for a confirming + * command); `reservedKeys` names canonical keys the shell owns outright, each + * with the detail `overrideError` reports for it. + */ +export interface CliOptionPolicy { + readonly label?: string; + readonly overrideError?: (detail: string, recovery?: string) => Diagnostic; + readonly overrides?: Readonly>; + readonly reserved?: readonly string[]; + readonly reservedKeys?: Readonly>; +} + const grammarRecovery = `Restrict the inputSchema initializer to the bounded argv grammar (${cliArgvGrammar}), then inspect again.`; const argvError = (message: string, sourcePath: string): Diagnostic => ({ @@ -60,18 +107,31 @@ const argvError = (message: string, sourcePath: string): Diagnostic => ({ const resolutionRecovery = 'Declare the schema inline, or reference a top-level `export const` of a module reached through relative imports inside the project (alias chains such as `export const inputSchema = shared` are followed); then inspect again.'; +const defaultLabel = (relativePath: string): string => `CLI route ${relativePath}`; + +/** + * The parser (input-schema.ts) words every grammar issue for the CLI route + * it was written for, `CLI route ...`; a caller projecting + * another owner's schema (a tool route with a CLI projection) reads the same + * issue under its own label. + */ +const relabelIssue = (issue: string, relativePath: string, label: string): string => { + const prefix = defaultLabel(relativePath); + return label !== prefix && issue.startsWith(prefix) ? `${label}${issue.slice(prefix.length)}` : issue; +}; + /** AB4838 for a reference the static resolver cannot follow; AB4839 for a reference cycle. */ const resolutionError = ( failure: InputSchemaResolutionFailure, - relativePath: string, + label: string, sourcePath: string, ): Diagnostic => { const chain = failure.chain.join(' -> '); return { code: failure.kind === 'cycle' ? 'AB4839' : 'AB4838', message: failure.kind === 'cycle' - ? `CLI route ${relativePath} inputSchema: ${chain} is a reference cycle.` - : `CLI route ${relativePath} inputSchema: ${chain} ${failure.reason}.`, + ? `${label} inputSchema: ${chain} is a reference cycle.` + : `${label} inputSchema: ${chain} ${failure.reason}.`, recovery: resolutionRecovery, severity: 'error', sourcePath, @@ -83,51 +143,146 @@ const optionNameOf = (key: string): string => key .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1-$2') .toLowerCase(); -interface CliPropertyProjection { - readonly diagnostic?: Diagnostic; - readonly option?: CompiledCliOption; +const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +type CliPropertyProjection = + | { readonly diagnostic: Diagnostic } + | { + readonly option: CompiledCliOption; + /** True when a projection override made a canonical-required key optional on argv. */ + readonly relaxed?: true; + }; + +/** The resolved policy one projection runs under: the label, the reserved set, and the override reporter. */ +interface ResolvedCliOptionPolicy { + readonly label: string; + readonly overrideError: (detail: string, recovery?: string) => Diagnostic; + readonly overrides: Readonly>; + readonly reserved: ReadonlySet; + readonly reservedKeys: Readonly>; + readonly sourcePath: string; } -/** The argv policy for one schema property: flag rule, kebab-case naming, and reserved names. */ +const resolvePolicy = (policy: CliOptionPolicy, relativePath: string, sourcePath: string): ResolvedCliOptionPolicy => { + const label = policy.label ?? defaultLabel(relativePath); + return { + label, + // Without a reporter an override failure is a grammar error of the owner, + // which is what a caller passing overrides without one would read anyway. + overrideError: policy.overrideError ?? ((detail) => argvError(`${label} ${detail}.`, sourcePath)), + overrides: policy.overrides ?? {}, + reserved: new Set([...reservedCliOptionNames, ...(policy.reserved ?? [])]), + reservedKeys: policy.reservedKeys ?? {}, + sourcePath, + }; +}; + +const describeKind = (base: ScalarBase): string => + base.kind === 'enum' ? `one of ${(base.choices ?? []).map((choice) => JSON.stringify(choice)).join(', ')}` : base.kind; + +const matchesKind = (base: ScalarBase, value: unknown): boolean => { + switch (base.kind) { + case 'boolean': + return typeof value === 'boolean'; + case 'number': + return typeof value === 'number'; + case 'string': + return typeof value === 'string'; + case 'enum': + return typeof value === 'string' && (base.choices ?? []).includes(value); + default: { + const unreachable: never = base.kind; + throw new TypeError(`Unhandled scalar base ${String(unreachable)}.`); + } + } +}; + +/** + * The argv policy for one schema property: reserved keys, the flag rule, + * kebab-case naming, and reserved names, applied to the final spelling — a + * projection's `name` and `aliases` included — and the projection's + * `default` judged against the key's kind. + */ const cliOptionFor = ( property: StaticInputSchemaProperty, - relativePath: string, - sourcePath: string, + policy: ResolvedCliOptionPolicy, ): CliPropertyProjection => { - const required = !property.optional && !property.hasDefault; + const { key } = property; + // A key the shell owns is judged before any spelling: no `name` reaches it. + const reservedKey = policy.reservedKeys[key]; + if (reservedKey !== undefined) { + return { diagnostic: policy.overrideError(reservedKey.detail, reservedKey.recovery) }; + } + const override = policy.overrides[key] ?? {}; + const canonicallyRequired = !property.optional && !property.hasDefault; + const relaxed = canonicallyRequired && (override.required === false || override.default !== undefined); + const required = canonicallyRequired && !relaxed; if (property.base.kind === 'boolean' && required) { return { diagnostic: argvError( - `CLI route ${relativePath} property ${JSON.stringify(property.key)}: a required boolean cannot be expressed as a flag; add .optional() or .default(false).`, - sourcePath, + `${policy.label} property ${JSON.stringify(key)}: a required boolean cannot be expressed as a flag; add .optional() or .default(false).`, + policy.sourcePath, ), }; } - const option = optionNameOf(property.key); - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(option)) { + const option = override.name ?? optionNameOf(key); + if (!kebabCase.test(option)) { return { - diagnostic: argvError( - `CLI route ${relativePath} property ${JSON.stringify(property.key)} does not project onto a kebab-case option name.`, - sourcePath, - ), + diagnostic: override.name === undefined + ? argvError( + `${policy.label} property ${JSON.stringify(key)} does not project onto a kebab-case option name.`, + policy.sourcePath, + ) + : policy.overrideError(`flags.${key}.name ${JSON.stringify(option)} is not a kebab-case option name`), }; } - if (reservedCliOptionNames.has(option)) { + if (policy.reserved.has(option)) { return { - diagnostic: argvError( - `CLI route ${relativePath} property ${JSON.stringify(property.key)} projects onto the reserved option --${option}.`, - sourcePath, - ), + diagnostic: override.name === undefined + ? argvError( + `${policy.label} property ${JSON.stringify(key)} projects onto the reserved option --${option}.`, + policy.sourcePath, + ) + : policy.overrideError(`flags.${key}.name ${JSON.stringify(option)} is the reserved option --${option}`), }; } + const aliases = override.aliases ?? []; + for (const [index, alias] of aliases.entries()) { + if (!kebabCase.test(alias)) { + return { diagnostic: policy.overrideError(`flags.${key}.aliases entry ${JSON.stringify(alias)} is not a kebab-case option name`) }; + } + if (policy.reserved.has(alias)) { + return { diagnostic: policy.overrideError(`flags.${key}.aliases entry ${JSON.stringify(alias)} is the reserved option --${alias}`) }; + } + if (alias === option || aliases.indexOf(alias) !== index) { + return { diagnostic: policy.overrideError(`flags.${key}.aliases repeats the spelling --${alias}`) }; + } + } + + if (override.default !== undefined) { + const values = Array.isArray(override.default) ? override.default : [override.default]; + const shape = property.repeated ? `an array of ${describeKind(property.base)}` : describeKind(property.base); + if (property.repeated !== Array.isArray(override.default) || !values.every((value) => matchesKind(property.base, value))) { + return { diagnostic: policy.overrideError(`flags.${key}.default ${JSON.stringify(override.default)} is not ${shape}`) }; + } + } + const description = override.description ?? property.description; return { + ...(relaxed ? { relaxed } : {}), option: { + ...(aliases.length === 0 ? {} : { aliases }), ...(property.base.choices === undefined ? {} : { choices: property.base.choices }), - ...(property.hasDefault ? { defaultValue: property.defaultValue } : {}), - ...(property.description === undefined ? {} : { description: property.description }), - key: property.key, + // Help shows the effective default; only the projection's own default + // is also recorded in `defaults` for the shell to apply. + ...(override.default !== undefined + ? { defaultValue: override.default } + : property.hasDefault + ? { defaultValue: property.defaultValue } + : {}), + ...(description === undefined ? {} : { description }), + key, kind: property.base.kind, option, repeated: property.repeated, @@ -136,47 +291,87 @@ const cliOptionFor = ( }; }; -/** The option surface one schema projects onto; `options` is absent whenever a diagnostic fired. */ +/** + * The option surface one schema projects onto; `options` is absent whenever a + * diagnostic fired. `defaults` maps, keys sorted, each canonical key whose + * projection override declared a CLI `default` to that literal — the + * schema's own `.default()` values are not in it; absent when no override + * did. `relaxed` lists, sorted, the canonical-required keys a projection + * override (`required: false` or a CLI `default`) made optional on argv; + * absent when none was. + */ export interface ProjectedCliOptions { + readonly defaults?: Readonly>; readonly diagnostics: readonly Diagnostic[]; readonly options?: readonly CompiledCliOption[]; + readonly relaxed?: readonly string[]; +} + +interface SpellingClaim { + readonly key: string; + readonly overridden: boolean; } -/** The one argv projection policy: per-property rules, then option-name collisions, then deterministic order. */ const projectOptions = ( entries: readonly ParsedInputSchemaEntry[], - relativePath: string, - sourcePath: string, + policy: ResolvedCliOptionPolicy, ): ProjectedCliOptions => { + const defaults: Record = {}; const diagnostics: Diagnostic[] = []; const options: CompiledCliOption[] = []; - const seenOptions = new Map(); + const relaxed: string[] = []; + const seenSpellings = new Map(); for (const entry of entries) { if ('issue' in entry) { - diagnostics.push(argvError(entry.issue, sourcePath)); + diagnostics.push(argvError(entry.issue, policy.sourcePath)); continue; } - const projected = cliOptionFor(entry.property, relativePath, sourcePath); - if (projected.diagnostic !== undefined) { + const projected = cliOptionFor(entry.property, policy); + if ('diagnostic' in projected) { diagnostics.push(projected.diagnostic); continue; } - const option = projected.option!; - const claimed = seenOptions.get(option.option); - if (claimed !== undefined) { - diagnostics.push(argvError( - `CLI route ${relativePath} properties ${JSON.stringify(claimed)} and ${JSON.stringify(option.key)} both project onto --${option.option}.`, - sourcePath, - )); + if (projected.relaxed === true) relaxed.push(entry.property.key); + const option = projected.option; + const override = policy.overrides[option.key] ?? {}; + if (override.default !== undefined) defaults[option.key] = override.default; + const spellings = [option.option, ...(option.aliases ?? [])]; + const collision = spellings.flatMap((spelling, index) => { + const claimed = seenSpellings.get(spelling); + return claimed === undefined + ? [] + : [{ + claim: { key: option.key, overridden: index > 0 || override.name !== undefined }, + claimed, + spelling, + }]; + })[0]; + if (collision !== undefined) { + const { claim, claimed, spelling } = collision; + diagnostics.push(claim.overridden || claimed.overridden + ? policy.overrideError( + `flags spell --${spelling} for both ${JSON.stringify(claimed.key)} and ${JSON.stringify(claim.key)}; two options collide on one spelling`, + ) + : argvError( + `${policy.label} properties ${JSON.stringify(claimed.key)} and ${JSON.stringify(claim.key)} both project onto --${spelling}.`, + policy.sourcePath, + )); continue; } - seenOptions.set(option.option, option.key); + for (const [index, spelling] of spellings.entries()) { + seenSpellings.set(spelling, { key: option.key, overridden: index > 0 || override.name !== undefined }); + } options.push(option); } if (diagnostics.length > 0) return { diagnostics }; + const sortedDefaults = Object.fromEntries( + Object.entries(defaults).sort(([left], [right]) => left.localeCompare(right)), + ); return { + ...(Object.keys(sortedDefaults).length === 0 ? {} : { defaults: sortedDefaults }), diagnostics: [], options: [...options].sort((left, right) => left.option.localeCompare(right.option)), + ...(relaxed.length === 0 ? {} : { relaxed: [...relaxed].sort((left, right) => left.localeCompare(right)) }), }; }; @@ -185,7 +380,6 @@ const scalarBaseOfSchema = (schema: RouteInputArrayItemSchema): ScalarBase => ? { choices: schema.enum, kind: 'enum' } : { kind: schema.type }; -/** One canonical contract property in the shape the module parse produces, so both take the same policy. */ const staticPropertyOf = ( key: string, schema: RouteInputPropertySchema, @@ -211,40 +405,50 @@ export const projectInputSchemaOptions = ( schema: RouteInputSchema, relativePath: string, sourcePath: string, + policy: CliOptionPolicy = {}, ): ProjectedCliOptions => deepFreeze(projectOptions( Object.entries(schema.properties).map(([key, property]) => ({ property: staticPropertyOf(key, property, schema.required ?? []), })), - relativePath, - sourcePath, + resolvePolicy(policy, relativePath, sourcePath), )); +/** Where the module's `inputSchema` references resolve, plus the option policy its owner runs under. */ +export interface ExtractCliArgvOptions extends InputSchemaExtractionOptions { + readonly policy?: CliOptionPolicy; +} + /** * Statically projects one CLI route module's `export const inputSchema` * declaration onto the argv contract. The module is parsed with the * TypeScript compiler and never executed; validation-only refinements pass * through uninterpreted because the real zod schema validates at run time. A * schema reached through a reference the resolver cannot follow is AB4838 - * (AB4839 for a cycle); grammar issues stay AB4814. + * (AB4839 for a cycle); grammar issues stay AB4814. A tool route with a CLI + * projection is parsed the same way, under its own `policy.label`. */ export const extractCliArgv = ( moduleText: string, relativePath: string, sourcePath: string, - options: InputSchemaExtractionOptions = {}, + options: ExtractCliArgvOptions = {}, ): ExtractedCliArgv => { - const parsed = parseInputSchema(moduleText, relativePath, options); + const { policy = {}, ...extraction } = options; + const resolved = resolvePolicy(policy, relativePath, sourcePath); + const parsed = parseInputSchema(moduleText, relativePath, extraction); if (!parsed.found) return deepFreeze({ diagnostics: [], found: false }); const origin = parsed.origin === undefined ? {} : { origin: parsed.origin }; if (parsed.entries === undefined) { return deepFreeze({ diagnostics: [ - ...parsed.issues.map((issue) => argvError(issue, sourcePath)), - ...(parsed.resolution === undefined ? [] : [resolutionError(parsed.resolution, relativePath, sourcePath)]), + ...parsed.issues.map((issue) => argvError(relabelIssue(issue, relativePath, resolved.label), sourcePath)), + ...(parsed.resolution === undefined ? [] : [resolutionError(parsed.resolution, resolved.label, sourcePath)]), ], found: true, ...origin, }); } - return deepFreeze({ ...projectOptions(parsed.entries, relativePath, sourcePath), found: true, ...origin }); + const entries = parsed.entries.map((entry) => + 'issue' in entry ? { issue: relabelIssue(entry.issue, relativePath, resolved.label) } : entry); + return deepFreeze({ ...projectOptions(entries, resolved), found: true, ...origin }); }; diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index 0f2633a76..f1bc08a5d 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -1,17 +1,36 @@ import { extname } from 'node:path'; -import { extractCliArgv, projectInputSchemaOptions, type ExtractedCliArgv } from './cli-argv.ts'; +import { + extractCliArgv, + projectInputSchemaOptions, + type CliOptionOverride, + type CliOptionPolicy, + type CliReservedKey, + type ExtractedCliArgv, +} from './cli-argv.ts'; +import { + cliProjectionBindingError, + cliProjectionContractError, + extractCliProjection, + inputKeysRecovery, + relaxationRecovery, + relaxationWithoutMapInputDetail, + stringArray, + unknownInputKeyDetail, + type CliProjectionModule, +} from './cli-projection.ts'; import { scanRouteModuleExports } from './contract.ts'; import { mcpRouteProtocolName } from './protocol-name.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isRecord } from '../core/strict-json.ts'; import { routeRenderLimits, validateRouteRenderConfig, type RouteRenderBudget } from './render-budget.ts'; -import type { - CompiledAgentRoute, - CompiledCliCommand, - CompiledCliOption, - CompiledServerSurface, +import { + safeIdentitySegment, + type CompiledAgentRoute, + type CompiledCliCommand, + type CompiledCliOption, + type CompiledServerSurface, } from './types.ts'; /** @@ -36,8 +55,6 @@ export const isRenderedCliRoute = (route: CompiledAgentRoute): boolean => export const cliCommandPath = (route: CompiledAgentRoute): readonly string[] => route.id.slice('cli:'.length).split('/'); -const safeIdentitySegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; - const collisionError = (message: string, sourcePath: string): Diagnostic => ({ code: 'AB4813', message, @@ -46,22 +63,39 @@ const collisionError = (message: string, sourcePath: string): Diagnostic => ({ sourcePath, }); +/** The MCP provenance of one claimed command path: the bulk projection's tool, or a tool's projection module. */ +interface McpPathProvenance { + readonly identity: string; + /** Project-relative path of the `.cli.{ts,tsx}` module; absent for the bulk `routes.mcpCommands` projection. */ + readonly projection?: string; +} + +/** + * AB4813 for a projected MCP command: the bulk projection is fixed in config + * (`routes.mcpCommands.exclude`), an explicit projection in its module + * (`command`, or removing the module). + */ const mcpCollisionError = ( - identity: string, + provenance: McpPathProvenance, message: string, sourcePath: string, ): Diagnostic => ({ code: 'AB4813', - message: `Projected MCP tool ${JSON.stringify(identity)} ${message}`, - recovery: `Exclude ${JSON.stringify(identity)} with routes.mcpCommands.exclude, or rename the colliding custom CLI route or alias.`, + message: provenance.projection === undefined + ? `Projected MCP tool ${JSON.stringify(provenance.identity)} ${message}` + : `CLI projection ${provenance.projection} of MCP tool ${JSON.stringify(provenance.identity)} ${message}`, + recovery: provenance.projection === undefined + ? `Exclude ${JSON.stringify(provenance.identity)} with routes.mcpCommands.exclude, or rename the colliding custom CLI route or alias.` + : `Change command (or aliases) in ${provenance.projection} or remove the module, or rename the colliding custom CLI route or alias; then inspect again.`, severity: 'error', sourcePath, }); -const mcpSelectionError = (message: string): Diagnostic => ({ +const mcpSelectionError = (message: string, recovery?: string): Diagnostic => ({ code: 'AB4822', message, - recovery: 'Correct the routes.mcpCommands include/exclude patterns using one of the listed generated tool identities, then inspect again.', + recovery: recovery + ?? 'Correct the routes.mcpCommands include/exclude patterns using one of the listed generated tool identities, then inspect again.', severity: 'error', }); @@ -90,11 +124,6 @@ interface RouteCliConfig { readonly render?: RouteRenderBudget; } -const stringArray = (value: unknown): readonly string[] | undefined => - Array.isArray(value) && value.every((item): item is string => typeof item === 'string') - ? value - : undefined; - /** Interprets the statically extracted route config's CLI-owned fields. */ const routeCliConfig = (route: CompiledAgentRoute): RouteCliConfig => { const relativePath = route.provenance.relativePath; @@ -158,43 +187,37 @@ const routeCliConfig = (route: CompiledAgentRoute): RouteCliConfig => { }; }; -/** Applies `config.positionals` onto the extracted option surface, in declared order. */ +/** + * Applies `config.positionals` onto the extracted option surface, in declared + * order. `report` words one rule violation for the declaring module: a CLI + * route's own AB4814, or a projection module's AB4845 (#596); the detail it + * receives continues `config.positionals ...` without a final period. + */ const applyPositionals = ( options: readonly CompiledCliOption[], positionals: readonly string[], - relativePath: string, - sourcePath: string, + report: (detail: string) => Diagnostic, ): { readonly diagnostics: readonly Diagnostic[]; readonly options?: readonly CompiledCliOption[] } => { const diagnostics: Diagnostic[] = []; const byKey = new Map(options.map((option) => [option.key, option])); const indexOfKey = new Map(); for (const [index, key] of positionals.entries()) { if (indexOfKey.has(key)) { - diagnostics.push(positionalsError( - `CLI route ${relativePath} config.positionals names ${JSON.stringify(key)} twice.`, - sourcePath, - )); + diagnostics.push(report(`config.positionals names ${JSON.stringify(key)} twice`)); continue; } const option = byKey.get(key); if (option === undefined) { - diagnostics.push(positionalsError( - `CLI route ${relativePath} config.positionals names ${JSON.stringify(key)}, which is not a projected inputSchema key.`, - sourcePath, - )); + diagnostics.push(report(`config.positionals names ${JSON.stringify(key)}, which is not a projected inputSchema key`)); continue; } if (option.kind === 'boolean') { - diagnostics.push(positionalsError( - `CLI route ${relativePath} config.positionals names the boolean key ${JSON.stringify(key)}; flags cannot be positional.`, - sourcePath, - )); + diagnostics.push(report(`config.positionals names the boolean key ${JSON.stringify(key)}; flags cannot be positional`)); continue; } if (option.repeated && index !== positionals.length - 1) { - diagnostics.push(positionalsError( - `CLI route ${relativePath} config.positionals places the array key ${JSON.stringify(key)} before the end; only the last positional may be variadic.`, - sourcePath, + diagnostics.push(report( + `config.positionals places the array key ${JSON.stringify(key)} before the end; only the last positional may be variadic`, )); continue; } @@ -206,10 +229,7 @@ const applyPositionals = ( if (option === undefined || !indexOfKey.has(key)) continue; if (!option.required && !option.repeated) sawOptionalPositional = true; else if (option.required && sawOptionalPositional) { - diagnostics.push(positionalsError( - `CLI route ${relativePath} config.positionals places the required key ${JSON.stringify(key)} after an optional one.`, - sourcePath, - )); + diagnostics.push(report(`config.positionals places the required key ${JSON.stringify(key)} after an optional one`)); } } if (diagnostics.length > 0) return { diagnostics }; @@ -267,16 +287,24 @@ const confirmationOption: CompiledCliOption = Object.freeze({ required: false, }); +/** One generated tool with its own `.cli.{ts,tsx}` module: `identity` → project-relative module path. */ +interface ProjectedMcpTool extends EligibleMcpTool { + readonly projection: string; +} + /** * Projects selected tools from generated MCP servers into rendered CLI * commands. This in-house projection intentionally uses the compiled route * graph directly (G7); MCPorter remains an independent live-server client. + * `projections` maps a tool route id to its projection module (#596): such a + * tool has one command, the projection's, and leaves the eligible set here. */ export const compileMcpCliCommands = ( servers: readonly CompiledServerSurface[], selection: McpCommandSelection, + projections: ReadonlyMap = new Map(), ): CompiledMcpCliCommandSurface => { - const eligible: EligibleMcpTool[] = servers + const generatedTools: EligibleMcpTool[] = servers .filter((server) => server.mode === 'generated') .flatMap((server) => server.routes .filter((route) => route.kind === 'tool') @@ -285,12 +313,30 @@ export const compileMcpCliCommands = ( return { identity: `${server.name}:${tool}`, route, server: server.name, tool }; })) .sort((left, right) => left.identity.localeCompare(right.identity)); + const eligible = generatedTools.filter((tool) => !projections.has(tool.route.id)); + const projected: ProjectedMcpTool[] = generatedTools + .flatMap((tool) => { + const projection = projections.get(tool.route.id); + return projection === undefined ? [] : [{ ...tool, projection }]; + }); const available = eligible.length === 0 ? 'No generated MCP tools are available.' : `Available generated MCP tools: ${eligible.map((tool) => tool.identity).join(', ')}.`; const diagnostics: Diagnostic[] = []; let selected: EligibleMcpTool[]; + // An include pattern that reaches only tools with projection modules names + // them: the pattern is not wrong about the tools, only about who projects + // them. + const onlyProjected = (pattern: string, expression: RegExp): Diagnostic | undefined => { + const matches = projected.filter((tool) => expression.test(tool.identity)); + if (matches.length === 0) return undefined; + return mcpSelectionError( + `routes.mcpCommands.include pattern ${JSON.stringify(pattern)} matches only tools with their own CLI projection modules (${matches.map((tool) => `${tool.identity} via ${tool.projection}`).join(', ')}); a projected tool leaves the bulk projection. ${available}`, + 'Drop the pattern (the projection module already compiles the command), or remove the projection module to project the tool in bulk; then inspect again.', + ); + }; + if (selection.include === undefined) { selected = [...eligible]; } else if (selection.include.length === 0) { @@ -304,7 +350,7 @@ export const compileMcpCliCommands = ( const expression = patternExpression(pattern); const matches = eligible.filter((tool) => expression.test(tool.identity)); if (matches.length === 0) { - diagnostics.push(mcpSelectionError( + diagnostics.push(onlyProjected(pattern, expression) ?? mcpSelectionError( `routes.mcpCommands.include pattern ${JSON.stringify(pattern)} matches no eligible tool. ${available}`, )); } @@ -316,7 +362,10 @@ export const compileMcpCliCommands = ( for (const pattern of selection.exclude ?? []) { const expression = patternExpression(pattern); const matches = eligible.filter((tool) => expression.test(tool.identity)); - if (matches.length === 0) { + // Excluding a tool that already left the bulk projection through its + // projection module asks for what is the case; only a pattern that + // reaches no generated tool at all is a mistake. + if (matches.length === 0 && !projected.some((tool) => expression.test(tool.identity))) { diagnostics.push(mcpSelectionError( `routes.mcpCommands.exclude pattern ${JSON.stringify(pattern)} matches no eligible tool. ${available}`, )); @@ -361,37 +410,213 @@ export interface CompileCliCommandsOptions { * the graph bound one (`route.inputSchema` is the contract's normalized * `input`), so the command grammar and the route's declared input are one * object; otherwise the module is parsed again, which is what reports why no - * contract exists (AB4814, AB4838, AB4839). + * contract exists (AB4814, AB4838, AB4839). `policy` is the projection + * module's respelling of the keys and the label its diagnostics name (#596). */ const routeArgv = ( route: CompiledAgentRoute, moduleText: string, options: CompileCliCommandsOptions, + policy: CliOptionPolicy = {}, ): ExtractedCliArgv => { const relativePath = route.provenance.relativePath; if (route.inputSchema !== undefined) { - return { ...projectInputSchemaOptions(route.inputSchema, relativePath, route.source), found: true }; + return { ...projectInputSchemaOptions(route.inputSchema, relativePath, route.source, policy), found: true }; } return extractCliArgv(moduleText, relativePath, route.source, { + policy, ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), source: route.source, }); }; +/** + * One tool route of a generated server paired with the `.cli.{ts,tsx}` + * module beside it (#596). `toolText` is the tool module's own source, read + * by the graph; it is parsed again only when the tool has no static + * contract, so the reason (AB4814/AB4838/AB4839) is reported under the + * tool's label. + */ +export interface CliProjectionPair { + readonly module: CliProjectionModule; + readonly moduleText: string; + /** Project-relative POSIX path of the projection module. */ + readonly relativePath: string; + /** Absolute path of the projection module. */ + readonly source: string; + readonly tool: CompiledAgentRoute; + readonly toolText?: string; +} + +/** The commands the projection modules compile, the tool routes behind them, and where each module lives. */ +export interface CompiledProjectedCliCommandSurface extends CompiledMcpCliCommandSurface { + /** Tool route id → absolute path of its projection module; build-side, never digested. */ + readonly projectionSources: Readonly>; +} + +const positionalsRecovery = 'Name existing scalar inputSchema keys in argument order; only the last positional may be an array. Then inspect again.'; + +/** + * AB4845 on a confirming projection whose tool contract has a key `yes`: the + * shell keys parsed values by canonical key and reads and strips `yes` as + * the confirmation, so the tool could never receive it — under any `name`. + */ +const confirmationKeyReservation: CliReservedKey = { + detail: 'the tool\'s inputSchema has a key "yes", which a confirming command reserves for its --yes confirmation and strips before the tool runs, whatever the key is spelled; set confirm: false or rename the key', + recovery: 'Declare confirm: false in the projection, or rename the tool\'s "yes" input key; then inspect again.', +}; + +/** + * Compiles each tool's explicit CLI surface (#596): the projection module's + * `config` respells the tool's canonical input onto argv under the one + * option policy CLI routes use, so kebab-case, reserved-name, and collision + * rules judge the final spellings and aliases. A pair whose module or + * binding fails compiles no command; the diagnostics name the module. The + * command runs the tool (`routeId`, `mcp`) with the projected grammar + * (`projection`), never `--input`. + */ +export const compileProjectedCliCommands = ( + pairs: readonly CliProjectionPair[], + compileOptions: CompileCliCommandsOptions = {}, +): CompiledProjectedCliCommandSurface => { + const diagnostics: Diagnostic[] = []; + const commands: CompiledCliCommand[] = []; + const routes: CompiledAgentRoute[] = []; + const projectionSources: Record = {}; + + for (const pair of [...pairs].sort((left, right) => left.tool.id.localeCompare(right.tool.id))) { + const { module, relativePath, source, tool } = pair; + const binding = (detail: string, recovery?: string): Diagnostic => + cliProjectionBindingError(relativePath, tool.id, detail, source, recovery); + const extracted = extractCliProjection(pair.moduleText, relativePath, source, tool.inputSchema, tool, { + ...(compileOptions.projectRoot === undefined ? {} : { projectRoot: compileOptions.projectRoot }), + }); + diagnostics.push(...extracted.diagnostics); + if (extracted.diagnostics.length > 0) continue; + const { config } = extracted; + + const annotations = tool.config['annotations']; + const confirm = config.confirm ?? !(isRecord(annotations) && annotations.readOnlyHint === true); + const aliases = config.aliases ?? []; + let aliasesValid = true; + for (const [index, alias] of aliases.entries()) { + if (!safeIdentitySegment.test(alias)) { + diagnostics.push(binding( + `config.aliases[${index}] ${JSON.stringify(alias)} is not a safe identity segment`, + 'Use command aliases of letters, digits, and inner ".", "_", "-" only, then inspect again.', + )); + aliasesValid = false; + } else if (aliases.indexOf(alias) !== index) { + diagnostics.push(binding( + `config.aliases declares ${JSON.stringify(alias)} twice`, + 'Declare each command alias once, then inspect again.', + )); + aliasesValid = false; + } + } + if (!aliasesValid) continue; + + // The tool text is absent only when the graph's read raced a deletion; + // the next source snapshot settles it, as for a CLI route. + if (tool.inputSchema === undefined && pair.toolText === undefined) continue; + const overrides: Record = {}; + for (const [key, flag] of Object.entries(config.flags ?? {})) overrides[key] = flag; + const argv = routeArgv(tool, pair.toolText ?? '', compileOptions, { + label: `Tool route ${tool.provenance.relativePath} (CLI projection ${relativePath})`, + overrideError: (detail, recovery) => binding(detail, recovery), + overrides, + ...(confirm ? { reserved: ['yes'], reservedKeys: { yes: confirmationKeyReservation } } : {}), + }); + // A tool without an extractable inputSchema is judged by its server's + // contract diagnostics; the projection has nothing to bind until then. + if (!argv.found) continue; + diagnostics.push(...argv.diagnostics); + if (argv.options === undefined) continue; + let options = argv.options; + + // Without a canonical contract the binding checks ran against nothing + // in extractCliProjection; the parsed schema is the contract here. + if (tool.inputSchema === undefined) { + const keys = new Set(options.map((option) => option.key)); + const keyRecovery = inputKeysRecovery([...keys]); + let bound = true; + for (const [key, flag] of Object.entries(config.flags ?? {})) { + if (!keys.has(key)) { + diagnostics.push(binding(unknownInputKeyDetail('flags', key), keyRecovery)); + bound = false; + } + if (!extracted.mapInput && argv.relaxed?.includes(key) === true) { + diagnostics.push(cliProjectionContractError( + relativePath, + tool.id, + relaxationWithoutMapInputDetail(key, flag), + source, + relaxationRecovery(key), + )); + bound = false; + } + } + if (!bound) continue; + } + + if (config.positionals !== undefined) { + const positioned = applyPositionals(options, config.positionals, (detail) => binding(detail, positionalsRecovery)); + diagnostics.push(...positioned.diagnostics); + if (positioned.options === undefined) continue; + options = positioned.options; + } + // A confirming command takes --yes like the bulk projection does; the + // spelling was reserved above, so no key of the schema claims it. + if (confirm) options = [...options, confirmationOption]; + + const description = config.description ?? tool.config['description']; + // The tool's own render budget was validated with its server (AB4835 is + // reported once, there); the projected command inherits the value. + const render = routeRenderLimits(tool.config); + routes.push(tool); + projectionSources[tool.id] = source; + commands.push({ + aliases, + ...(typeof description === 'string' ? { description } : {}), + exitCode: config.exitCode ?? (tool.config['exitCode'] === 'result' ? 'result' : 'zero'), + mcp: { confirm, server: module.server, tool: module.stem }, + options, + path: config.command ?? [module.stem], + projection: { + ...(argv.defaults === undefined ? {} : { defaults: argv.defaults }), + mapInput: extracted.mapInput, + module: relativePath, + ...(argv.relaxed === undefined ? {} : { relaxed: argv.relaxed }), + }, + ...(render === undefined ? {} : { render }), + rendered: true, + routeId: tool.id, + }); + } + + return deepFreeze({ commands, diagnostics, projectionSources, routes }); +}; + +const emptyMcpSurface: CompiledMcpCliCommandSurface = { commands: [], diagnostics: [], routes: [] }; +const emptyProjectedSurface: CompiledProjectedCliCommandSurface = { ...emptyMcpSurface, projectionSources: {} }; + /** * Compiles the generated-mode CLI route surface into the collision-checked * command graph. `readModuleText` supplies each plain route's source text * (a racing deletion yields undefined and the route simply compiles no - * command; the next source snapshot settles it). + * command; the next source snapshot settles it). `projected` is the bulk + * `routes.mcpCommands` projection and `projections` the per-tool projection + * modules (#596); both join the command set and the collision pass. */ export const compileCliCommands = async ( routes: readonly CompiledAgentRoute[], readModuleText: (route: CompiledAgentRoute) => Promise, - projected: CompiledMcpCliCommandSurface = { commands: [], diagnostics: [], routes: [] }, + projected: CompiledMcpCliCommandSurface = emptyMcpSurface, compileOptions: CompileCliCommandsOptions = {}, + projections: CompiledProjectedCliCommandSurface = emptyProjectedSurface, ): Promise => { - const diagnostics: Diagnostic[] = [...projected.diagnostics]; - const commands: CompiledCliCommand[] = [...projected.commands]; + const diagnostics: Diagnostic[] = [...projected.diagnostics, ...projections.diagnostics]; + const commands: CompiledCliCommand[] = [...projected.commands, ...projections.commands]; for (const route of [...routes].sort((left, right) => left.id.localeCompare(right.id))) { const relativePath = route.provenance.relativePath; @@ -426,7 +651,8 @@ export const compileCliCommands = async ( let options = argv.options; if (config.positionals !== undefined) { - const positioned = applyPositionals(options, config.positionals, relativePath, route.source); + const positioned = applyPositionals(options, config.positionals, (detail) => + positionalsError(`CLI route ${relativePath} ${detail}.`, route.source)); diagnostics.push(...positioned.diagnostics); if (positioned.options === undefined) continue; options = positioned.options; @@ -445,31 +671,55 @@ export const compileCliCommands = async ( } interface PathClaim { - readonly mcp?: NonNullable; readonly path: readonly string[]; + readonly provenance?: McpPathProvenance; readonly relativePath: string; readonly source: string; } + const mcpProvenance = (command: CompiledCliCommand): McpPathProvenance => ({ + identity: `${command.mcp!.server}:${command.mcp!.tool}`, + ...(command.projection === undefined ? {} : { projection: command.projection.module }), + }); // Collision checks run over every discovered custom route's claimed path, - // even when it compiled no command, plus every selected MCP projection. - const claims: PathClaim[] = [ - ...routes.map((route) => ({ + // even when it compiled no command, plus every compiled MCP projection. + // Each route id claims at most one path, so the table serves the alias + // pass below as well. + const claimByRouteId = new Map(); + for (const route of routes) { + claimByRouteId.set(route.id, { path: cliCommandPath(route), relativePath: route.provenance.relativePath, source: route.source, - })), - ...projected.commands.map((command) => { - const route = projected.routes.find((candidate) => candidate.id === command.routeId)!; - return { - mcp: command.mcp!, - path: command.path, - relativePath: route.provenance.relativePath, - source: route.source, - }; - }), - ]; - const mcpIdentity = (claim: PathClaim): string | undefined => - claim.mcp === undefined ? undefined : `${claim.mcp.server}:${claim.mcp.tool}`; + }); + } + for (const command of projected.commands) { + const route = projected.routes.find((candidate) => candidate.id === command.routeId)!; + claimByRouteId.set(command.routeId, { + path: command.path, + provenance: mcpProvenance(command), + relativePath: route.provenance.relativePath, + source: route.source, + }); + } + for (const command of projections.commands) { + claimByRouteId.set(command.routeId, { + path: command.path, + provenance: mcpProvenance(command), + relativePath: command.projection!.module, + source: projections.projectionSources[command.routeId]!, + }); + } + const claims = [...claimByRouteId.values()]; + const sides = (claim: PathClaim, existing: PathClaim): { + readonly mcp: PathClaim; + readonly other: PathClaim; + readonly sourcePath: string; + } => { + const [mcp, other] = claim.provenance === undefined ? [existing, claim] : [claim, existing]; + // A projection module owns its `command`; the bulk projection is fixed + // in config, so the colliding custom route is the file to open. + return { mcp, other, sourcePath: mcp.provenance?.projection === undefined ? other.source : mcp.source }; + }; const claimedPaths = new Map(); for (const claim of claims) { const path = claim.path.join('/'); @@ -478,18 +728,13 @@ export const compileCliCommands = async ( claimedPaths.set(path, claim); continue; } - const mcp = claim.mcp === undefined ? existing : claim; - const identity = mcpIdentity(mcp); - diagnostics.push(identity === undefined + const { mcp, other, sourcePath } = sides(claim, existing); + diagnostics.push(mcp.provenance === undefined ? collisionError( `CLI command ${JSON.stringify(path.replaceAll('/', ' '))} is claimed by both ${existing.relativePath} and ${claim.relativePath}; the compiler never chooses silently.`, claim.source, ) - : mcpCollisionError( - identity, - `claims the same command path as ${claim.mcp === undefined ? claim.relativePath : existing.relativePath}.`, - claim.mcp === undefined ? claim.source : existing.source, - )); + : mcpCollisionError(mcp.provenance, `claims the same command path as ${other.relativePath}.`, sourcePath)); } const groupPaths = new Map(); for (const claim of claims) { @@ -501,17 +746,18 @@ export const compileCliCommands = async ( for (const [path, claim] of claimedPaths) { const groupClaim = groupPaths.get(path); if (groupClaim === undefined) continue; - const mcp = claim.mcp === undefined ? groupClaim : claim; - const identity = mcpIdentity(mcp); - diagnostics.push(identity === undefined + const { mcp, other, sourcePath } = sides(claim, groupClaim); + diagnostics.push(mcp.provenance === undefined ? collisionError( `CLI command ${JSON.stringify(path.replaceAll('/', ' '))} is both the command module ${claim.relativePath} and a command group (${groupClaim.relativePath} nests below it); the compiler never chooses silently.`, claim.source, ) : mcpCollisionError( - identity, - `collides with the custom command ${claim.mcp === undefined ? claim.relativePath : groupClaim.relativePath} at its server command group.`, - claim.mcp === undefined ? claim.source : groupClaim.source, + mcp.provenance, + mcp.provenance.projection === undefined + ? `collides with the custom command ${other.relativePath} at its server command group.` + : `collides with ${other.relativePath} at the command path ${JSON.stringify(path.replaceAll('/', ' '))}, which is both a command and a command group.`, + sourcePath, )); } @@ -521,6 +767,11 @@ export const compileCliCommands = async ( readonly description: string; readonly pathClaim: PathClaim; } + const describeOwner = (claim: PathClaim): string => claim.provenance === undefined + ? `CLI route ${claim.relativePath}` + : claim.provenance.projection === undefined + ? `Projected MCP tool ${JSON.stringify(claim.provenance.identity)}` + : `CLI projection ${claim.provenance.projection}`; const levelNames = new Map>(); const claimLevelName = (parent: string, name: string, claim: LevelClaim): LevelClaim | undefined => { const names = levelNames.get(parent) ?? new Map(); @@ -533,62 +784,68 @@ export const compileCliCommands = async ( for (const [path, claim] of claimedPaths) { const segments = path.split('/'); claimLevelName(segments.slice(0, -1).join('/'), segments[segments.length - 1]!, { - description: claim.mcp === undefined + description: claim.provenance === undefined ? `the command ${claim.relativePath}` - : `projected MCP tool ${JSON.stringify(mcpIdentity(claim))}`, + : claim.provenance.projection === undefined + ? `projected MCP tool ${JSON.stringify(claim.provenance.identity)}` + : `the CLI projection ${claim.provenance.projection} command`, pathClaim: claim, }); } for (const [path, claim] of groupPaths) { const segments = path.split('/'); claimLevelName(segments.slice(0, -1).join('/'), segments[segments.length - 1]!, { - description: claim.mcp === undefined + description: claim.provenance === undefined ? `the ${claim.relativePath} command group` - : `the ${JSON.stringify(mcpIdentity(claim))} MCP server command group`, + : claim.provenance.projection === undefined + ? `the ${JSON.stringify(claim.provenance.identity)} MCP server command group` + : `the ${claim.provenance.projection} command group`, pathClaim: claim, }); } for (const command of commands) { const parent = command.path.slice(0, -1).join('/'); - const route = [...routes, ...projected.routes].find((candidate) => candidate.id === command.routeId)!; - const commandClaim: PathClaim = { - ...(command.mcp === undefined ? {} : { mcp: command.mcp }), - path: command.path, - relativePath: route.provenance.relativePath, - source: route.source, - }; + const commandClaim = claimByRouteId.get(command.routeId)!; + const owner = describeOwner(commandClaim); for (const alias of new Set(command.aliases)) { if (!safeIdentitySegment.test(alias)) { diagnostics.push(collisionError( - `CLI route ${route.provenance.relativePath} declares the unsafe alias ${JSON.stringify(alias)}; use letters, digits, and inner ".", "_", "-" only.`, - route.source, + `${owner} declares the unsafe alias ${JSON.stringify(alias)}; use letters, digits, and inner ".", "_", "-" only.`, + commandClaim.source, )); continue; } const existing = claimLevelName(parent, alias, { - description: `the ${route.provenance.relativePath} alias`, + description: `the ${commandClaim.relativePath} alias`, pathClaim: commandClaim, }); - if (existing !== undefined) { - const mcp = commandClaim.mcp === undefined ? existing.pathClaim : commandClaim; - const identity = mcpIdentity(mcp); - diagnostics.push(identity === undefined - ? collisionError( - `CLI alias ${JSON.stringify(alias)} on ${route.provenance.relativePath} collides with ${existing.description} at the same nesting level.`, - route.source, - ) - : mcpCollisionError( - identity, - `collides with the custom alias ${JSON.stringify(alias)} on ${commandClaim.mcp === undefined ? route.provenance.relativePath : existing.pathClaim.relativePath}.`, - commandClaim.mcp === undefined ? route.source : existing.pathClaim.source, - )); + if (existing === undefined) continue; + if (commandClaim.provenance !== undefined) { + // The alias belongs to a projection module (the bulk projection + // declares none): the module is where it is changed. + diagnostics.push(mcpCollisionError( + commandClaim.provenance, + `declares the alias ${JSON.stringify(alias)}, which collides with ${existing.description} at the same nesting level.`, + commandClaim.source, + )); + } else if (existing.pathClaim.provenance !== undefined) { + diagnostics.push(mcpCollisionError( + existing.pathClaim.provenance, + `collides with the custom alias ${JSON.stringify(alias)} on ${commandClaim.relativePath}.`, + existing.pathClaim.provenance.projection === undefined ? commandClaim.source : existing.pathClaim.source, + )); + } else { + diagnostics.push(collisionError( + `CLI alias ${JSON.stringify(alias)} on ${commandClaim.relativePath} collides with ${existing.description} at the same nesting level.`, + commandClaim.source, + )); } } const duplicateAlias = command.aliases.find((alias, index) => command.aliases.indexOf(alias) !== index); if (duplicateAlias !== undefined) { diagnostics.push(collisionError( - `CLI route ${route.provenance.relativePath} declares the alias ${JSON.stringify(duplicateAlias)} twice.`, - route.source, + `${owner} declares the alias ${JSON.stringify(duplicateAlias)} twice.`, + commandClaim.source, )); } } diff --git a/packages/agent-bundle/src/routes/cli-projection.ts b/packages/agent-bundle/src/routes/cli-projection.ts new file mode 100644 index 000000000..c066dc857 --- /dev/null +++ b/packages/agent-bundle/src/routes/cli-projection.ts @@ -0,0 +1,447 @@ +import { extractRouteConfig, routeConfigGrammar } from './config-extract.ts'; +import { scanRouteModuleExports, type RouteModuleExports } from './contract.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { isRecord } from '../core/strict-json.ts'; +import type { CliProjectionFlagConfig, CliProjectionFlagDefault } from './public.ts'; +import { safeIdentitySegment, type CompiledAgentRoute, type RouteInputSchema } from './types.ts'; + +/** + * The CLI surface projection of one MCP tool (#596): the colocated + * `src/mcp//tools/.cli.{ts,tsx}` module. It is never a route — + * discovery records it before route classification and pairs it with the + * sibling tool route — and its `config` is read by the unchanged static + * route-config grammar. This leaf owns the module's shape: how a path is + * recognized, what the validated `config` may hold, whether the module + * exports `mapInput`, and the `AB4843`–`AB4845` diagnostics. + */ + +/** The file suffixes reserved for a tool's CLI projection module under `src/mcp/**`. */ +export const cliProjectionSuffixes = ['.cli.ts', '.cli.tsx'] as const; + +/** One `src/mcp//tools/.cli.{ts,tsx}` module, as discovery classifies it. */ +export interface CliProjectionModule { + readonly server: string; + /** The tool route id the module projects: `tool:/`. */ + readonly siblingId: string; + /** The tool name, `` of `.cli.{ts,tsx}`. */ + readonly stem: string; +} + +const projectionModulePath = /^src\/mcp\/(?[^/]+)\/tools\/(?[^/]+)\.cli\.tsx?$/u; +const misplacedModulePath = /^src\/mcp\/[^/]+\/(?:resources|prompts|apps)\/[^/]+\.cli\.tsx?$/u; + +/** `src/mcp//tools/.cli.{ts,tsx}` → its server, stem, and sibling tool id; undefined for every other path. */ +export const classifyCliProjectionModule = (relativePath: string): CliProjectionModule | undefined => { + const match = projectionModulePath.exec(relativePath); + if (match?.groups === undefined) return undefined; + const server = match.groups['server']; + const stem = match.groups['stem']; + if (server === undefined || stem === undefined) return undefined; + return { server, siblingId: `tool:${server}/${stem}`, stem }; +}; + +/** True for a `.cli.{ts,tsx}` module under `resources/`, `prompts/`, or `apps/`: only tool routes take a CLI projection (AB4843). */ +export const isMisplacedCliProjectionModule = (relativePath: string): boolean => misplacedModulePath.test(relativePath); + +/** + * The validated `config` of a projection module: the closed key set of + * `CliProjectionConfig` with `flags`/`positionals` keyed by canonical key + * strings. Deep-frozen; every field is optional. + */ +export interface CliProjectionConfigRecord { + readonly aliases?: readonly string[]; + readonly command?: readonly string[]; + readonly confirm?: boolean; + readonly description?: string; + readonly exitCode?: 'result' | 'zero'; + readonly flags?: Readonly>; + readonly positionals?: readonly string[]; +} + +/** What one projection module contributes, judged statically; `config` is empty whenever AB4844 fired on it. */ +export interface ExtractedCliProjection { + readonly config: CliProjectionConfigRecord; + /** AB4844 (module contract) and AB4845 (grammar binding) in that order. */ + readonly diagnostics: readonly Diagnostic[]; + /** True when the module exports `mapInput` as a synchronous, non-generator function with a runtime binding. */ + readonly mapInput: boolean; +} + +export interface CliProjectionExtractionOptions { + /** Absolute project root; const string references inside `config` resolve inside it only. */ + readonly projectRoot?: string; +} + +const projectionConfigKeys: readonly string[] = ['aliases', 'command', 'confirm', 'description', 'exitCode', 'flags', 'positionals']; + +const flagConfigKeys: readonly string[] = ['aliases', 'default', 'description', 'name', 'required']; + +const emptyProjectionConfig: CliProjectionConfigRecord = deepFreeze({}); + +const projectionSubject = (module: string, toolId: string): string => `CLI projection ${module} for ${toolId}`; + +const contractRecovery = 'Declare only command, aliases, confirm, description, exitCode, flags, and positionals, each in the shape CliProjectionConfig documents; then inspect again.'; +const grammarRecovery = `Export the projection config as a single top-level \`export const config = { ... }\` object literal inside the static route-config grammar (${routeConfigGrammar}), then inspect again.`; +const mapInputRecovery = 'Export mapInput as one synchronous, non-generator function with a runtime binding — a function declaration (`export function mapInput(input) { ... }`), an arrow (`export const mapInput = (input) => ({ ... })`), or a function expression — or remove the export; then inspect again.'; +const spellingRecovery = 'Use kebab-case option spellings without leading dashes that are neither reserved (help, json, ndjson, version, and yes when the command confirms) nor claimed by another option or alias; then inspect again.'; + +/** AB4844: the projection module's own contract — `config` shape and `mapInput` — is not met. */ +export const cliProjectionContractError = ( + module: string, + toolId: string, + detail: string, + sourcePath: string, + recovery = contractRecovery, +): Diagnostic => ({ + code: 'AB4844', + message: `${projectionSubject(module, toolId)}: ${detail}.`, + recovery, + severity: 'error', + sourcePath, +}); + +/** AB4845: the projection does not bind to the tool's argv grammar (unknown key, spelling, command segment). */ +export const cliProjectionBindingError = ( + module: string, + toolId: string, + detail: string, + sourcePath: string, + recovery = spellingRecovery, +): Diagnostic => ({ + code: 'AB4845', + message: `${projectionSubject(module, toolId)}: ${detail}.`, + recovery, + severity: 'error', + sourcePath, +}); + +/** AB4843: a `.cli.{ts,tsx}` module under `tools/` without the sibling tool route `.{ts,tsx}`. */ +export const orphanCliProjectionError = ( + relativePath: string, + module: CliProjectionModule, + sourcePath: string, +): Diagnostic => ({ + code: 'AB4843', + message: `${projectionSubject(relativePath, module.siblingId)}: has no sibling tool route src/mcp/${module.server}/tools/${module.stem}.{ts,tsx} to project; a projection is never a route of its own.`, + recovery: 'Rename the module so its stem matches the tool route beside it, or prefix the file name with _ to park it; then inspect again.', + severity: 'error', + sourcePath, +}); + +/** AB4843 for the second of `.cli.ts` and `.cli.tsx`: a tool takes one projection module. */ +export const duplicateCliProjectionError = ( + relativePath: string, + existingRelativePath: string, + module: CliProjectionModule, + sourcePath: string, +): Diagnostic => ({ + code: 'AB4843', + message: `${projectionSubject(relativePath, module.siblingId)}: ${existingRelativePath} already projects this tool, and a tool takes one projection module.`, + recovery: 'Keep exactly one of the .cli.ts and .cli.tsx modules, or prefix one file name with _ to park it; then inspect again.', + severity: 'error', + sourcePath, +}); + +/** + * AB4843: a `.cli.{ts,tsx}` module under `resources/`, `prompts/`, or + * `apps/`, where no route takes a CLI projection. There is no tool to name, + * so the subject is the module alone. + */ +export const misplacedCliProjectionError = (relativePath: string, sourcePath: string): Diagnostic => ({ + code: 'AB4843', + message: `CLI projection ${relativePath}: sits under resources/, prompts/, or apps/, where no route takes the .cli suffix; only src/mcp//tools/.cli.{ts,tsx} projects a tool, and resources, prompts, and Apps have no argv surface to project.`, + recovery: 'Move the module beside the tool route it projects, rename it so it does not end in .cli.ts or .cli.tsx, or prefix the file name with _ to park it; then inspect again.', + severity: 'error', + sourcePath, +}); + +/** The value when it is an array of strings (empty included), else undefined. */ +export const stringArray = (value: unknown): readonly string[] | undefined => + Array.isArray(value) && value.every((item): item is string => typeof item === 'string') + ? value + : undefined; + +const isFlagDefault = (value: unknown): value is CliProjectionFlagDefault => { + const scalar = (entry: unknown): entry is boolean | number | string => + typeof entry === 'boolean' || typeof entry === 'string' || (typeof entry === 'number' && Number.isFinite(entry)); + return scalar(value) || (Array.isArray(value) && value.every(scalar)); +}; + +const configReason = (diagnostic: Diagnostic, relativePath: string): string => { + const prefix = `Route module ${relativePath} `; + const message = diagnostic.message.endsWith('.') ? diagnostic.message.slice(0, -1) : diagnostic.message; + return message.startsWith(prefix) ? `the module ${message.slice(prefix.length)}` : message; +}; + +type FlagValidation = + | { readonly detail: string } + | { readonly flag: CliProjectionFlagConfig }; + +const validateFlag = (key: string, value: unknown): FlagValidation => { + if (!isRecord(value)) return { detail: `config.flags.${key} must be an object` }; + const unknown = Object.keys(value).find((field) => !flagConfigKeys.includes(field)); + if (unknown !== undefined) return { detail: `config.flags.${key}.${unknown} is an unknown field` }; + const aliases = value.aliases === undefined ? undefined : stringArray(value.aliases); + if (value.aliases !== undefined && aliases === undefined) return { detail: `config.flags.${key}.aliases must be an array of strings` }; + const defaultValue = value.default; + if (defaultValue !== undefined && !isFlagDefault(defaultValue)) { + return { detail: `config.flags.${key}.default must be a boolean, number, string, or an array of those` }; + } + const description = value.description; + if (description !== undefined && typeof description !== 'string') { + return { detail: `config.flags.${key}.description must be a string` }; + } + const name = value.name; + if (name !== undefined && typeof name !== 'string') return { detail: `config.flags.${key}.name must be a string` }; + if (value.required !== undefined && value.required !== false) { + return { detail: `config.flags.${key}.required may only be false (the canonical schema decides what is required)` }; + } + return { + flag: { + ...(aliases === undefined ? {} : { aliases }), + ...(defaultValue === undefined ? {} : { default: defaultValue }), + ...(description === undefined ? {} : { description }), + ...(name === undefined ? {} : { name }), + ...(value.required === undefined ? {} : { required: false as const }), + }, + }; +}; + +interface ConfigValidation { + readonly config?: CliProjectionConfigRecord; + readonly details: readonly string[]; +} + +const validateProjectionConfig = (raw: Readonly>): ConfigValidation => { + const details: string[] = []; + for (const key of Object.keys(raw)) { + if (!projectionConfigKeys.includes(key)) details.push(`config.${key} is an unknown key`); + } + const aliases = raw['aliases'] === undefined ? undefined : stringArray(raw['aliases']); + if (raw['aliases'] !== undefined && aliases === undefined) details.push('config.aliases must be an array of strings'); + const command = raw['command'] === undefined ? undefined : stringArray(raw['command']); + if (raw['command'] !== undefined && (command === undefined || command.length === 0)) { + details.push('config.command must be a non-empty array of command segment strings'); + } + const confirm = raw['confirm']; + if (confirm !== undefined && typeof confirm !== 'boolean') details.push('config.confirm must be a boolean'); + const description = raw['description']; + if (description !== undefined && typeof description !== 'string') details.push('config.description must be a string'); + const exitCode = raw['exitCode']; + if (exitCode !== undefined && exitCode !== 'result' && exitCode !== 'zero') { + details.push('config.exitCode must be "result" or "zero" when declared'); + } + const flags: Record = {}; + const declaredFlags = raw['flags']; + if (declaredFlags !== undefined && !isRecord(declaredFlags)) { + details.push('config.flags must be an object keyed by canonical inputSchema keys'); + } else if (declaredFlags !== undefined) { + for (const [key, value] of Object.entries(declaredFlags)) { + const validated = validateFlag(key, value); + if ('detail' in validated) details.push(validated.detail); + else flags[key] = validated.flag; + } + } + const positionals = raw['positionals'] === undefined ? undefined : stringArray(raw['positionals']); + if (raw['positionals'] !== undefined && positionals === undefined) { + details.push('config.positionals must be an array of canonical inputSchema key strings'); + } + if (details.length > 0) return { details }; + return { + config: { + ...(aliases === undefined ? {} : { aliases }), + ...(command === undefined ? {} : { command }), + ...(typeof confirm === 'boolean' ? { confirm } : {}), + ...(typeof description === 'string' ? { description } : {}), + ...(exitCode === 'result' || exitCode === 'zero' ? { exitCode } : {}), + ...(declaredFlags === undefined ? {} : { flags }), + ...(positionals === undefined ? {} : { positionals }), + }, + details: [], + }; +}; + +const positionalSpellingRecovery = (key: string): string => + `Remove name and aliases from config.flags.${key}, or drop ${JSON.stringify(key)} from config.positionals so it is an option; then inspect again.`; + +/** + * Binds a validated config to the tool's canonical contract: `flags` and + * `positionals` must name contract keys, a positional key takes no option + * spelling, and `command` segments must be safe identity segments (AB4845); + * relaxing a canonical-required key needs `mapInput` to supply it (AB4844). + * Spelling rules run later, inside the one argv policy, on the final + * `--options`. + */ +const bindProjectionConfig = ( + config: CliProjectionConfigRecord, + contract: RouteInputSchema | undefined, + mapInput: boolean, + report: { + readonly binding: (detail: string, recovery?: string) => Diagnostic; + readonly contract: (detail: string, recovery?: string) => Diagnostic; + }, +): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + for (const [index, segment] of (config.command ?? []).entries()) { + if (!safeIdentitySegment.test(segment)) { + diagnostics.push(report.binding( + `config.command[${index}] ${JSON.stringify(segment)} is not a safe identity segment`, + 'Use command segments of letters, digits, and inner ".", "_", "-" only, then inspect again.', + )); + } + } + // A positional is consumed as a bare argument; the parser never reads a + // `--spelling` for it, so `name` and `aliases` would advertise spellings + // that do not exist. `description`, `default`, and `required: false` + // still apply to the key. + for (const key of new Set(config.positionals ?? [])) { + const flag = config.flags?.[key]; + if (flag === undefined) continue; + const fields = [ + ...(flag.name === undefined ? [] : ['name']), + ...(flag.aliases === undefined ? [] : ['aliases']), + ]; + if (fields.length === 0) continue; + diagnostics.push(report.binding( + `config.flags.${key} is positional; ${fields.join(' and ')} ${fields.length === 1 && fields[0] === 'name' ? 'does' : 'do'} not apply to a bare argument`, + positionalSpellingRecovery(key), + )); + } + // Without a static contract the tool's own argv parse reports why + // (AB4814/AB4838/AB4839, relabelled); nothing here can be judged. + if (contract === undefined) return diagnostics; + const keys = Object.keys(contract.properties); + const keyRecovery = inputKeysRecovery(keys); + const required = contract.required ?? []; + for (const [key, flag] of Object.entries(config.flags ?? {})) { + if (!keys.includes(key)) { + diagnostics.push(report.binding(unknownInputKeyDetail('flags', key), keyRecovery)); + continue; + } + if (!mapInput && required.includes(key) && (flag.required === false || flag.default !== undefined)) { + diagnostics.push(report.contract(relaxationWithoutMapInputDetail(key, flag), relaxationRecovery(key))); + } + } + for (const key of config.positionals ?? []) { + if (!keys.includes(key)) { + diagnostics.push(report.binding(unknownInputKeyDetail('positionals', key), keyRecovery)); + } + } + return diagnostics; +}; + +/** AB4845 detail: `config.flags.` or `config.positionals` names a key the tool's inputSchema lacks. */ +export const unknownInputKeyDetail = (site: 'flags' | 'positionals', key: string): string => + site === 'flags' + ? `config.flags.${key} names a key that is not in the tool's inputSchema` + : `config.positionals names ${JSON.stringify(key)}, which is not a key of the tool's inputSchema`; + +/** Recovery for `unknownInputKeyDetail`: the keys the tool's inputSchema does declare. */ +export const inputKeysRecovery = (keys: readonly string[]): string => + `Name only keys of the tool's inputSchema (${keys.length === 0 ? 'it declares none' : keys.join(', ')}), then inspect again.`; + +/** + * AB4844 detail: `flags..required: false` or `flags..default` + * relaxes a key the tool's inputSchema requires, and no `mapInput` exists to + * supply it before the canonical schema validates. + */ +export const relaxationWithoutMapInputDetail = (key: string, flag: CliProjectionFlagConfig): string => + `config.flags.${key}.${flag.required === false ? 'required' : 'default'} relaxes ${JSON.stringify(key)}, which the tool's inputSchema requires, but the module exports no mapInput to supply it`; + +export const relaxationRecovery = (key: string): string => + `Export a mapInput function that fills ${JSON.stringify(key)} before the canonical inputSchema validates, or keep the key required on the CLI; then inspect again.`; + +/** + * The AB4844 detail when an exported `mapInput` is not what the shell can + * call: it must carry a runtime binding (no ambient `declare`), return the + * mapped input directly (no generator), and return it synchronously (no + * `async`), because the shell applies it inline before `inputSchema.parse` + * and a Promise or iterator would reach the schema instead of the input. + * A `mapInput` re-exported from another module is judged where it is + * declared (`export { mapInput } from './shared.ts'` is followed like a + * default re-export); one the scan cannot follow — a bare specifier, an + * unreadable file, or a re-export cycle — is rejected rather than trusted, + * since a projection has no run-time fallback judgment. Undefined when the + * module exports no `mapInput` or exports an accepted one. + */ +const judgeMapInput = (exports: RouteModuleExports): string | undefined => { + if (!exports.named.has('mapInput')) return undefined; + if (exports.namedAmbient.has('mapInput')) { + return 'mapInput is an ambient declaration (declare function or declare const), which emits no runtime binding for the shell to call'; + } + const unresolved = exports.namedUnresolved.get('mapInput'); + if (unresolved !== undefined) { + return `mapInput is re-exported from ${JSON.stringify(unresolved)}, which cannot be followed statically to a function`; + } + if (exports.namedGeneratorFunctions.has('mapInput')) { + return exports.namedAsyncFunctions.has('mapInput') + ? 'mapInput is an async generator function, which yields an async iterator instead of returning the mapped input' + : 'mapInput is a generator function, which yields an iterator instead of returning the mapped input'; + } + if (exports.namedAsyncFunctions.has('mapInput')) { + return 'mapInput is an async function, which returns a Promise, but the shell applies mapInput synchronously before the canonical inputSchema validates'; + } + if (!exports.namedFunctions.has('mapInput')) return 'mapInput is exported but is not statically a function'; + return undefined; +}; + +/** + * Statically extracts one projection module: its `config` through the + * unchanged route-config grammar (`extractRouteConfig`), validated against + * the closed `CliProjectionConfig` key set and bound to the tool's contract, + * and whether it exports a `mapInput` the shell can call (`judgeMapInput` + * over `scanRouteModuleExports`). The module is parsed, never executed. Every + * failure is `AB4844` (the module's own contract) or `AB4845` (binding to + * the tool's argv grammar), addressed as + * `CLI projection for tool:/: .` on the + * module's own path; a module with any AB4844 extracts the empty config. + */ +export const extractCliProjection = ( + moduleText: string, + relativePath: string, + sourcePath: string, + contract: RouteInputSchema | undefined, + tool: CompiledAgentRoute, + options: CliProjectionExtractionOptions = {}, +): ExtractedCliProjection => { + const report = { + binding: (detail: string, recovery?: string): Diagnostic => + cliProjectionBindingError(relativePath, tool.id, detail, sourcePath, recovery), + contract: (detail: string, recovery?: string): Diagnostic => + cliProjectionContractError(relativePath, tool.id, detail, sourcePath, recovery), + }; + const diagnostics: Diagnostic[] = []; + const exports = scanRouteModuleExports(moduleText, relativePath, { source: sourcePath }); + const mapInputDetail = judgeMapInput(exports); + if (mapInputDetail !== undefined) diagnostics.push(report.contract(mapInputDetail, mapInputRecovery)); + const mapInput = exports.named.has('mapInput') && mapInputDetail === undefined; + + if (!exports.named.has('config')) { + diagnostics.push(report.contract('the module exports no config', grammarRecovery)); + return deepFreeze({ config: emptyProjectionConfig, diagnostics, mapInput }); + } + const extracted = extractRouteConfig(moduleText, relativePath, sourcePath, { + ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), + }); + if (extracted.diagnostics.length > 0) { + for (const diagnostic of extracted.diagnostics) { + diagnostics.push(report.contract(configReason(diagnostic, relativePath), grammarRecovery)); + } + return deepFreeze({ config: emptyProjectionConfig, diagnostics, mapInput }); + } + // An `appResourceUri()` reference has no App to resolve against here; the + // reference text is not a projection value. + for (const reference of extracted.appReferences) { + diagnostics.push(report.contract( + `config references MCP App ${JSON.stringify(reference.reference)} at ${reference.position}; a CLI projection carries no App reference`, + )); + } + const validated = validateProjectionConfig(extracted.config); + for (const detail of validated.details) diagnostics.push(report.contract(detail)); + if (validated.config === undefined || diagnostics.length > 0) { + return deepFreeze({ config: emptyProjectionConfig, diagnostics, mapInput }); + } + diagnostics.push(...bindProjectionConfig(validated.config, contract, mapInput, report)); + return deepFreeze({ config: validated.config, diagnostics, mapInput }); +}; diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index e40ed90f9..e6960fa99 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -10,6 +10,9 @@ const modifier = (node: ts.Node, kind: ts.SyntaxKind): boolean => const exported = (node: ts.Node): boolean => modifier(node, ts.SyntaxKind.ExportKeyword); const asynchronous = (node: ts.Node): boolean => modifier(node, ts.SyntaxKind.AsyncKeyword); +/** `declare function` / `declare const`: a type-level declaration that emits no runtime binding. */ +const ambient = (node: ts.Node): boolean => modifier(node, ts.SyntaxKind.DeclareKeyword); +const generator = (node: ts.FunctionDeclaration | ts.FunctionExpression): boolean => node.asteriskToken !== undefined; const unwrappedExpression = (expression: ts.Expression): ts.Expression => { let current = expression; @@ -58,10 +61,25 @@ export interface RouteModuleExports { /** Set when the default export is re-exported from another module. */ readonly defaultReExport?: RouteDefaultReExport; readonly named: ReadonlySet; - /** Exported names bound to an async function or arrow function. */ + /** + * Exported names whose declaration is ambient (`declare function`, + * `declare const`): TypeScript emits no runtime binding for them, so they + * are in `named` but in none of the function sets. + */ + readonly namedAmbient: ReadonlySet; + /** Exported names bound to an async function or arrow function (async generators included). */ readonly namedAsyncFunctions: ReadonlySet; - /** Exported names bound to a function or arrow function. */ + /** Exported names bound to a function or arrow function that emits a runtime binding (generators included, ambient declarations excluded). */ readonly namedFunctions: ReadonlySet; + /** Exported names bound to a generator function (`function*`, `async function*`). */ + readonly namedGeneratorFunctions: ReadonlySet; + /** + * Exported names re-exported from a module the scan could not follow (a + * bare specifier, an unreadable file, or a re-export cycle), keyed to the + * specifier named, so their shape is unknown statically rather than "not a + * function". + */ + readonly namedUnresolved: ReadonlyMap; /** True when the module exports `execute` or `render` (the retired split contract). */ readonly splitExport: boolean; } @@ -83,7 +101,7 @@ interface PendingReExport { } interface FollowedReExport { - readonly exports: ScannedModuleExports; + readonly exports: RouteModuleExports; readonly source: string; } @@ -92,34 +110,35 @@ export const scanRouteModuleExports = ( moduleText: string, relativePath: string, options: ScanRouteModuleOptions = {}, -): RouteModuleExports => { - const { unresolvedNamed: _unresolvedNamed, ...exports } = scanModuleExports(moduleText, relativePath, options, new Set()); - return Object.freeze(exports); -}; - -/** The scan plus the named re-exports whose shape stayed unknown, so a chain propagates "unknown" rather than "not a function". */ -interface ScannedModuleExports extends RouteModuleExports { - readonly unresolvedNamed: ReadonlySet; -} +): RouteModuleExports => Object.freeze(scanModuleExports(moduleText, relativePath, options, new Set())); /** What one binding of a scanned module is known to be. */ interface BindingShape { + /** True when the binding is an ambient declaration with no runtime emit. */ + readonly ambient: boolean; readonly asyncFunction: boolean; readonly function: boolean; + readonly generator: boolean; /** True when the binding is a re-export the scan could not follow. */ readonly unresolved: boolean; } -const bindingShape = (exports: ScannedModuleExports, name: string): BindingShape => name === 'default' +const unknownShape: BindingShape = { ambient: false, asyncFunction: false, function: false, generator: false, unresolved: true }; + +const bindingShape = (exports: RouteModuleExports, name: string): BindingShape => name === 'default' ? { + ambient: false, asyncFunction: exports.asyncDefault, function: exports.defaultFunction, + generator: false, unresolved: exports.defaultReExport?.resolution === 'unresolved', } : { + ambient: exports.namedAmbient.has(name), asyncFunction: exports.namedAsyncFunctions.has(name), function: exports.namedFunctions.has(name), - unresolved: exports.unresolvedNamed.has(name), + generator: exports.namedGeneratorFunctions.has(name), + unresolved: exports.namedUnresolved.has(name), }; const scanModuleExports = ( @@ -127,13 +146,19 @@ const scanModuleExports = ( relativePath: string, options: ScanRouteModuleOptions, visited: ReadonlySet, -): ScannedModuleExports => { +): RouteModuleExports => { const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + // Local bindings by shape; an ambient declaration emits nothing, so it + // joins `ambientBindings` and no function set. + const ambientBindings = new Set(); const asyncFunctionBindings = new Set(); const functionBindings = new Set(); + const generatorBindings = new Set(); const named = new Set(); + const namedAmbient = new Set(); const namedAsyncFunctions = new Set(); const namedFunctions = new Set(); + const namedGeneratorFunctions = new Set(); // Exported names aliasing a local binding (`export { Foo as bar }`), judged // once every declaration is seen, and names re-exported from other modules. const namedAliases = new Map(); @@ -153,10 +178,12 @@ const scanModuleExports = ( if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name)) continue; + if (ambient(statement)) ambientBindings.add(declaration.name.text); const initializer = declaration.initializer === undefined ? undefined : unwrappedExpression(declaration.initializer); if (initializer !== undefined && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) { functionBindings.add(declaration.name.text); if (asynchronous(initializer)) asyncFunctionBindings.add(declaration.name.text); + if (ts.isFunctionExpression(initializer) && generator(initializer)) generatorBindings.add(declaration.name.text); } if (exported(statement)) { addNamed(declaration.name.text); @@ -166,9 +193,12 @@ const scanModuleExports = ( continue; } if (ts.isFunctionDeclaration(statement)) { - if (statement.name !== undefined) { + if (statement.name !== undefined && ambient(statement)) { + ambientBindings.add(statement.name.text); + } else if (statement.name !== undefined && statement.body !== undefined) { functionBindings.add(statement.name.text); if (asynchronous(statement)) asyncFunctionBindings.add(statement.name.text); + if (generator(statement)) generatorBindings.add(statement.name.text); } if (exported(statement) && modifier(statement, ts.SyntaxKind.DefaultKeyword)) { defaultFunction = true; @@ -224,20 +254,20 @@ const scanModuleExports = ( asyncDefault = asyncFunctionBindings.has(defaultIdentifier); } for (const [name, local] of namedAliases) { + if (ambientBindings.has(local)) namedAmbient.add(name); if (functionBindings.has(local)) namedFunctions.add(name); if (asyncFunctionBindings.has(local)) namedAsyncFunctions.add(name); + if (generatorBindings.has(local)) namedGeneratorFunctions.add(name); } // Re-exports are followed lazily and once per target module: a placement // that re-exports its component and schemas from one shared route reads // that route a single time. - const targets = new Map(); + const targets = new Map(); const shapeOf = ({ name, specifier }: PendingReExport): BindingShape => { if (!targets.has(specifier)) targets.set(specifier, followReExport(specifier, options, visited)?.exports); const exports = targets.get(specifier); - return exports === undefined - ? { asyncFunction: false, function: false, unresolved: true } - : bindingShape(exports, name); + return exports === undefined ? unknownShape : bindingShape(exports, name); }; let resolvedDefaultReExport: RouteDefaultReExport | undefined; if (defaultReExport !== undefined) { @@ -246,12 +276,14 @@ const scanModuleExports = ( defaultFunction = shape.function; asyncDefault = shape.asyncFunction; } - const unresolvedNamed = new Set(); + const namedUnresolved = new Map(); for (const [name, reExport] of namedReExports) { const shape = shapeOf(reExport); + if (shape.ambient) namedAmbient.add(name); if (shape.function) namedFunctions.add(name); if (shape.asyncFunction) namedAsyncFunctions.add(name); - if (shape.unresolved) unresolvedNamed.add(name); + if (shape.generator) namedGeneratorFunctions.add(name); + if (shape.unresolved) namedUnresolved.set(name, reExport.specifier); } return { @@ -259,10 +291,12 @@ const scanModuleExports = ( defaultFunction, ...(resolvedDefaultReExport === undefined ? {} : { defaultReExport: Object.freeze(resolvedDefaultReExport) }), named, + namedAmbient, namedAsyncFunctions, namedFunctions, + namedGeneratorFunctions, + namedUnresolved, splitExport, - unresolvedNamed, }; }; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 202edffa4..f0780d525 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -10,8 +10,18 @@ import { resolveAppRouteTemplate } from './app-template.ts'; import { compileCliCommands, compileMcpCliCommands, + compileProjectedCliCommands, + type CliProjectionPair, type McpCommandSelection, } from './cli-commands.ts'; +import { + classifyCliProjectionModule, + duplicateCliProjectionError, + isMisplacedCliProjectionModule, + misplacedCliProjectionError, + orphanCliProjectionError, + type CliProjectionModule, +} from './cli-projection.ts'; import { type AppReferenceTarget, type ExtractedRouteConfig, @@ -44,6 +54,7 @@ import { validateRouteRenderConfig } from './render-budget.ts'; import { validateRouteExecutionConfig } from './task-support.ts'; import { emptyRouteConfig, + safeIdentitySegment, type CompiledAgentRoute, type CompiledCliMode, type CompiledCliSurface, @@ -86,9 +97,6 @@ const mcpRouteKinds: Readonly> = { tools: 'tool', }; -/** Every identity segment a route path contributes must be a safe name. */ -const safeIdentitySegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; - const serverModeOverrides = new Set(['generated', 'custom', 'command', 'remote']); /** True when a `routes.servers.` override keeps the server's own entry instead of compiling its routes. */ @@ -191,6 +199,18 @@ interface DiscoveredRouteModule { type DiscoveredModule = DiscoveredLayoutModule | DiscoveredProviderModule | DiscoveredRouteModule; +/** + * One `src/mcp//tools/.cli.{ts,tsx}` module (#596): the CLI + * surface projection of the sibling tool route, never a route of its own. It + * is recorded before route classification so it is not id-checked, + * contract-validated, registered, or typed as a tool. + */ +interface DiscoveredProjectionModule { + readonly module: CliProjectionModule; + readonly relativePath: string; + readonly source: string; +} + const stemOf = (fileName: string): string => fileName.slice(0, -extname(fileName).length); const layoutStem = 'layout'; @@ -766,12 +786,27 @@ export const compileRouteGraph = async ( const modules: DiscoveredModule[] = []; const modulesById = new Map(); const providerModulesByKey = new Map(); + const projectionModules: DiscoveredProjectionModule[] = []; for (const source of sources) { if (claimed.artifact.has(source)) continue; const relativePath = toPosixPath(relative(projectRoot, source)); if (claimed.bin.has(source) && !isConventionalScriptPath(relativePath)) continue; if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; if (preflightSupportSources.has(source)) continue; + // A tool's CLI projection module (#596) is paired with its sibling once + // every route id is known; it never derives an id of its own. The same + // suffix under resources, prompts, or apps names nothing that has an + // argv surface, so it is a mistake to report rather than a route to + // classify (`resource:.cli`). + const projection = classifyCliProjectionModule(relativePath); + if (projection !== undefined) { + projectionModules.push({ module: projection, relativePath, source }); + continue; + } + if (isMisplacedCliProjectionModule(relativePath)) { + diagnostics.push(misplacedCliProjectionError(relativePath, source)); + continue; + } const module = classifyModule(source, relativePath); // The documented opt-out: a server pinned to custom, command, or remote // keeps its own entry, so its layout never enters the graph — it is not @@ -853,6 +888,34 @@ export const compileRouteGraph = async ( modules.push(module); } + // Pairing needs the complete id table: a projection whose sibling tool + // route does not exist is an orphan (AB4843). Whether the pair compiles is + // decided once its server's mode is known (below): a projection of a tool + // whose server is custom, command, remote, or in conflict is skipped + // silently, because the server's own diagnostic or override is the + // actionable fact. + const pairedProjections: DiscoveredProjectionModule[] = []; + const projectionBySibling = new Map(); + for (const projection of projectionModules) { + const sibling = modulesById.get(projection.module.siblingId); + if (sibling === undefined || sibling.surface !== 'route' || sibling.kind !== 'tool') { + diagnostics.push(orphanCliProjectionError(projection.relativePath, projection.module, projection.source)); + continue; + } + const existing = projectionBySibling.get(projection.module.siblingId); + if (existing !== undefined) { + diagnostics.push(duplicateCliProjectionError( + projection.relativePath, + existing.relativePath, + projection.module, + projection.source, + )); + continue; + } + projectionBySibling.set(projection.module.siblingId, projection); + pairedProjections.push(projection); + } + const serverRoutes = new Map(); const events: CompiledAgentRoute[] = []; const scripts: CompiledAgentRoute[] = []; @@ -1121,11 +1184,40 @@ export const compileRouteGraph = async ( } } + // A projection pairs with a tool route of a generated server only; its text + // is read once here, like every other module the graph judges. The tool's + // own text is what the projected command re-parses when the tool has no + // static contract (AB4814/AB4838/AB4839 under the tool's label). + const pairs: CliProjectionPair[] = []; + for (const projection of pairedProjections) { + const server = servers.find((candidate) => candidate.name === projection.module.server && candidate.mode === 'generated'); + const tool = server?.routes.find((route) => route.id === projection.module.siblingId); + if (tool === undefined) continue; + const moduleText = await readRouteModuleText(projection.source); + if (moduleText === undefined) continue; + moduleTextBySource.set(projection.source, moduleText); + const toolText = moduleTextBySource.get(tool.source); + pairs.push({ + module: projection.module, + moduleText, + relativePath: projection.relativePath, + source: projection.source, + tool, + ...(toolText === undefined ? {} : { toolText }), + }); + } + // One command per operation: a tool with a projection module leaves the + // bulk projection's eligible set; an include pattern that matches only such + // tools is AB4822 naming the module. const projected = overrides.mcpCommands === undefined ? undefined - : compileMcpCliCommands(servers, overrides.mcpCommands); + : compileMcpCliCommands( + servers, + overrides.mcpCommands, + new Map(pairs.map((pair) => [pair.tool.id, pair.relativePath])), + ); let cli: CompiledCliSurface | undefined; - if (cliRoutes.length > 0 || projected !== undefined) { + if (cliRoutes.length > 0 || projected !== undefined || pairs.length > 0) { const conventionalCli = conventionalEntryAt(projectRoot, 'src', 'cli'); let mode: CompiledCliMode; if (overrides.cli !== undefined) { @@ -1134,11 +1226,11 @@ export const compileRouteGraph = async ( mode = 'generated'; } else { mode = 'conflict'; - const generatedClaim = cliRoutes.length === 0 - ? 'the routes.mcpCommands projection' - : projected === undefined - ? 'src/cli/ command route modules' - : 'src/cli/ command route modules plus the routes.mcpCommands projection'; + const generatedClaim = [ + ...(cliRoutes.length === 0 ? [] : ['src/cli/ command route modules']), + ...(projected === undefined ? [] : ['the routes.mcpCommands projection']), + ...(pairs.length === 0 ? [] : ['tool CLI projection modules (src/mcp//tools/.cli.ts)']), + ].join(' plus '); diagnostics.push(routeError( 'AB4801', `The conventional src/cli entry module and ${generatedClaim} both exist; the compiler never chooses silently.`, @@ -1146,22 +1238,39 @@ export const compileRouteGraph = async ( conventionalCli, )); } + // A projection module is judged in every mode, as the bulk projection's + // selection is: its contract and binding errors name the module to fix + // whether or not the CLI compiles this time. + const projections = compileProjectedCliCommands(pairs, { projectRoot }); if (mode === 'generated') { const compiled = await compileCliCommands(cliRoutes, async (route) => - moduleTextBySource.get(route.source), projected, { projectRoot }); + moduleTextBySource.get(route.source), projected, { projectRoot }, projections); diagnostics.push(...compiled.diagnostics); - // The routed CLI executable inlines every command route (AB4837, #558). + // The routed CLI executable inlines every command route and every + // projection module (AB4837, #558). for (const route of cliRoutes) { diagnostics.push(...routeFrameworkImportDiagnostics(route, moduleTextBySource.get(route.source))); } + for (const pair of pairs) { + diagnostics.push(...validateRouteFrameworkImports( + pair.moduleText, + pair.relativePath, + pair.source, + 'routed CLI executable', + 'CLI projection module', + )); + } + const backingRoutes = new Map( + [...cliRoutes, ...(projected?.routes ?? []), ...projections.routes].map((route) => [route.id, route] as const), + ); cli = { commands: compiled.commands, mode, - routes: [...cliRoutes, ...(projected?.routes ?? [])] - .sort((left, right) => left.id.localeCompare(right.id)), + ...(Object.keys(projections.projectionSources).length === 0 ? {} : { projectionSources: projections.projectionSources }), + routes: [...backingRoutes.values()].sort((left, right) => left.id.localeCompare(right.id)), }; } else { - diagnostics.push(...(projected?.diagnostics ?? [])); + diagnostics.push(...(projected?.diagnostics ?? []), ...projections.diagnostics); if (mode === 'conventional' && projected !== undefined) { diagnostics.push(routeError( 'AB4804', @@ -1170,6 +1279,14 @@ export const compileRouteGraph = async ( conventionalCli, )); } + if (mode === 'conventional' && pairs.length > 0) { + diagnostics.push(routeError( + 'AB4804', + `CLI projection modules (${pairs.map((pair) => pair.relativePath).join(', ')}) require a generated CLI surface, but routes.cli is conventional.`, + 'Set routes.cli to generated, or remove the projection modules (or prefix them with _) to keep the conventional src/cli entry.', + conventionalCli, + )); + } cli = { mode, routes: mode === 'conventional' ? [] : cliRoutes }; } } diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 38b272737..568336a49 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -1,8 +1,33 @@ export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './graph.ts'; -export { cliArgvGrammar, extractCliArgv, reservedCliOptionNames } from './cli-argv.ts'; -export type { ExtractedCliArgv } from './cli-argv.ts'; -export { cliCommandPath, compileCliCommands, isRenderedCliRoute } from './cli-commands.ts'; -export type { CompileCliCommandsOptions, CompiledCliCommandSurface } from './cli-commands.ts'; +export { cliArgvGrammar, extractCliArgv, projectInputSchemaOptions, reservedCliOptionNames } from './cli-argv.ts'; +export type { CliOptionOverride, CliOptionPolicy, ExtractedCliArgv, ProjectedCliOptions } from './cli-argv.ts'; +export { + cliCommandPath, + compileCliCommands, + compileMcpCliCommands, + compileProjectedCliCommands, + isRenderedCliRoute, +} from './cli-commands.ts'; +export type { + CliProjectionPair, + CompileCliCommandsOptions, + CompiledCliCommandSurface, + CompiledMcpCliCommandSurface, + CompiledProjectedCliCommandSurface, + McpCommandSelection, +} from './cli-commands.ts'; +export { + classifyCliProjectionModule, + cliProjectionSuffixes, + extractCliProjection, + isMisplacedCliProjectionModule, +} from './cli-projection.ts'; +export type { + CliProjectionConfigRecord, + CliProjectionExtractionOptions, + CliProjectionModule, + ExtractedCliProjection, +} from './cli-projection.ts'; export { appResourceUriHelperName, extractRouteConfig, @@ -30,6 +55,7 @@ export type { CompiledCliCommand, CompiledCliMode, CompiledCliOption, + CompiledCliProjection, CompiledCliSurface, CompiledLayout, CompiledLayoutScope, @@ -121,6 +147,9 @@ export type { AgentProviderWorkspaceIdentity, AppRouteConfig, CanonicalAgentEvent, + CliProjectionConfig, + CliProjectionFlagConfig, + CliProjectionFlagDefault, CliRouteConfig, CliRouteProps, PromptConfig, @@ -128,6 +157,7 @@ export type { RouteMeta, RouteRenderConfig, RouteSchema, + RouteSchemaInputKey, RouteSchemaOutput, RouteUiMeta, ToolConfig, diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index a5ebada6f..c822d4f20 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -528,6 +528,79 @@ export interface CliRouteConfig { readonly render?: RouteRenderConfig; } +/** A literal a CLI projection may declare as `flags..default`: what the argv grammar itself can spell. */ +export type CliProjectionFlagDefault = + | boolean + | number + | string + | readonly (boolean | number | string)[]; + +/** + * How a CLI projection spells one canonical input key on argv. Every field + * is optional; an absent flag entry keeps the default policy (the kebab-cased + * key as `--option`, the schema's `.describe()` and `.default()`). + */ +export interface CliProjectionFlagConfig { + /** Extra long-form spellings (kebab-case, no leading dashes) accepted beside `name`. */ + readonly aliases?: readonly string[]; + /** + * A CLI-only default the shell fills in before the canonical `inputSchema` + * reads the input; on a canonical-required key it is legal only when the + * module exports `mapInput`, and the key is listed as relaxed. + */ + readonly default?: CliProjectionFlagDefault; + /** Help text; overrides the schema's `.describe()`. */ + readonly description?: string; + /** The CLI spelling (kebab-case, no leading dashes); default: the kebab-cased key. */ + readonly name?: string; + /** + * Relax a canonical-required key so the option may be omitted on argv; + * legal only when the module exports `mapInput`, which must then supply + * the key before the canonical schema validates. + */ + readonly required?: false; +} + +/** + * The keys of a schema's input object, as `keyof z.input` reads them: + * a zod schema declares `_input`; a schema declaring only `_output` (the + * structural {@link RouteSchema}) falls back to its output keys. + */ +export type RouteSchemaInputKey = Schema extends { readonly _input: infer Input } + ? keyof Input & string + : Schema extends RouteSchema + ? keyof Output & string + : string; + +/** + * The `config` export of a tool's CLI surface projection module, + * `src/mcp//tools/.cli.{ts,tsx}` (#596). The module is never a + * route: it projects the sibling tool route onto one idiomatic command whose + * identity stays the tool's. Every field must stay inside the static + * route-config grammar; `flags` and `positionals` name canonical keys of the + * tool's `inputSchema`, so declare it as + * `satisfies CliProjectionConfig` with + * `import type { inputSchema } from './.js'`. The module may also export + * a synchronous `mapInput(input)` the shell applies to the parsed argv before + * the canonical schema validates. + */ +export interface CliProjectionConfig>>> { + /** Alternative command names at the same nesting level (the `src/cli` alias rules apply). */ + readonly aliases?: readonly string[]; + /** Command path segments; default `[]`. Each must be a safe identity segment. */ + readonly command?: readonly string[]; + /** Require `--yes` before running; default: `!(annotations.readOnlyHint === true)` of the tool. */ + readonly confirm?: boolean; + /** Help text; default: the tool's `config.description`. */ + readonly description?: string; + /** Exit-code policy; default: the tool's `config.exitCode`, else `'zero'`. */ + readonly exitCode?: 'result' | 'zero'; + /** Per canonical key: the CLI spelling, aliases, description, default, and relaxed requirement. */ + readonly flags?: Partial, CliProjectionFlagConfig>>>; + /** Canonical keys consumed as bare arguments, in order (the `src/cli` positional rules apply). */ + readonly positionals?: readonly RouteSchemaInputKey[]; +} + /** * Props received by every routed CLI command's async default function. * diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index a9d257d12..48f6e5bf3 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -1,5 +1,5 @@ import type { Diagnostic } from '../core/diagnostics.ts'; -import type { CanonicalAgentEvent } from './public.ts'; +import type { CanonicalAgentEvent, CliProjectionFlagDefault } from './public.ts'; /** * Every route kind the conventional source tree can declare. Context @@ -43,6 +43,13 @@ export type { CapabilityEvidence, CapabilityState } from '../core/capabilities.t */ export const emptyRouteConfig: Readonly> = Object.freeze({}); +/** + * Every identity segment a route path contributes — a server or tool name, + * a CLI command segment, a projected `command` segment — must be a safe + * name: letters and digits, with inner `.`, `_`, and `-` only. + */ +export const safeIdentitySegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + export type RouteInputSchemaLiteral = | boolean | number @@ -210,9 +217,17 @@ export type CompiledCliMode = 'generated' | 'conventional' | 'conflict'; * positional entry consumes bare arguments in `positional` order instead. */ export interface CompiledCliOption { + /** Extra long-form `--spellings` accepted for this option (a CLI projection's `flags..aliases`). */ + readonly aliases?: readonly string[]; /** Accepted values of a `z.enum([...])` base. */ readonly choices?: readonly string[]; - /** The static `.default()` value, surfaced in generated help. */ + /** + * The effective default generated help shows: a CLI projection's + * `flags..default` when the projection declares one, else the schema's + * static `.default()`. Display only — the shell fills in + * `CompiledCliProjection.defaults` alone before `mapInput`; a schema + * default is zod's to apply when the canonical `inputSchema` parses. + */ readonly defaultValue?: unknown; /** The static `.describe('')` string, surfaced in generated help. */ readonly description?: string; @@ -223,10 +238,39 @@ export interface CompiledCliOption { readonly positional?: number; /** True for a `z.array(...)` schema: a repeatable option or the trailing variadic positional. */ readonly repeated: boolean; - /** True when the schema has neither `.optional()` nor `.default(...)`. */ + /** + * True when the schema has neither `.optional()` nor `.default(...)` and no + * CLI projection relaxed the key (`flags..required: false`, or a + * projection `default`). + */ readonly required: boolean; } +/** + * The explicit CLI surface projection of one tool route: the + * `.cli.{ts,tsx}` module beside it (#596). The command it compiles + * keeps the tool's identity (`CompiledCliCommand.routeId`); this records what + * the module contributes beyond the argv grammar already spelled by + * `options`. + */ +export interface CompiledCliProjection { + /** + * Canonical key → the projection's `flags..default` literal: the + * CLI-only default the shell fills in for an option absent from argv + * before `mapInput` runs, so the mapper sees the projection's value and + * nothing else stands in for an omission (a schema `.default()` is applied + * by zod, after `mapInput`). Present only when at least one flag declares + * `default`; keys sorted. + */ + readonly defaults?: Readonly>; + /** True when the module exports a `mapInput` function the shell applies before `inputSchema`. */ + readonly mapInput: boolean; + /** Project-relative POSIX path of the projection module. */ + readonly module: string; + /** Canonical-required keys made optional on the CLI (`flags..required: false` or a CLI `default`); sorted. */ + readonly relaxed?: readonly string[]; +} + /** * One executable command compiled from a `src/cli/**` route: nesting is the * path-derived identity (`cli:library/audit` -> `library audit`), metadata @@ -252,6 +296,12 @@ export interface CompiledCliCommand { readonly options: readonly CompiledCliOption[]; /** Command path segments below the CLI root (`['library', 'audit']`). */ readonly path: readonly string[]; + /** + * Present for a command compiled from a tool's `.cli.{ts,tsx}` + * projection module (#596); absent for `src/cli/**` routes and for the + * bulk `routes.mcpCommands` projection. + */ + readonly projection?: CompiledCliProjection; /** * The render budget the route declared in `config.render` (#454); a * projected MCP command inherits its tool's. Absent means the runtime @@ -272,6 +322,14 @@ export interface CompiledCliSurface { */ readonly commands?: readonly CompiledCliCommand[]; readonly mode: CompiledCliMode; + /** + * Command `routeId` → absolute path of its `.cli.{ts,tsx}` projection + * module, for the generated executable to bundle beside the route module. + * Build-side only: absolute paths never enter the graph digest, which + * covers the relative `CompiledCliProjection.module` instead. Present only + * when some command carries a projection. + */ + readonly projectionSources?: Readonly>; /** Backing routes for every compiled command; empty when `conventional` mode omits them. */ readonly routes: readonly CompiledAgentRoute[]; } diff --git a/packages/agent-bundle/src/rstest/setup-module.ts b/packages/agent-bundle/src/rstest/setup-module.ts index a588c69e2..b1ec56138 100644 --- a/packages/agent-bundle/src/rstest/setup-module.ts +++ b/packages/agent-bundle/src/rstest/setup-module.ts @@ -41,6 +41,15 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string .map((provider) => ` ${JSON.stringify(provider.id)}: () => import(${JSON.stringify(specifier(provider.source))}),`); const layoutLoaders = manifest.layouts .map((layout) => ` ${JSON.stringify(layout.id)}: () => import(${JSON.stringify(specifier(layout.source))}),`); + const projectionLoaders = manifest.cliCommands + .filter((command) => command.projection !== undefined) + .map((command) => ({ + routeId: command.routeId, + source: resolve(manifest.projectRoot, command.projection!.module), + })) + .sort((left, right) => left.routeId.localeCompare(right.routeId)) + .map((projection) => + ` ${JSON.stringify(projection.routeId)}: () => import(${JSON.stringify(specifier(projection.source))}),`); return [ '// @generated by agent-bundle/rstest. Do not edit: rerun Rstest to regenerate.', '//', @@ -58,6 +67,9 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string ...(providerLoaders.length === 0 ? [] : [' providerLoaders: {', ...providerLoaders, ' },']), + ...(projectionLoaders.length === 0 + ? [] + : [' projectionLoaders: {', ...projectionLoaders, ' },']), ...(manifest.state === undefined ? [] : [` stateLoader: () => import(${JSON.stringify(specifier(manifest.state.source))}),`]), diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index fc1f7f5cb..0ef961a07 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -20,7 +20,7 @@ import type * as AgentRuntime from '@agent-bundle/runtime'; import type { RegisteredRouteId } from '@agent-bundle/runtime'; -import { cliInputError, runGeneratedCliEntry } from '../cli-entry.ts'; +import { runGeneratedCliEntry } from '../cli-entry.ts'; import type { CliRenderedEvent } from '../cli-entry.ts'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; @@ -29,7 +29,13 @@ import { CLI_DISPATCH_PROOF_LEVEL, type AgentBundleTestManifest } from './manife import { parseCanonicalJsonLine, parseRenderedEventLines } from './output-modes.ts'; import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; -import { prepareCliRenderHost, type HarnessOptionsArguments, type RenderRouteContextInit } from './render.ts'; +import { + loadCliProjectionModule, + parseCliCommandInput, + prepareCliRenderHost, + type HarnessOptionsArguments, + type RenderRouteContextInit, +} from './render.ts'; import { harnessTerminal } from './terminal.ts'; import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts'; @@ -244,12 +250,12 @@ export const invokeCli = async ( recovery: 'Export both zod schemas from the command module; the routed CLI validates argv through them.', }); } - let parsed: unknown; - try { - parsed = module.inputSchema.parse(input); - } catch (error) { - throw cliInputError(command, input, error); - } + const parsed = parseCliCommandInput( + command, + module.inputSchema, + await loadCliProjectionModule(manifest, command), + input, + ); const root = process.cwd(); const plugin = harnessPluginRoot({ context, manifest, resolvePluginRoot: runtime.resolvePluginRoot }); // Same provider invocation the generated plain-command path builds (#366). @@ -292,9 +298,14 @@ export const invokeCli = async ( ...(renderHost === undefined ? {} : { - render: (command, input, execution) => { + render: async (command, input, execution) => { executed = command; - return renderHost.render(command, input, execution); + return renderHost.render( + command, + input, + execution, + await loadCliProjectionModule(manifest, command), + ); }, }), signal, diff --git a/packages/agent-bundle/src/test/registry.ts b/packages/agent-bundle/src/test/registry.ts index 55ed8ea80..b440364c4 100644 --- a/packages/agent-bundle/src/test/registry.ts +++ b/packages/agent-bundle/src/test/registry.ts @@ -22,8 +22,9 @@ const REGISTRY_SYMBOL = Symbol.for(AGENT_TEST_REGISTRY_SYMBOL_KEY); * 4: `providerLoaders` (conventional context providers mounted by the harness). * 5: `layoutLoaders` (conventional layouts composed around manifest renders). * 6: `manifest.scripts` (the script-dispatch level's inventory). + * 7: `projectionLoaders` (explicit CLI projection modules). */ -export const AGENT_TEST_REGISTRY_VERSION = 6; +export const AGENT_TEST_REGISTRY_VERSION = 7; export type AgentStateModuleLoader = () => Promise<{ readonly default: AgentStateDefinition; @@ -31,6 +32,7 @@ export type AgentStateModuleLoader = () => Promise<{ export type AgentProviderModuleLoader = () => Promise<{ readonly default?: unknown }>; export type AgentLayoutModuleLoader = () => Promise; +export type AgentProjectionModuleLoader = () => Promise>>; export interface AgentTestRouteRegistry { /** Lazy loaders keyed by compiled layout id (`layout:root`, `layout:mcp:`). */ @@ -38,6 +40,8 @@ export interface AgentTestRouteRegistry { /** Lazy loaders keyed by compiled route id, so a test only compiles the routes it renders. */ readonly loaders: Readonly>; readonly manifest: AgentBundleTestManifest; + /** Lazy loaders keyed by backing tool route id; present only for explicit CLI projections. */ + readonly projectionLoaders?: Readonly>; /** Lazy loaders keyed by compiled provider id; present only when the project declares providers. */ readonly providerLoaders?: Readonly>; readonly stateLoader?: AgentStateModuleLoader; @@ -128,6 +132,16 @@ export const registeredStateLoader = ( return registry.stateLoader; }; +/** The projection-module loader generated beside the registered manifest for one backing tool route id. */ +export const registeredProjectionLoader = ( + manifest: AgentBundleTestManifest, + routeId: string, +): AgentProjectionModuleLoader | undefined => { + const registry = registered(); + if (registry === undefined || !producedRegisteredLoaders(registry, manifest)) return undefined; + return registry.projectionLoaders?.[routeId]; +}; + /** The provider-module loader generated beside the registered manifest for one compiled provider id. */ export const registeredProviderLoader = ( manifest: AgentBundleTestManifest, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index a01f5caad..da60c2ed5 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import type * as AgentFlightServer from '@agent-bundle/runtime/flight/server'; import type * as AgentRuntime from '@agent-bundle/runtime'; @@ -25,7 +26,10 @@ import type { } from '@agent-bundle/runtime'; import type * as React from 'react'; -import { cliInputError } from '../cli-entry.ts'; +import { + CliInputError, + cliInputError, +} from '../cli-entry.ts'; import type { CliRenderedEvent, GeneratedCliRenderContext, @@ -44,6 +48,7 @@ import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers. import { routeKindTerminal } from './terminal.ts'; import { registeredManifestIdentity, + registeredProjectionLoader, registeredRouteLoader, registeredStateLoader, testManifest, @@ -1051,9 +1056,70 @@ export interface PreparedCliRenderHost { command: CompiledCliCommand, input: Readonly>, context: GeneratedCliRenderContext, + projectionModule?: Readonly>, ) => GeneratedCliRenderSession; } +/** Loads the dispatched command's explicit CLI projection through the generated registry. */ +export const loadCliProjectionModule = async ( + manifest: AgentBundleTestManifest, + command: CompiledCliCommand, +): Promise> | undefined> => { + if (command.projection === undefined) return undefined; + const modulePath = join(manifest.projectRoot, command.projection.module); + try { + const loader = registeredProjectionLoader(manifest, command.routeId); + if (loader !== undefined) return await loader(); + if (registeredManifestIdentity() !== undefined) { + throw new Error('The registered projection loaders belong to a different manifest.'); + } + return await import(pathToFileURL(modulePath).href) as Readonly>; + } catch (cause) { + throw new AgentTestError( + 'invalid-route-module', + `Unable to load CLI projection ${command.projection.module} for ${command.routeId}.`, + { + cause, + details: [`module path: ${modulePath}`], + recovery: 'Build the Rstest configuration with agentBundleRstest() so the projection is transformed with the project modules.', + }, + ); + } +}; + +/** + * Mirrors the generated bin's explicit defaults, mapping, and canonical + * validation boundary; confirmation is the shell's (`parseMcpCommandInput`). + */ +export const parseCliCommandInput = ( + command: CompiledCliCommand, + inputSchema: AgentRouteSchema, + projectionModule: Readonly> | undefined, + input: Readonly>, +): unknown => { + const withDefaults: Record = { ...input }; + for (const [key, value] of Object.entries(command.projection?.defaults ?? {})) { + if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; + } + let mapped: unknown = withDefaults; + if (command.projection?.mapInput === true) { + const mapInput = projectionModule?.['mapInput']; + if (typeof mapInput !== 'function') { + throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); + } + try { + mapped = mapInput(withDefaults); + } catch (error) { + throw new CliInputError(error instanceof Error ? error.message : String(error)); + } + } + try { + return inputSchema.parse(mapped); + } catch (error) { + throw cliInputError(command, mapped, error); + } +}; + /** * Accepts preloaded route modules and prepares the renderer and manifest * state before the synchronous generated-shell render factory is installed. @@ -1085,6 +1151,7 @@ export const prepareCliRenderHost = async ( command: CompiledCliCommand, input: Readonly>, execution: GeneratedCliRenderContext, + projectionModule?: Readonly>, ): GeneratedCliRenderSession => { const module = options.modules.get(command.routeId); if (module === undefined) { @@ -1107,22 +1174,17 @@ export const prepareCliRenderHost = async ( }, ); } - let parsed: unknown; - try { - parsed = module.inputSchema.parse(input); - } catch (error) { - throw cliInputError(command, input, error); - } + const parsed = parseCliCommandInput( + command, + module.inputSchema, + projectionModule, + input, + ); const commandName = command.path.join(' '); - const invocation: AgentRenderInvocation = command.mcp === undefined - ? { - kind: 'cli', - props: { args: execution.args, command: commandName }, - } - : { - kind: 'tool', - props: { input: parsed as never, operationId: command.routeId }, - }; + const invocation: AgentRenderInvocation = { + kind: 'cli', + props: { args: execution.args, command: commandName }, + }; const collected: AgentProgressUpdate[] = []; const descriptor = options.manifest.routes[command.routeId]; const dispatcher = createFlightDispatcher({ @@ -1160,20 +1222,12 @@ export const prepareCliRenderHost = async ( ...context, ...mounted.context, providers, - invocation: command.mcp === undefined - ? { - kind: 'cli', - operationId: command.routeId, - surface: commandName, - ...context.invocation, - } - : { - artifactEpoch: `${options.manifest.plugin.name}@${options.manifest.plugin.version}`, - kind: 'tool', - operationId: command.routeId, - surface: command.mcp.tool, - ...context.invocation, - }, + invocation: { + kind: 'cli', + operationId: command.routeId, + surface: commandName, + ...context.invocation, + }, signal: request.signal, }; }, diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts new file mode 100644 index 000000000..8ee6b3c4e --- /dev/null +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -0,0 +1,797 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import type { AgentBundleConfig } from '../src/core/types.ts'; +import { + classifyCliProjectionModule, + extractCliProjection, + isMisplacedCliProjectionModule, +} from '../src/routes/cli-projection.ts'; +import { compileRouteGraph } from '../src/routes/graph.ts'; +import type { CompiledAgentRoute } from '../src/routes/types.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const createRoot = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-cli-projection-'))); + roots.push(root); + return root; +}; + +const writeTree = async (root: string, files: Readonly>): Promise => { + for (const [path, contents] of Object.entries(files)) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + } +}; + +const fixtureConfig = (extra: Readonly> = {}): AgentBundleConfig => ({ + plugin: { name: 'projection-fixture', version: '1.0.0' }, + ...extra, +}); + +const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.code); + +const toolModule = (options: { + readonly config?: string; + readonly schema?: string; +} = {}): string => [ + `export const config = ${options.config ?? "{ description: 'Submit work.' }"};`, + `export const inputSchema = ${options.schema ?? 'z.object({ laneKey: z.string() }).strict()'};`, + 'export const resultSchema = z.object({ ok: z.boolean() });', + 'export default async function Tool() { return undefined; }', + '', +].join('\n'); + +const cliModule = (config: string, mapInput?: string): string => [ + `export const config = ${config};`, + ...(mapInput === undefined ? [] : [mapInput]), + '', +].join('\n'); + +const projectionPath = 'src/mcp/demo/tools/submit.cli.ts'; +const toolPath = 'src/mcp/demo/tools/submit.tsx'; + +const compileProjection = async ( + projection: string, + options: { + readonly config?: AgentBundleConfig; + readonly extraFiles?: Readonly>; + readonly tool?: string; + } = {}, +) => { + const root = await createRoot(); + await writeTree(root, { + [projectionPath]: projection, + [toolPath]: options.tool ?? toolModule(), + ...options.extraFiles, + }); + return { + graph: await compileRouteGraph(root, options.config ?? fixtureConfig()), + root, + }; +}; + +const expectOnlyDiagnostic = ( + graph: Awaited>, + code: string, + root: string, + fragments: readonly string[], + source = projectionPath, +): void => { + expect(codesOf(graph.diagnostics)).toEqual([code]); + expect(graph.diagnostics[0]).toMatchObject({ + severity: 'error', + sourcePath: join(root, source), + }); + for (const fragment of fragments) { + expect(graph.diagnostics[0]!.message).toContain(fragment); + } +}; + +describe('MCP tool CLI surface projections', () => { + it('pairs a tool projection without creating a route or contract binding for the projection', async () => { + expect(classifyCliProjectionModule(projectionPath)).toEqual({ + server: 'demo', + siblingId: 'tool:demo/submit', + stem: 'submit', + }); + expect(classifyCliProjectionModule(toolPath)).toBeUndefined(); + expect(isMisplacedCliProjectionModule('src/mcp/demo/resources/submit.cli.ts')).toBe(true); + expect(isMisplacedCliProjectionModule(projectionPath)).toBe(false); + + const { graph, root } = await compileProjection(cliModule('{}')); + + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.commands).toHaveLength(1); + expect(graph.cli?.commands?.[0]).toMatchObject({ + projection: { mapInput: false, module: projectionPath }, + routeId: 'tool:demo/submit', + }); + expect(graph.cli?.routes.map((route) => route.id)).toEqual(['tool:demo/submit']); + expect(graph.servers.flatMap((server) => server.routes).map((route) => route.id)) + .not.toContain('tool:demo/submit.cli'); + expect(graph.contracts?.flatMap((contract) => contract.routes)).toEqual(['tool:demo/submit']); + expect(graph.cli?.projectionSources).toEqual({ + 'tool:demo/submit': join(root, projectionPath), + }); + }); + + it('defaults the command to the tool name and accepts an explicit path with command aliases', async () => { + const defaulted = await compileProjection(cliModule('{}')); + expect(defaulted.graph.diagnostics).toEqual([]); + expect(defaulted.graph.cli?.commands?.[0]).toMatchObject({ + aliases: [], + path: ['submit'], + }); + + const explicit = await compileProjection(cliModule("{ aliases: ['send', 'ship'], command: ['req'] }")); + expect(explicit.graph.diagnostics).toEqual([]); + expect(explicit.graph.cli?.commands?.[0]).toMatchObject({ + aliases: ['send', 'ship'], + path: ['req'], + }); + }); + + it('maps renamed, repeated, positional, aliased, defaulted, and relaxed options precisely', async () => { + const schema = [ + 'z.object({', + ' argv: z.array(z.string()).min(1),', + ' cwd: z.string(),', + ' laneKey: z.string(),', + ' limit: z.number(),', + ' tickets: z.array(z.string()).optional(),', + ' verbose: z.boolean().default(false),', + '}).strict()', + ].join('\n'); + const projection = cliModule([ + '{', + " command: ['req'],", + " description: 'Submit from the CLI.',", + " positionals: ['argv'],", + ' flags: {', + " cwd: { required: false },", + " laneKey: { aliases: ['lane-key'], description: 'Choose a lane.', name: 'lane' },", + " limit: { default: 20 },", + " tickets: { name: 'ticket' },", + ' },', + '}', + ].join('\n'), 'export const mapInput = (input) => input;'); + const { graph } = await compileProjection(projection, { tool: toolModule({ schema }) }); + + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.commands).toEqual([{ + aliases: [], + description: 'Submit from the CLI.', + exitCode: 'zero', + mcp: { confirm: true, server: 'demo', tool: 'submit' }, + options: [ + { + key: 'argv', + kind: 'string', + option: 'argv', + positional: 0, + repeated: true, + required: true, + }, + { + key: 'cwd', + kind: 'string', + option: 'cwd', + repeated: false, + required: false, + }, + { + aliases: ['lane-key'], + description: 'Choose a lane.', + key: 'laneKey', + kind: 'string', + option: 'lane', + repeated: false, + required: true, + }, + { + defaultValue: 20, + key: 'limit', + kind: 'number', + option: 'limit', + repeated: false, + required: false, + }, + { + key: 'tickets', + kind: 'string', + option: 'ticket', + repeated: true, + required: false, + }, + { + defaultValue: false, + key: 'verbose', + kind: 'boolean', + option: 'verbose', + repeated: false, + required: false, + }, + { + description: 'Confirm running this mutation-capable MCP tool.', + key: 'yes', + kind: 'boolean', + option: 'yes', + repeated: false, + required: false, + }, + ], + path: ['req'], + projection: { + defaults: { limit: 20 }, + mapInput: true, + module: projectionPath, + relaxed: ['cwd', 'limit'], + }, + rendered: true, + routeId: 'tool:demo/submit', + }]); + }); + + it('records projection defaults apart from schema defaults, sorted, and omits the record without one', async () => { + const schema = [ + 'z.object({', + " mode: z.enum(['fast', 'full']).default('fast'),", + ' retries: z.number().optional(),', + ' tags: z.array(z.string()).optional(),', + '}).strict()', + ].join('\n'); + const projected = await compileProjection( + cliModule("{ flags: { mode: { default: 'full' }, tags: { default: ['a', 'b'] } } }"), + { tool: toolModule({ schema }) }, + ); + expect(projected.graph.diagnostics).toEqual([]); + const command = projected.graph.cli!.commands![0]!; + expect(command.projection).toEqual({ + defaults: { mode: 'full', tags: ['a', 'b'] }, + mapInput: false, + module: projectionPath, + }); + expect(Object.keys(command.projection!.defaults!)).toEqual(['mode', 'tags']); + expect(command.options.find((option) => option.key === 'mode')).toMatchObject({ defaultValue: 'full', required: false }); + expect(command.options.find((option) => option.key === 'retries')).not.toHaveProperty('defaultValue'); + + const schemaOnly = await compileProjection(cliModule('{}'), { tool: toolModule({ schema }) }); + expect(schemaOnly.graph.diagnostics).toEqual([]); + expect(schemaOnly.graph.cli?.commands?.[0]?.projection).toEqual({ mapInput: false, module: projectionPath }); + expect(schemaOnly.graph.cli?.commands?.[0]?.options.find((option) => option.key === 'mode')) + .toMatchObject({ defaultValue: 'fast' }); + }); + + it('derives confirmation and metadata defaults while honoring projection and tool overrides', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/demo/tools/override.cli.ts': cliModule('{ confirm: false }'), + 'src/mcp/demo/tools/override.tsx': toolModule({ + config: "{ description: 'Override confirmation.' }", + }), + 'src/mcp/demo/tools/read.cli.ts': cliModule("{ exitCode: 'zero' }"), + 'src/mcp/demo/tools/read.tsx': toolModule({ + config: "{ annotations: { readOnlyHint: true }, description: 'Read safely.', exitCode: 'result', render: { maxElapsedMs: 120000 } }", + }), + 'src/mcp/demo/tools/write.cli.ts': cliModule('{}'), + 'src/mcp/demo/tools/write.tsx': toolModule({ + config: "{ annotations: { readOnlyHint: false }, description: 'Write data.', exitCode: 'result' }", + }), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics).toEqual([]); + const commands = Object.fromEntries( + graph.cli!.commands!.map((command) => [command.routeId, command]), + ); + expect(commands['tool:demo/read']).toMatchObject({ + description: 'Read safely.', + exitCode: 'zero', + mcp: { confirm: false, server: 'demo', tool: 'read' }, + render: { maxElapsedMs: 120_000 }, + }); + expect(commands['tool:demo/read']!.options.map((option) => option.option)).not.toContain('yes'); + expect(commands['tool:demo/write']).toMatchObject({ + description: 'Write data.', + exitCode: 'result', + mcp: { confirm: true, server: 'demo', tool: 'write' }, + }); + expect(commands['tool:demo/write']!.options).toContainEqual( + expect.objectContaining({ key: 'yes', option: 'yes' }), + ); + expect(commands['tool:demo/override']).toMatchObject({ + description: 'Override confirmation.', + exitCode: 'zero', + mcp: { confirm: false, server: 'demo', tool: 'override' }, + }); + expect(commands['tool:demo/override']!.options.map((option) => option.option)).not.toContain('yes'); + }); + + it('reports AB4843 for orphan, duplicate, and misplaced projections while private projections stay parked', async () => { + const orphanRoot = await createRoot(); + await writeTree(orphanRoot, { + 'src/mcp/demo/tools/ghost.cli.ts': cliModule('{}'), + }); + const orphan = await compileRouteGraph(orphanRoot, fixtureConfig()); + expectOnlyDiagnostic( + orphan, + 'AB4843', + orphanRoot, + ['CLI projection src/mcp/demo/tools/ghost.cli.ts for tool:demo/ghost: has no sibling tool route'], + 'src/mcp/demo/tools/ghost.cli.ts', + ); + + const duplicate = await compileProjection(cliModule('{}'), { + extraFiles: { 'src/mcp/demo/tools/submit.cli.tsx': cliModule('{}') }, + }); + expectOnlyDiagnostic( + duplicate.graph, + 'AB4843', + duplicate.root, + [ + 'CLI projection src/mcp/demo/tools/submit.cli.tsx for tool:demo/submit: src/mcp/demo/tools/submit.cli.ts already projects this tool', + ], + 'src/mcp/demo/tools/submit.cli.tsx', + ); + expect(duplicate.graph.cli?.commands?.map((command) => command.projection?.module)).toEqual([projectionPath]); + + const misplacedRoot = await createRoot(); + await writeTree(misplacedRoot, { + 'src/mcp/demo/resources/submit.cli.ts': cliModule('{}'), + }); + const misplaced = await compileRouteGraph(misplacedRoot, fixtureConfig()); + expectOnlyDiagnostic( + misplaced, + 'AB4843', + misplacedRoot, + ['CLI projection src/mcp/demo/resources/submit.cli.ts: sits under resources/', 'tool'], + 'src/mcp/demo/resources/submit.cli.ts', + ); + expect(misplaced.diagnostics[0]!.message).not.toContain(' for tool:'); + + const parkedRoot = await createRoot(); + await writeTree(parkedRoot, { + 'src/mcp/demo/tools/_parked.cli.ts': cliModule('{}'), + }); + const parked = await compileRouteGraph(parkedRoot, fixtureConfig()); + expect(parked.diagnostics).toEqual([]); + expect(parked.cli).toBeUndefined(); + expect(parked.servers).toEqual([]); + }); + + it('reports AB4844 for non-static config, closed-shape, mapper, and required-relaxation violations', async () => { + const source = '/project/src/mcp/demo/tools/submit.cli.ts'; + const tool: CompiledAgentRoute = { + config: {}, + id: 'tool:demo/submit', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: toolPath }, + serverId: 'mcp:demo', + source: '/project/src/mcp/demo/tools/submit.tsx', + }; + const extracted = extractCliProjection( + 'export const config = build();\n', + projectionPath, + source, + undefined, + tool, + { projectRoot: '/project' }, + ); + expect(codesOf(extracted.diagnostics)).toEqual(['AB4844']); + expect(extracted.diagnostics[0]).toMatchObject({ severity: 'error', sourcePath: source }); + expect(extracted.diagnostics[0]!.message).toContain(`CLI projection ${projectionPath}`); + expect(extracted.diagnostics[0]!.message).toContain('config'); + + const cases: readonly [projection: string, tool: string, fragments: readonly string[]][] = [ + [cliModule("{ render: { maxElapsedMs: 1000 } }"), toolModule(), ['config.render', 'unknown']], + [cliModule("{ command: 'submit' }"), toolModule(), ['config.command', 'array']], + [ + cliModule("{ flags: { laneKey: { required: false } } }"), + toolModule(), + ['flags.laneKey.required', 'mapInput'], + ], + [ + cliModule("{ flags: { laneKey: { default: 'main' } } }"), + toolModule(), + ['flags.laneKey.default', 'mapInput'], + ], + ]; + for (const [projection, toolSource, fragments] of cases) { + const result = await compileProjection(projection, { tool: toolSource }); + expectOnlyDiagnostic(result.graph, 'AB4844', result.root, fragments); + } + }); + + describe('mapInput must be a synchronous, non-generator function with a runtime binding', () => { + const subject = `CLI projection ${projectionPath} for tool:demo/submit: mapInput`; + + const expectRejectedMapInput = async (mapInput: string, fragments: readonly string[]): Promise => { + const { graph, root } = await compileProjection(cliModule('{}', mapInput)); + expectOnlyDiagnostic(graph, 'AB4844', root, [subject, ...fragments]); + expect(graph.diagnostics[0]!.recovery).toContain('synchronous, non-generator function'); + expect(graph.cli?.commands).toEqual([]); + expect(graph.cli?.projectionSources).toBeUndefined(); + }; + + it('rejects an ambient function declaration, which emits no runtime binding', async () => { + await expectRejectedMapInput( + 'export declare function mapInput(input: { laneKey?: string }): { laneKey: string };', + ['ambient declaration', 'declare function', 'no runtime binding'], + ); + }); + + it('rejects an ambient const declaration', async () => { + await expectRejectedMapInput( + 'export declare const mapInput: (input: { laneKey?: string }) => { laneKey: string };', + ['ambient declaration', 'declare const', 'no runtime binding'], + ); + }); + + it('rejects a locally declared ambient function exported by name', async () => { + await expectRejectedMapInput( + 'declare function mapInput(input: { laneKey?: string }): { laneKey: string };\nexport { mapInput };', + ['ambient declaration'], + ); + }); + + it('rejects a generator function', async () => { + await expectRejectedMapInput( + 'export function* mapInput(input) { yield input; }', + ['is a generator function', 'iterator instead of returning the mapped input'], + ); + }); + + it('rejects an async generator function', async () => { + await expectRejectedMapInput( + 'export async function* mapInput(input) { yield input; }', + ['is an async generator function', 'async iterator'], + ); + }); + + it('rejects a generator function expression', async () => { + await expectRejectedMapInput( + 'export const mapInput = function* (input) { yield input; };', + ['is a generator function'], + ); + }); + + it('rejects an async arrow function', async () => { + await expectRejectedMapInput( + 'export const mapInput = async (input) => input;', + ['is an async function', 'Promise', 'synchronously'], + ); + }); + + it('rejects an async function declaration', async () => { + await expectRejectedMapInput( + 'export async function mapInput(input) { return input; }', + ['is an async function', 'synchronously'], + ); + }); + + it('rejects a const that is not statically a function', async () => { + await expectRejectedMapInput( + 'export const mapInput = pipe(identity);', + ['is exported but is not statically a function'], + ); + }); + + it('rejects an overload signature with no implementation, which emits no runtime binding', async () => { + await expectRejectedMapInput( + 'export function mapInput(input: { laneKey?: string }): { laneKey: string };', + ['is exported but is not statically a function'], + ); + }); + + it('rejects a re-export the scan cannot follow, and follows one it can', async () => { + await expectRejectedMapInput( + "export { mapInput } from 'mapper-package';", + ['is re-exported from "mapper-package"', 'cannot be followed statically'], + ); + await expectRejectedMapInput( + "export { mapInput } from './missing-mapper.ts';", + ['is re-exported from "./missing-mapper.ts"'], + ); + + // The shared module sits outside src/mcp so discovery never reads it as a route. + const reExport = "export { mapInput } from '../../../shared/mapper.ts';"; + const declaredAmbient = await compileProjection(cliModule('{}', reExport), { + extraFiles: { 'src/shared/mapper.ts': 'export declare function mapInput(input: unknown): unknown;\n' }, + }); + expectOnlyDiagnostic(declaredAmbient.graph, 'AB4844', declaredAmbient.root, [subject, 'ambient declaration']); + expect(declaredAmbient.graph.cli?.commands).toEqual([]); + + const followed = await compileProjection(cliModule('{}', reExport), { + extraFiles: { 'src/shared/mapper.ts': 'export const mapInput = (input) => input;\n' }, + }); + expect(followed.graph.diagnostics).toEqual([]); + expect(followed.graph.cli?.commands?.[0]?.projection).toEqual({ mapInput: true, module: projectionPath }); + }); + + it('accepts a function declaration, an arrow, a function expression, an exported alias, and an overloaded declaration', async () => { + const accepted: readonly [form: string, mapInput: string][] = [ + ['function declaration', 'export function mapInput(input) { return input; }'], + ['arrow', 'export const mapInput = (input) => input;'], + ['function expression', 'export const mapInput = function (input) { return input; };'], + ['parenthesized arrow with a satisfies clause', 'export const mapInput = ((input) => input) satisfies (input: unknown) => unknown;'], + ['local function exported by name', 'function mapInput(input) { return input; }\nexport { mapInput };'], + ['local arrow exported under the name', 'const toInput = (input) => input;\nexport { toInput as mapInput };'], + [ + 'overloaded function declaration', + [ + 'export function mapInput(input: string): { laneKey: string };', + 'export function mapInput(input: { laneKey?: string }): { laneKey: string };', + 'export function mapInput(input: unknown) { return input; }', + ].join('\n'), + ], + ]; + for (const [form, mapInput] of accepted) { + const { graph } = await compileProjection(cliModule('{}', mapInput)); + expect(graph.diagnostics, form).toEqual([]); + expect(graph.cli?.commands?.[0]?.projection, form).toEqual({ mapInput: true, module: projectionPath }); + } + }); + }); + + it('reports AB4845 for unknown keys, invalid spellings, collisions, unsafe paths, and reserved yes', async () => { + const twoKeys = toolModule({ + schema: 'z.object({ first: z.string().optional(), second: z.string().optional() }).strict()', + }); + const cases: readonly [projection: string, tool: string, fragments: readonly string[]][] = [ + [cliModule("{ flags: { nope: {} } }"), toolModule(), ['flags.nope', 'input']], + [cliModule("{ positionals: ['nope'] }"), toolModule(), ['positionals', 'nope']], + [cliModule("{ flags: { laneKey: { name: 'json' } } }"), toolModule(), ['--json', 'reserved']], + [cliModule("{ flags: { laneKey: { name: 'Lane' } } }"), toolModule(), ['Lane', 'kebab-case']], + [ + cliModule("{ flags: { first: { name: 'same' }, second: { name: 'same' } } }"), + twoKeys, + ['--same', 'both'], + ], + [ + cliModule("{ flags: { first: { aliases: ['second'] } } }"), + twoKeys, + ['--second', 'collid'], + ], + [cliModule("{ command: ['bad segment!'] }"), toolModule(), ['bad segment!', 'safe']], + [cliModule("{ flags: { laneKey: { name: 'yes' } } }"), toolModule(), ['--yes', 'reserved']], + ]; + for (const [projection, toolSource, fragments] of cases) { + const result = await compileProjection(projection, { tool: toolSource }); + expectOnlyDiagnostic(result.graph, 'AB4845', result.root, fragments); + } + }); + + it('reports AB4845 when a confirming command projects a tool whose contract has a key yes, whatever its spelling', async () => { + const confirming = toolModule({ + config: "{ annotations: { readOnlyHint: false }, description: 'Submit work.' }", + schema: 'z.object({ laneKey: z.string(), yes: z.string() }).strict()', + }); + for (const projection of [cliModule('{}'), cliModule("{ flags: { yes: { name: 'assent' } } }")]) { + const result = await compileProjection(projection, { tool: confirming }); + expectOnlyDiagnostic(result.graph, 'AB4845', result.root, [ + `CLI projection ${projectionPath} for tool:demo/submit:`, + 'key "yes"', + 'confirming command reserves', + 'set confirm: false or rename the key', + ]); + expect(result.graph.diagnostics[0]!.recovery).toContain('confirm: false'); + expect(result.graph.cli?.commands).toEqual([]); + } + + const unconfirmed = await compileProjection(cliModule('{ confirm: false }'), { tool: confirming }); + expect(unconfirmed.graph.diagnostics).toEqual([]); + expect(unconfirmed.graph.cli?.commands?.[0]?.options.map((option) => option.option)).toEqual(['lane-key', 'yes']); + expect(unconfirmed.graph.cli?.commands?.[0]?.options.find((option) => option.key === 'yes')) + .toMatchObject({ kind: 'string', required: true }); + + // With no confirmation the key is the application's, so the projection + // may respell it like any other; the canonical key stays `yes`. + const renamed = await compileProjection( + cliModule("{ confirm: false, flags: { yes: { name: 'assume' } } }"), + { tool: confirming }, + ); + expect(renamed.graph.diagnostics).toEqual([]); + expect(renamed.graph.cli?.commands?.[0]?.options.find((option) => option.key === 'yes')) + .toMatchObject({ key: 'yes', kind: 'string', option: 'assume' }); + }); + + it('rejects name and aliases on a positional key with AB4845 while description, default, and required stay legal', async () => { + const schema = 'z.object({ argv: z.array(z.string()).min(1), cwd: z.string().optional() }).strict()'; + const rejected: readonly [projection: string, fragments: readonly string[]][] = [ + [cliModule("{ positionals: ['argv'], flags: { argv: { name: 'command' } } }"), ['config.flags.argv is positional; name does not apply']], + [cliModule("{ positionals: ['argv'], flags: { argv: { aliases: ['command'] } } }"), ['config.flags.argv is positional; aliases do not apply']], + [ + cliModule("{ positionals: ['argv'], flags: { argv: { aliases: ['command'], name: 'cmd' } } }"), + ['config.flags.argv is positional; name and aliases do not apply'], + ], + ]; + for (const [projection, fragments] of rejected) { + const result = await compileProjection(projection, { tool: toolModule({ schema }) }); + expectOnlyDiagnostic(result.graph, 'AB4845', result.root, fragments); + expect(result.graph.diagnostics[0]!.recovery).toContain('config.positionals'); + } + + const legal = await compileProjection( + cliModule( + "{ positionals: ['argv'], flags: { argv: { default: ['ls'], description: 'The command line.', required: false } } }", + 'export const mapInput = (input) => input;', + ), + { tool: toolModule({ schema }) }, + ); + expect(legal.graph.diagnostics).toEqual([]); + expect(legal.graph.cli?.commands?.[0]?.options.find((option) => option.key === 'argv')).toEqual({ + defaultValue: ['ls'], + description: 'The command line.', + key: 'argv', + kind: 'string', + option: 'argv', + positional: 0, + repeated: true, + required: false, + }); + expect(legal.graph.cli?.commands?.[0]?.projection).toEqual({ + defaults: { argv: ['ls'] }, + mapInput: true, + module: projectionPath, + relaxed: ['argv'], + }); + }); + + it('excludes explicit projections from bulk MCP commands and diagnoses projected-only includes', async () => { + const all = await compileProjection(cliModule('{}'), { + config: fixtureConfig({ routes: { mcpCommands: true } }), + }); + expect(all.graph.diagnostics).toEqual([]); + expect(all.graph.cli?.commands?.map((command) => ({ + path: command.path, + projection: command.projection, + routeId: command.routeId, + }))).toEqual([{ + path: ['submit'], + projection: { mapInput: false, module: projectionPath }, + routeId: 'tool:demo/submit', + }]); + expect(all.graph.cli?.commands?.map((command) => command.path.join(' '))) + .not.toContain('demo submit'); + + const selected = await compileProjection(cliModule('{}'), { + config: fixtureConfig({ + routes: { mcpCommands: { include: ['demo:submit'] } }, + }), + }); + expect(codesOf(selected.graph.diagnostics)).toEqual(['AB4822']); + expect(selected.graph.diagnostics[0]!.message).toContain('demo:submit'); + expect(selected.graph.diagnostics[0]!.message).toContain(projectionPath); + expect(selected.graph.cli?.commands?.filter((command) => + command.routeId === 'tool:demo/submit')).toHaveLength(1); + }); + + it('reports AB4813 when a projection command collides with a conventional CLI route', async () => { + const { graph } = await compileProjection(cliModule("{ command: ['status'] }"), { + extraFiles: { + 'src/cli/status.ts': [ + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({});', + 'export default async function Status() { return undefined; }', + '', + ].join('\n'), + }, + }); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4813']); + expect(graph.diagnostics[0]!.message).toContain('status'); + expect(graph.diagnostics[0]!.message).toContain(projectionPath); + expect(graph.diagnostics[0]!.recovery).toContain(projectionPath); + expect(graph.diagnostics[0]!.recovery).toContain('command'); + }); + + it('relabels AB4814 and AB4838 for projected tools without a static contract', async () => { + const nested = await compileProjection(cliModule('{}'), { + tool: toolModule({ + schema: 'z.object({ nested: z.object({ a: z.string() }) }).strict()', + }), + }); + expectOnlyDiagnostic( + nested.graph, + 'AB4814', + nested.root, + [`Tool route ${toolPath} (CLI projection ${projectionPath})`, 'z.object'], + toolPath, + ); + + const external = await compileProjection(cliModule('{}'), { + tool: [ + "import { external } from 'schema-package';", + "export const config = { description: 'Submit work.' };", + 'export const inputSchema = external;', + 'export const resultSchema = z.object({ ok: z.boolean() });', + 'export default async function Tool() { return undefined; }', + '', + ].join('\n'), + }); + expectOnlyDiagnostic( + external.graph, + 'AB4838', + external.root, + [ + `Tool route ${toolPath} (CLI projection ${projectionPath})`, + 'inputSchema -> external', + // The reason is input-schema.ts's existing AB4838 wording, relabelled. + 'imported from "schema-package", which is not a relative module path', + ], + toolPath, + ); + }); + + it('reports AB4837 when a projection value-imports the compiler-bearing API entry', async () => { + const { graph, root } = await compileProjection([ + "import { defineConfig } from 'agent-bundle/api';", + 'void defineConfig;', + 'export const config = {};', + '', + ].join('\n')); + + expectOnlyDiagnostic( + graph, + 'AB4837', + root, + ['CLI projection module', projectionPath, 'agent-bundle/api'], + ); + }); + + it('keeps absolute projection sources out of the digest and includes relative option policy', async () => { + const first = await compileProjection(cliModule('{}')); + const second = await compileProjection(cliModule('{}')); + expect(first.root).not.toBe(second.root); + expect(first.graph.cli?.projectionSources).not.toEqual(second.graph.cli?.projectionSources); + expect(first.graph.digest).toBe(second.graph.digest); + + const renamed = await compileProjection(cliModule( + "{ flags: { laneKey: { name: 'lane' } } }", + )); + expect(renamed.graph.diagnostics).toEqual([]); + expect(renamed.graph.cli?.commands?.[0]?.options).toContainEqual( + expect.objectContaining({ key: 'laneKey', option: 'lane' }), + ); + expect(renamed.graph.digest).not.toBe(first.graph.digest); + }); + + it('reports AB4804 when routes.cli keeps a conventional src/cli entry beside a projection module', async () => { + const { graph } = await compileProjection(cliModule('{}'), { + config: fixtureConfig({ routes: { cli: 'conventional' } }), + extraFiles: { 'src/cli.ts': 'export const main = async () => 0;\n' }, + }); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4804']); + expect(graph.diagnostics[0]!.message).toBe( + `CLI projection modules (${projectionPath}) require a generated CLI surface, but routes.cli is conventional.`, + ); + expect(graph.cli).toEqual({ mode: 'conventional', routes: [] }); + }); + + it('silently skips a projection whose sibling belongs to a custom server override', async () => { + const { graph } = await compileProjection(cliModule('{}'), { + config: fixtureConfig({ routes: { servers: { demo: 'custom' } } }), + }); + + expect(graph.diagnostics).toEqual([]); + expect(graph.cli).toBeUndefined(); + expect(graph.servers).toEqual([{ + id: 'mcp:demo', + mode: 'custom', + name: 'demo', + routes: [], + }]); + }); +}); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index ebf7494ab..938927c5d 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -1,13 +1,15 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, realpath, 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 { afterAll, afterEach, beforeAll, describe, expect, it } from '@rstest/core'; -import { build, validate } from '../src/api.ts'; +import { build, type ReadyInspectResult, validate } from '../src/api.ts'; +import { runCli } from '../src/cli.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; const execFile = promisify(executeFile); const roots: string[] = []; @@ -203,7 +205,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', - "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string(), operationId: z.string(), tooling: z.string(), view: z.unknown() }).strict();", + "export const resultSchema = z.object({ invocation: z.enum(['cli', 'tool']), message: z.string(), operationId: z.string(), tooling: z.string(), view: z.unknown() }).strict();", 'export default async function Lookup({ input }) {', ' const context = await agent();', " await context.progress.report({ completed: 1, message: 'lookup', total: 1 });", @@ -217,7 +219,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 "import { z } from 'zod';", "export const config = { description: 'Applies one value.' };", 'export const inputSchema = z.object({ value: z.string() }).strict();', - "export const resultSchema = z.object({ invocation: z.literal('tool'), operationId: z.string(), value: z.string() }).strict();", + "export const resultSchema = z.object({ invocation: z.enum(['cli', 'tool']), operationId: z.string(), value: z.string() }).strict();", 'export default async function Apply({ input }) {', ' const context = await agent();', ' const result = { invocation: context.invocation.kind, operationId: context.invocation.operationId, value: input.value };', @@ -353,13 +355,13 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 const projectedJson = await execFile(binPath, [ 'harness', 'lookup', '--input', '{"message":"packed"}', '--json', ]); - // The projected command's provider sees `invocation.kind === 'tool'`, not the - // CLI surface it was typed on (#319 review). + // The projected command and provider both see the CLI surface; the tool id + // remains the operation identity. expect(JSON.parse(projectedJson.stdout)).toEqual({ - invocation: 'tool', + invocation: 'cli', message: 'packed', operationId: 'tool:harness/lookup', - tooling: 'tool:ffprobe 6.1', + tooling: 'cli:ffprobe 6.1', view: providerView, }); const projectedNdjson = await execFile(binPath, [ @@ -381,7 +383,7 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 'harness', 'apply', '--input', '{"value":"allowed"}', '--yes', '--json', ]); expect(JSON.parse(projectedMutation.stdout)).toEqual({ - invocation: 'tool', + invocation: 'cli', operationId: 'tool:harness/apply', value: 'allowed', }); @@ -482,3 +484,265 @@ it('refuses a routed command that imports agent-bundle/api with AB4837 before bu await expect(stat(join(root, 'dist'))).rejects.toMatchObject({ code: 'ENOENT' }); await expect(stat(join(root, 'artifact'))).rejects.toMatchObject({ code: 'ENOENT' }); }); + +/** + * The CLI surface projection (#596) in a built executable: `submit.cli.ts` + * beside `src/mcp/demo/tools/submit.tsx` projects the tool onto ` submit` + * with an idiomatic grammar, the generated shell parses that grammar and + * applies `mapInput` before the tool's canonical schema, and `inspect --routes` + * reports the projection on the compiled command. One build serves every case. + */ +describe('the CLI surface projection in the generated routed-CLI executable', () => { + const projectionModule = 'src/mcp/demo/tools/submit.cli.ts'; + const usage = 'Usage: cli-projection-fixture submit [options] '; + let root: string; + let binPath: string; + let built: Awaited>; + + beforeAll(async () => { + // `process.cwd()` in the child is the resolved path; the fixture compares against it. + root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-cli-projection-'))); + binPath = join(root, 'dist', 'bin', 'cli-projection-fixture.js'); + 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: 'cli-projection-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'CLI projection fixture.', name: 'cli-projection-fixture', version: '1.0.0' },", + ' routes: { mcpCommands: true },', + " targets: ['portable'],", + '});', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/demo/tools/ping.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Answers a ping.' };", + 'export const inputSchema = z.object({}).strict();', + "export const resultSchema = z.object({ pong: z.literal(true) }).strict();", + 'export default async function Ping() {', + ' return pong;', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/demo/tools/purge.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: false }, description: 'Purges one cache target.' };", + 'export const inputSchema = z.object({ target: z.string().min(1) }).strict();', + "export const resultSchema = z.object({ operation: z.literal('purge'), target: z.string() }).strict();", + 'export default async function Purge({ input }) {', + " const value = { operation: 'purge', target: input.target };", + ' return {`purged: ${input.target}`};', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/demo/tools/purge.cli.ts', [ + "export const config = { command: ['purge'], positionals: ['target'] };", + 'export const mapInput = (input) => {', + " if ('yes' in input) throw new Error('mapInput received the confirmation flag.');", + ' return input;', + '};', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/demo/tools/submit.tsx', [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: false }, description: 'Submits one command line as lane work.' };", + // The application-owned optional `yes` key (#616): the projection + // declares confirm: false, so the shell strips nothing and the value + // must reach the tool through the canonical schema. + 'export const inputSchema = z.object({', + ' argv: z.array(z.string()).min(1),', + " cwd: z.string().min(1).default('.'),", + ' laneKey: z.string().optional(),', + ' tags: z.array(z.string()).optional(),', + ' yes: z.boolean().optional(),', + '});', + 'export const resultSchema = z.object({', + ' argv: z.array(z.string()).min(1),', + ' cwd: z.string().min(1),', + ' laneKey: z.string().optional(),', + " operation: z.literal('submit'),", + ' tags: z.array(z.string()).optional(),', + ' yes: z.boolean().optional(),', + '});', + 'export default async function Submit({ input }) {', + ' const { invocation } = await agent();', + " const value = { ...input, operation: 'submit' };", + ' return (', + ' ', + " {`submit: ${input.argv.join(' ')}`}", + ' {`invocation: ${invocation.kind} ${invocation.operationId} ${invocation.surface}`}', + ' ', + ' );', + '}', + '', + ].join('\n')), + writeProjectFile(root, projectionModule, [ + 'export const config = {', + " command: ['submit'],", + ' confirm: false,', + ' flags: {', + " cwd: { description: 'Working directory of the command (default: the current directory).', required: false },", + " laneKey: { name: 'lane' },", + " tags: { description: 'Tag attached to the request (repeatable; duplicates are dropped).', name: 'tag' },", + ' },', + " positionals: ['argv'],", + '};', + 'export const mapInput = (input) => {', + ' const tags = input.tags === undefined ? undefined : [...new Set(input.tags)];', + " const rejected = tags?.find((tag) => tag.startsWith('!'));", + ' if (rejected !== undefined) throw new Error(`Tag ${JSON.stringify(rejected)} must not start with "!".`);', + ' return { ...input, cwd: input.cwd ?? process.cwd(), ...(tags === undefined ? {} : { tags }) };', + '};', + '', + ].join('\n')), + ]); + built = await build({ output: 'artifact', packageOutputs: true, root }); + }, 120_000); + + afterAll(async () => { + await rm(root, { force: true, recursive: true }); + }); + + it('bundles the projection module into the executable and keeps the tool as the only route behind it', async () => { + expect(built.model.packageBuild?.bins).toMatchObject([ + { name: 'cli-projection-fixture', provenance: { kind: 'conventional' } }, + ]); + await expect(stat(binPath)).resolves.toMatchObject({}); + const evidence = built.packageBuild!.files.find((file) => file.path === 'bin/cli-projection-fixture.js'); + expect(evidence?.sourceInputs).toEqual(expect.arrayContaining([ + 'src/mcp/demo/tools/ping.tsx', + 'src/mcp/demo/tools/purge.cli.ts', + 'src/mcp/demo/tools/purge.tsx', + projectionModule, + 'src/mcp/demo/tools/submit.tsx', + ])); + const generatedCli = built.model.packageBuild?.bins[0]?.generatedCli; + expect(generatedCli?.commands.map((command) => command.path.join(' ')).sort()).toEqual(['demo ping', 'purge', 'submit']); + expect(generatedCli?.routes.map((route) => route.id).sort()).toEqual(['tool:demo/ping', 'tool:demo/purge', 'tool:demo/submit']); + }); + + it('prints help with the short path, the projected spellings, the tool provenance, and the projection module', async () => { + const help = await execFile(binPath, ['submit', '--help'], { cwd: root }); + + expect(help.stdout).toContain(`${usage}\n`); + expect(help.stdout).toContain('Submits one command line as lane work.'); + expect(help.stdout).toContain('MCP tool: demo:submit'); + expect(help.stdout).toContain(`Projection: ${projectionModule}`); + expect(help.stdout).toMatch(/^ +/mu); + expect(help.stdout).toMatch(/^ +--cwd +Working directory of the command \(default: the current directory\)\. \[default: "\."\]$/mu); + expect(help.stdout).toMatch(/^ +--lane /mu); + expect(help.stdout).toMatch(/^ +--tag \.\.\. +Tag attached to the request/mu); + expect(help.stdout).not.toContain('requires --yes'); + expect(help.stdout).not.toContain('--input'); + expect(help.stdout).not.toContain('(required)'); + const tree = await execFile(binPath, ['--help'], { cwd: root }); + expect(tree.stdout).toMatch(/^ +submit +Submits one command line as lane work\.$/mu); + expect(tree.stdout).toMatch(/^ +demo /mu); + }); + + it('round-trips the projected grammar through --json: renamed flag, repeated flag, passthrough argv, derived cwd', async () => { + const submitted = await execFile(binPath, ['submit', '--lane', 'x', '--tag', 'a', '--tag', 'a', '--json', '--', 'cargo', 'check'], { cwd: root }); + expect(JSON.parse(submitted.stdout)).toEqual({ argv: ['cargo', 'check'], cwd: root, laneKey: 'x', operation: 'submit', tags: ['a'] }); + + const passthrough = await execFile(binPath, ['submit', '--cwd', '/tmp/elsewhere', '--json', '--', 'cargo', 'check', '-p', 'core', '--lane', 'literal'], { cwd: root }); + expect(JSON.parse(passthrough.stdout)).toEqual({ argv: ['cargo', 'check', '-p', 'core', '--lane', 'literal'], cwd: '/tmp/elsewhere', operation: 'submit' }); + + const piped = await execFile(binPath, ['submit', '--', 'cargo', 'check'], { cwd: root }); + expect(piped.stdout).toBe('submit: cargo check\n\ninvocation: cli tool:demo/submit submit\n'); + + const ping = await execFile(binPath, ['demo', 'ping', '--json'], { cwd: root }); + expect(JSON.parse(ping.stdout)).toEqual({ pong: true }); + }); + + it('requires and strips --yes before a confirming projection maps input', async () => { + await expect(execFile(binPath, ['purge', 'cache', '--json'], { cwd: root })).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining('MCP tool demo:purge is mutation-capable per its MCP annotations and requires --yes.'), + stdout: '', + }); + + const purged = await execFile(binPath, ['purge', '--yes', 'cache', '--json'], { cwd: root }); + expect(JSON.parse(purged.stdout)).toEqual({ operation: 'purge', target: 'cache' }); + expect(purged.stdout).not.toContain('yes'); + }); + + it('hands a non-confirming projection its application-owned --yes as tool input (#616)', async () => { + // The tool contract declares an optional `yes: z.boolean()` and the + // projection sets confirm: false, so `yes` belongs to the application: + // the shell strips nothing and the value crosses the canonical schema. + const affirmed = await execFile(binPath, ['submit', '--yes', '--json', '--', 'cargo', 'check'], { cwd: root }); + expect(JSON.parse(affirmed.stdout)).toEqual({ argv: ['cargo', 'check'], cwd: root, operation: 'submit', yes: true }); + + const absent = await execFile(binPath, ['submit', '--json', '--', 'cargo', 'check'], { cwd: root }); + expect(JSON.parse(absent.stdout)).toEqual({ argv: ['cargo', 'check'], cwd: root, operation: 'submit' }); + }); + + it('exits 2 from the packed shell when mapInput throws or the mapped input fails the canonical schema', async () => { + await expect(execFile(binPath, ['submit', '--tag', '!boom', '--', 'cargo', 'check'], { cwd: root })).rejects.toMatchObject({ + code: 2, + stderr: [ + 'Tag "!boom" must not start with "!".', + "Run 'cli-projection-fixture submit --help' for usage.", + '', + ].join('\n'), + stdout: '', + }); + await expect(execFile(binPath, ['submit', '--cwd', '', '--', 'cargo', 'check'], { cwd: root })).rejects.toMatchObject({ + code: 2, + stderr: [ + 'Invalid value for --cwd: expected non-empty string; received "".', + usage, + "Run 'cli-projection-fixture submit --help' for usage.", + '', + ].join('\n'), + stdout: '', + }); + await expect(execFile(binPath, ['submit', '--lane', 'x'], { cwd: root })).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining('Missing required argument: .'), + stdout: '', + }); + }); + + it('shows the projection on the compiled command through inspect --routes', async () => { + const terminal = captureCliTerminal(); + const code = await runCli(['inspect', '--root', root, '--routes', '--json'], terminal.output); + + expect(code).toBe(0); + const document = JSON.parse(terminal.stdout()) as ReadyInspectResult; + const commands = document.selected?.routes?.cli?.commands ?? []; + expect(commands.map((command) => command.path.join(' ')).sort()).toEqual(['demo ping', 'purge', 'submit']); + expect(commands.find((command) => command.routeId === 'tool:demo/submit')).toMatchObject({ + mcp: { confirm: false, server: 'demo', tool: 'submit' }, + options: [ + expect.objectContaining({ key: 'argv', option: 'argv', positional: 0, repeated: true, required: true }), + expect.objectContaining({ key: 'cwd', option: 'cwd', repeated: false, required: false }), + expect.objectContaining({ key: 'laneKey', option: 'lane', repeated: false, required: false }), + expect.objectContaining({ key: 'tags', option: 'tag', repeated: true, required: false }), + expect.objectContaining({ key: 'yes', kind: 'boolean', option: 'yes', repeated: false, required: false }), + ], + path: ['submit'], + projection: { mapInput: true, module: projectionModule }, + rendered: true, + routeId: 'tool:demo/submit', + }); + expect(commands.find((command) => command.routeId === 'tool:demo/ping')).not.toHaveProperty('projection'); + expect(document.selected?.routes?.servers.flatMap((server) => server.routes.map((route) => route.id))).toEqual([ + 'tool:demo/ping', + 'tool:demo/purge', + 'tool:demo/submit', + ]); + }); +}); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index b62082c8f..396e24448 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -325,8 +325,10 @@ describe('generated entry templates', () => { }); expect(webOnlyWithState).toBe(webOnly); - // A routed bin without `web` is byte-identical to the pre-#564 generator - // (hash of the same input on the parent commit's `entry-shell.ts`). + // A routed bin without `web` carries no web wiring: its bytes are those of + // the generator without #564 (hash of the same input on this commit's + // `entry-shell.ts`; #596's projection steps and `kind: 'cli'` request + // moved the pin from the pre-#564 value). const withoutWeb = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, @@ -334,7 +336,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('b4fea3c82a3f5b3ec5df4dcdb7496f5bbf030fb230ae1550dbd01b65936b2e9f'); + .toBe('b177c34fc9ef98e972b5f5db1296c01219634572a455796fcae30bfaf070ba72'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -700,7 +702,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat })).toBe(source); }); -it('generates projected MCP commands with the same tool invocation and request contract as the MCP server', () => { +it('generates bulk-projected MCP commands with the CLI invocation and preserves the tool route layout', () => { const route = { config: { annotations: { readOnlyHint: true } }, id: 'tool:curator/read_item', @@ -732,8 +734,8 @@ it('generates projected MCP commands with the same tool invocation and request c }); expect(source).toContain('import * as route0 from "/project/src/mcp/curator/tools/read_item.tsx"'); - expect(source).toContain("invocation: { kind: 'tool', props: { input: parsed, operationId: command.routeId } }"); - expect(source).toContain('request: { artifactEpoch: "route-fixture@1.2.3", kind: \'tool\', operationId: command.routeId, surface: command.mcp.tool }'); + expect(source).toContain("invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }"); + expect(source).toContain("request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') }"); expect(source).toContain('props: { input: parsed }'); // The worker mounts providers from `message.invocation`, so the render // message must carry the dispatched invocation (#319 review) and the @@ -742,6 +744,88 @@ it('generates projected MCP commands with the same tool invocation and request c expect(source).toContain('terminal: context.terminal,'); }); +it('imports explicit CLI projections and maps their input before canonical validation', () => { + const route = { + config: {}, + id: 'tool:curator/submit', + kind: 'tool' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/mcp/curator/tools/submit.tsx' }, + serverId: 'mcp:curator', + source: '/project/src/mcp/curator/tools/submit.tsx', + }; + const source = entryShellModule.generatedCliBinEntrySource({ + commands: [{ + aliases: [], + exitCode: 'zero', + mcp: { confirm: true, server: 'curator', tool: 'submit' }, + options: [ + { + defaultValue: '.', + key: 'cwd', + kind: 'string', + option: 'cwd', + repeated: false, + required: false, + }, + { + defaultValue: 'main', + key: 'laneKey', + kind: 'string', + option: 'lane', + repeated: false, + required: false, + }, + { + key: 'yes', + kind: 'boolean', + option: 'yes', + repeated: false, + required: false, + }, + ], + path: ['submit'], + projection: { + defaults: { laneKey: 'main' }, + mapInput: true, + module: 'src/mcp/curator/tools/submit.cli.ts', + relaxed: ['laneKey'], + }, + rendered: true, + routeId: route.id, + }], + plugin: { name: 'route-fixture', version: '1.2.3' }, + projectionSources: { + [route.id]: '/project/src/mcp/curator/tools/submit.cli.ts', + }, + routes: [route], + workerFile: 'route-fixture-flight.mjs', + }); + + expect(source).toContain('import * as projection0 from "/project/src/mcp/curator/tools/submit.cli.ts";'); + expect(source).toContain( + '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', + ); + const defaults = source.indexOf('for (const [key, value] of Object.entries(command.projection.defaults))'); + const mapping = source.indexOf('mapped = route.projection.mapInput(mapped)'); + const validation = source.indexOf('return route.module.inputSchema.parse(mapped)'); + expect(source).not.toContain('command.mcp?.confirm'); + expect(source).not.toContain('confirmationRequiredMessage'); + expect(source).not.toContain('delete mapped.yes'); + expect(defaults).toBeGreaterThan(-1); + expect(defaults).toBeLessThan(mapping); + expect(mapping).toBeLessThan(validation); + expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); + expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); + expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`)"); + expect(source).toContain('throw new CliInputError(error instanceof Error ? error.message : String(error));'); + expect(source).toContain( + "invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } }", + ); + expect(source).toContain( + "request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') }", + ); +}); + it('mounts the shell-probed terminal on every routed-CLI surface and forwards it under MCP and hooks (#511)', () => { const plainRoute = { config: {}, diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts index b6bbb68b4..5c32ae382 100644 --- a/packages/agent-bundle/tests/layout-build.test.ts +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -28,7 +28,7 @@ const lookupRoute = [ "import { z } from 'zod';", "export const config = { annotations: { readOnlyHint: true }, description: 'Looks up one value.' };", 'export const inputSchema = z.object({ message: z.string().default("ready") }).strict();', - "export const resultSchema = z.object({ invocation: z.literal('tool'), message: z.string() }).strict();", + "export const resultSchema = z.object({ invocation: z.enum(['cli', 'tool']), message: z.string() }).strict();", 'export default async function Lookup({ input }) {', ' const context = await agent();', ' const result = { invocation: context.invocation.kind, message: input.message };', @@ -205,7 +205,9 @@ it('composes the root and server layouts around every rendered surface of one bu }); // A projected MCP command keeps its tool route's server layout, and the - // route's own metadata merges beneath both layouts. + // route's own metadata merges beneath both layouts. The route table still + // says `tool` (`wrapped`), but the route and the layouts observe the CLI + // surface they ran from (`invocation`), unlike the MCP call above. const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"projected"}']); expect(projected.stdout).toBe('server: mcp:harness\n\nLookup: projected\n\n> shell: tool lookup\n'); const projectedEvents = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"events"}', '--ndjson']); @@ -214,9 +216,9 @@ it('composes the root and server layouts around every rendered surface of one bu .findLast((event) => event.type === 'complete'); expect(projectedComplete?.document).toMatchObject({ root: { - metadata: { from: 'route', invocation: 'tool', layout: 'harness', route: 'tool:harness/lookup', shell: 'layout-fixture', wrapped: 'tool' }, + metadata: { from: 'route', invocation: 'cli', layout: 'harness', route: 'tool:harness/lookup', shell: 'layout-fixture', wrapped: 'tool' }, }, - value: { invocation: 'tool', message: 'events' }, + value: { invocation: 'cli', message: 'events' }, }); await expect(execFile(binPath, ['harness', 'explode', '--input', '{}'])).rejects.toMatchObject({ code: 1, diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 87ffa2f3e..d5cedbe9b 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -203,6 +203,7 @@ it('serves compiled routes and durable state across packed process restarts', as 'plugin-root', 'publish-notice', 'strict-report', + 'submit', 'ticket', 'tooling', 'unavailable', diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts index a5d2b782c..497f45218 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-mcp.test.ts @@ -103,7 +103,7 @@ describe('projected MCP tools at the CLI dispatch level', () => { expect(allowed.exitCode).toBe(0); expect(cliJson(allowed)).toEqual({ executions: 1, - invocation: 'tool', + invocation: 'cli', marker: 'allowed', operationId: 'tool:harness/mutation-probe', }); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts new file mode 100644 index 000000000..0cd516518 --- /dev/null +++ b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from '@rstest/core'; + +import { cliJson, invokeCli } from '../../src/test/cli.ts'; +import { invokeMcpTool } from '../../src/test/mcp.ts'; +import { testManifest } from '../../src/test/registry.ts'; + +/** + * The explicit CLI surface projection (#596) at the `cli-dispatch` proof + * level: `src/mcp/harness/tools/submit.cli.tsx` projects `tool:harness/submit` + * onto `route-harness submit` with an idiomatic grammar (`--lane`, a + * repeatable `--tag`, trailing `argv` with `--` passthrough, `cwd` derived by + * `mapInput`). The operation itself is invoked once per surface — the routed + * CLI shell and the in-memory MCP server — and the two structured results are + * compared; every mapping the projection performs is its own case and + * exercises only the CLI grammar. + */ +const cwd = process.cwd(); +const usage = 'Usage: route-harness submit [options] '; +const helpHint = "Run 'route-harness submit --help' for usage."; +const providerLine = (kind: 'cli' | 'tool', surface: string): string => + `provider: ${JSON.stringify({ kind, surface, tool: 'ffprobe 6.1' })}`; + +describe('the CLI surface projection of tool:harness/submit', () => { + it('uses cli invocation kind for bulk and explicit CLI projections while MCP remains tool', async () => { + const bulk = await invokeCli([ + 'harness', + 'mutation-probe', + '--input', + '{"marker":"kind"}', + '--yes', + '--json', + ]); + const explicit = await invokeCli(['submit', '--', 'cargo', 'check']); + const mcp = await invokeMcpTool('mutation-probe', { input: { marker: 'kind' } }); + + expect((cliJson(bulk) as { readonly invocation: string }).invocation).toBe('cli'); + expect(explicit.stdout).toContain('invocation: cli tool:harness/submit submit'); + expect((mcp.structuredContent as { readonly invocation: string }).invocation).toBe('tool'); + }); + + it('compiles the projection module into one command whose route is the tool', () => { + const manifest = testManifest(); + const command = manifest.cliCommands.find((candidate) => candidate.routeId === 'tool:harness/submit'); + + expect(command).toEqual({ + aliases: [], + description: 'Submits one command line as lane work and echoes the accepted request.', + exitCode: 'zero', + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + options: [ + expect.objectContaining({ description: 'The command line to run.', key: 'argv', kind: 'string', option: 'argv', positional: 0, repeated: true, required: true }), + expect.objectContaining({ description: 'Working directory of the command (default: the current directory).', key: 'cwd', kind: 'string', option: 'cwd', repeated: false, required: false }), + expect.objectContaining({ description: 'Lane the work is queued under.', key: 'laneKey', kind: 'string', option: 'lane', repeated: false, required: false }), + expect.objectContaining({ description: 'Tag attached to the request (repeatable; duplicates are dropped).', key: 'tags', kind: 'string', option: 'tag', repeated: true, required: false }), + ], + path: ['submit'], + projection: { mapInput: true, module: 'src/mcp/harness/tools/submit.cli.tsx', relaxed: ['cwd'] }, + rendered: true, + routeId: 'tool:harness/submit', + }); + expect(manifest.cliCommands.map((candidate) => candidate.path.join(' '))).not.toContain('harness submit'); + expect(Object.keys(manifest.routes).filter((id) => id.includes('submit'))).toEqual(['tool:harness/submit']); + }); + + it('reaches the same operation with the same structured result from the projected grammar and the MCP surface', async () => { + const input = { argv: ['cargo', 'check'], cwd, laneKey: 'x', tags: ['a'] }; + const cli = await invokeCli(['submit', '--lane', 'x', '--tag', 'a', '--tag', 'a', '--json', '--', 'cargo', 'check']); + const mcp = await invokeMcpTool('submit', { input }); + + expect(cli.exitCode).toBe(0); + expect(cli.stderr).toBe(''); + expect(cli.command).toBe('submit'); + expect(cli.routeId).toBe('tool:harness/submit'); + expect(mcp.isError).toBe(false); + expect(cliJson(cli)).toEqual({ argv: ['cargo', 'check'], cwd, laneKey: 'x', operation: 'submit', tags: ['a'] }); + expect(cliJson(cli)).toEqual(mcp.structuredContent); + expect(cli.value).toEqual(mcp.structuredContent); + expect(mcp.content).toEqual([ + { text: 'submit: cargo check', type: 'text' }, + { text: 'invocation: tool tool:harness/submit submit', type: 'text' }, + { text: providerLine('tool', 'tool:harness/submit'), type: 'text' }, + ]); + }); + + it('spells the canonical laneKey as --lane and accepts no other spelling', async () => { + const run = await invokeCli(['submit', '--lane', 'x', '--json', '--', 'cargo', 'check']); + expect(run.exitCode).toBe(0); + expect(cliJson(run)).toMatchObject({ laneKey: 'x' }); + + const canonical = await invokeCli(['submit', '--lane-key', 'x', '--', 'cargo', 'check']); + expect(canonical.exitCode).toBe(2); + expect(canonical.stdout).toBe(''); + expect(canonical.stderr).toBe(['Unknown option: --lane-key.', helpHint, ''].join('\n')); + }); + + it('collects a repeated --tag into the canonical tags array in argv order', async () => { + const run = await invokeCli(['submit', '--tag', 'b', '--tag', 'a', '--json', '--', 'cargo', 'check']); + + expect(run.exitCode).toBe(0); + expect(cliJson(run)).toMatchObject({ tags: ['b', 'a'] }); + }); + + it('takes argv from the trailing positionals and passes everything after -- through untouched', async () => { + const bare = await invokeCli(['submit', 'cargo', 'check', '--json']); + expect(bare.exitCode).toBe(0); + expect(cliJson(bare)).toMatchObject({ argv: ['cargo', 'check'] }); + + const passthrough = await invokeCli(['submit', '--lane', 'x', '--json', '--', 'cargo', 'check', '-p', 'core', '--lane', 'literal']); + expect(passthrough.exitCode).toBe(0); + expect(cliJson(passthrough)).toMatchObject({ argv: ['cargo', 'check', '-p', 'core', '--lane', 'literal'], laneKey: 'x' }); + + // Without the separator a single-dash token belongs to the shell, which + // is why the projection documents `-- `. + const unknown = await invokeCli(['submit', 'cargo', 'check', '-p', 'core']); + expect(unknown.exitCode).toBe(2); + expect(unknown.stderr).toBe(['Unknown option: -p.', helpHint, ''].join('\n')); + + const missing = await invokeCli(['submit', '--lane', 'x']); + expect(missing.exitCode).toBe(2); + expect(missing.stdout).toBe(''); + expect(missing.value).toBeUndefined(); + expect(missing.stderr).toBe(['Missing required argument: .', helpHint, ''].join('\n')); + }); + + it('derives the relaxed cwd from the process through mapInput unless the CLI names one', async () => { + const derived = await invokeCli(['submit', '--json', '--', 'cargo', 'check']); + expect(derived.exitCode).toBe(0); + expect(cliJson(derived)).toMatchObject({ cwd }); + + const explicit = await invokeCli(['submit', '--cwd', '/tmp/elsewhere', '--json', '--', 'cargo', 'check']); + expect(explicit.exitCode).toBe(0); + expect(cliJson(explicit)).toMatchObject({ cwd: '/tmp/elsewhere' }); + }); + + it('de-duplicates tags in mapInput before the canonical schema sees them', async () => { + const run = await invokeCli(['submit', '--tag', 'a', '--tag', 'b', '--tag', 'a', '--json', '--', 'cargo', 'check']); + + expect(run.exitCode).toBe(0); + expect(cliJson(run)).toMatchObject({ tags: ['a', 'b'] }); + }); + + it('reports a thrown mapInput as an input failure: exit 2, nothing written to stdout, no value', async () => { + const run = await invokeCli(['submit', '--tag', '!boom', '--', 'cargo', 'check']); + expect(run.exitCode).toBe(2); + expect(run.stdout).toBe(''); + expect(run.value).toBeUndefined(); + expect(run.stderr).toBe(['Tag "!boom" must not start with "!".', helpHint, ''].join('\n')); + + const json = await invokeCli(['submit', '--tag', '!boom', '--json', '--', 'cargo', 'check']); + expect(json.exitCode).toBe(2); + expect(json.stdout).toBe(''); + expect(json.value).toBeUndefined(); + }); + + it('validates the mapped input against the canonical schema and spells issues with the CLI spelling', async () => { + const run = await invokeCli(['submit', '--lane', '', '--', 'cargo', 'check']); + expect(run.exitCode).toBe(2); + expect(run.stdout).toBe(''); + expect(run.stderr).toBe([ + 'Invalid value for --lane: expected non-empty string; received "".', + usage, + helpHint, + '', + ].join('\n')); + + const json = await invokeCli(['submit', '--lane', '', '--json', '--', 'cargo', 'check']); + expect(json.exitCode).toBe(2); + expect(json.stdout).toBe(''); + expect(JSON.parse(json.stderr)).toEqual({ + error: { + code: 'CLI_INPUT_INVALID', + issues: [{ expected: 'non-empty string', message: expect.any(String), received: '', target: '--lane' }], + usage, + }, + }); + }); + + it('runs without --yes because the projection sets confirm: false, and knows no --yes option', async () => { + const run = await invokeCli(['submit', '--', 'cargo', 'check']); + expect(run.exitCode).toBe(0); + expect(run.stderr).toBe(''); + + const yes = await invokeCli(['submit', '--yes', '--', 'cargo', 'check']); + expect(yes.exitCode).toBe(2); + expect(yes.stderr).toBe(['Unknown option: --yes.', helpHint, ''].join('\n')); + }); + + it('prints help with the short path, the projected spellings, the tool provenance, and the projection module', async () => { + const help = await invokeCli(['submit', '--help']); + + expect(help.exitCode).toBe(0); + expect(help.command).toBeUndefined(); + expect(help.stdout).toContain(`${usage}\n`); + expect(help.stdout).toContain('Submits one command line as lane work and echoes the accepted request.'); + expect(help.stdout).toContain('MCP tool: harness:submit'); + expect(help.stdout).toContain('Projection: src/mcp/harness/tools/submit.cli.tsx'); + expect(help.stdout).toMatch(/^ + +The command line to run\.$/mu); + expect(help.stdout).toMatch(/^ +--cwd +Working directory of the command \(default: the current directory\)\.$/mu); + expect(help.stdout).toMatch(/^ +--lane +Lane the work is queued under\.$/mu); + expect(help.stdout).toMatch(/^ +--tag \.\.\. +Tag attached to the request \(repeatable; duplicates are dropped\)\.$/mu); + expect(help.stdout).not.toContain('(required)'); + expect(help.stdout).not.toContain('requires --yes'); + for (const absent of ['--lane-key', '--tags', '--input', '--yes', 'route-harness harness submit']) { + expect(help.stdout).not.toContain(absent); + } + }); + + it('runs the tool under the cli invocation kind with the tool id as operationId, as the route and its provider observe', async () => { + const run = await invokeCli(['submit', '--', 'cargo', 'check']); + + expect(run.exitCode).toBe(0); + expect(run.stderr).toBe(''); + expect(run.stdout).toBe([ + 'submit: cargo check', + '', + 'invocation: cli tool:harness/submit submit', + '', + providerLine('cli', 'submit'), + '', + ].join('\n')); + }); +}); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 465538d7c..090b79da3 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from '@rstest/core'; import { agent } from '@agent-bundle/runtime'; +import { cliInputError, runGeneratedCliEntry } from '../../src/cli-entry.ts'; +import type { CompiledCliCommand } from '../../src/routes/types.ts'; import { cliJson, invokeCli } from '../../src/test/cli.ts'; /** @@ -15,6 +17,226 @@ import { cliJson, invokeCli } from '../../src/test/cli.ts'; * session contract that the generated executable wires around the shell. */ describe('the CLI dispatch level', () => { + it('requires confirmation for a projected mutation before dispatch and strips --yes from canonical input', async () => { + const command: CompiledCliCommand = { + aliases: [], + exitCode: 'zero', + mcp: { confirm: true, server: 'harness', tool: 'submit' }, + options: [{ + key: 'yes', + kind: 'boolean', + option: 'yes', + repeated: false, + required: false, + }], + path: ['submit'], + projection: { + mapInput: false, + module: 'src/mcp/harness/tools/submit.cli.tsx', + }, + rendered: false, + routeId: 'tool:harness/submit', + }; + const inputs: Readonly>[] = []; + const run = (argv: readonly string[]) => runGeneratedCliEntry({ + argv, + commands: [command], + execute: async (_compiled, input) => { + inputs.push(input); + return input; + }, + name: 'route-harness', + version: '1.0.0', + writeErr: () => undefined, + writeOut: () => undefined, + }); + + expect(await run(['submit'])).toBe(2); + expect(inputs).toEqual([]); + expect(await run(['submit', '--yes'])).toBe(0); + expect(inputs).toEqual([{}]); + }); + + it('hands a non-confirming projection its own canonical yes key untouched', async () => { + const command: CompiledCliCommand = { + aliases: [], + exitCode: 'zero', + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + options: [{ + key: 'yes', + kind: 'string', + option: 'yes', + repeated: false, + required: true, + }], + path: ['submit'], + projection: { + mapInput: false, + module: 'src/mcp/harness/tools/submit.cli.tsx', + }, + rendered: false, + routeId: 'tool:harness/submit', + }; + const inputs: Readonly>[] = []; + const code = await runGeneratedCliEntry({ + argv: ['submit', '--yes', 'affirmative'], + commands: [command], + execute: async (_compiled, input) => { + inputs.push(input); + return input; + }, + name: 'route-harness', + version: '1.0.0', + writeErr: () => undefined, + writeOut: () => undefined, + }); + + expect(code).toBe(0); + expect(inputs).toEqual([{ yes: 'affirmative' }]); + }); + + it('passes a non-confirming projection boolean --yes through as tool input', async () => { + const command: CompiledCliCommand = { + aliases: [], + exitCode: 'zero', + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + options: [{ + key: 'yes', + kind: 'boolean', + option: 'yes', + repeated: false, + required: false, + }], + path: ['submit'], + projection: { + mapInput: false, + module: 'src/mcp/harness/tools/submit.cli.tsx', + }, + rendered: false, + routeId: 'tool:harness/submit', + }; + const inputs: Readonly>[] = []; + const run = (argv: readonly string[]) => runGeneratedCliEntry({ + argv, + commands: [command], + execute: async (_compiled, input) => { + inputs.push(input); + return input; + }, + name: 'route-harness', + version: '1.0.0', + writeErr: () => undefined, + writeOut: () => undefined, + }); + + // The application owns `yes` here: nothing confirms, so nothing strips. + expect(await run(['submit', '--yes'])).toBe(0); + expect(await run(['submit'])).toBe(0); + expect(inputs).toEqual([{ yes: true }, {}]); + }); + + it('keeps the canonical yes key when a non-confirming projection renames its flag', async () => { + const command: CompiledCliCommand = { + aliases: [], + exitCode: 'zero', + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + options: [{ + key: 'yes', + kind: 'boolean', + option: 'assume', + repeated: false, + required: false, + }], + path: ['submit'], + projection: { + mapInput: false, + module: 'src/mcp/harness/tools/submit.cli.tsx', + }, + rendered: false, + routeId: 'tool:harness/submit', + }; + const inputs: Readonly>[] = []; + const errors: string[] = []; + const run = (argv: readonly string[]) => runGeneratedCliEntry({ + argv, + commands: [command], + execute: async (_compiled, input) => { + inputs.push(input); + return input; + }, + name: 'route-harness', + version: '1.0.0', + writeErr: (text) => void errors.push(text), + writeOut: () => undefined, + }); + + expect(await run(['submit', '--assume'])).toBe(0); + expect(inputs).toEqual([{ yes: true }]); + // The rename is the only spelling; the shell reserves --yes for + // confirming commands and this command does not confirm. + expect(await run(['submit', '--yes'])).toBe(2); + expect(errors[0]).toBe('Unknown option: --yes.\n'); + expect(inputs).toEqual([{ yes: true }]); + }); + + it('accepts projected option aliases, prints projection help, and spells schema failures as projected flags', async () => { + const command: CompiledCliCommand = { + aliases: [], + description: 'Submit work.', + exitCode: 'zero', + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + options: [{ + aliases: ['lane-key'], + key: 'laneKey', + kind: 'string', + option: 'lane', + repeated: false, + required: false, + }], + path: ['submit'], + projection: { + mapInput: false, + module: 'src/mcp/harness/tools/submit.cli.tsx', + }, + rendered: false, + routeId: 'tool:harness/submit', + }; + const run = async ( + argv: readonly string[], + execute: (input: Readonly>) => Promise, + ): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { + let stderr = ''; + let stdout = ''; + const code = await runGeneratedCliEntry({ + argv, + commands: [command], + execute: (_command, input) => execute(input), + name: 'route-harness', + version: '1.0.0', + writeErr: (text) => { stderr += text; }, + writeOut: (text) => { stdout += text; }, + }); + return { code, stderr, stdout }; + }; + + const dispatched = await run(['submit', '--lane-key', 'blue'], async (input) => input); + expect(dispatched).toEqual({ code: 0, stderr: '', stdout: '{"laneKey":"blue"}\n' }); + + const help = await run(['submit', '--help'], async () => ({})); + expect(help.code).toBe(0); + expect(help.stdout).toContain('MCP tool: harness:submit\nProjection: src/mcp/harness/tools/submit.cli.tsx'); + expect(help.stdout).toContain('--lane, --lane-key '); + + const invalid = await run(['submit', '--lane', 'blue'], async (input) => { + throw cliInputError(command, input, { + issues: [{ code: 'invalid_type', expected: 'number', message: 'Expected number', path: ['laneKey'] }], + }); + }); + expect(invalid.code).toBe(2); + expect(invalid.stderr).toContain('Invalid value for --lane: expected number; received "blue".'); + expect(invalid.stderr).not.toContain('--input.laneKey'); + }); + it('resolves an argv vector to the compiled command and returns its canonical JSON line', async () => { const run = await invokeCli(['inventory', 'fiction', '--format', 'json']); @@ -44,6 +266,7 @@ describe('the CLI dispatch level', () => { 'harness wait', 'inventory', 'report', + 'submit', 'tooling inspect', 'tooling report', ], diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index b382d1e4a..c70294790 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -33,7 +33,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'fault', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'plugin-root', 'publish-notice', 'strict-report', 'ticket', 'tooling', 'unavailable', 'wait']); + expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'fault', 'journal', 'layout-probe', 'lifecycle', 'mutation-probe', 'plugin-root', 'publish-notice', 'strict-report', 'submit', 'ticket', 'tooling', 'unavailable', 'wait']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -52,6 +52,7 @@ describe('the in-memory MCP projection level', () => { 'tool:harness/plugin-root', 'tool:harness/publish-notice', 'tool:harness/strict-report', + 'tool:harness/submit', 'tool:harness/ticket', 'tool:harness/tooling', 'tool:harness/unavailable', diff --git a/packages/agent-bundle/tests/projection/providers.test.ts b/packages/agent-bundle/tests/projection/providers.test.ts index 57c732e82..81dad58b9 100644 --- a/packages/agent-bundle/tests/projection/providers.test.ts +++ b/packages/agent-bundle/tests/projection/providers.test.ts @@ -91,13 +91,13 @@ describe('conventional providers through the harness', () => { }); }); - it('mounts providers for a projected MCP command with the tool invocation', async () => { + it('mounts providers for a bulk-projected MCP command with the cli invocation', async () => { const run = await invokeCli(['harness', 'tooling', '--json']); expect(run.exitCode).toBe(0); expect(cliJson(run)).toEqual({ keys, - libraryTooling: { kind: 'tool', surface: 'tool:harness/tooling', tool: 'ffprobe 6.1' }, + libraryTooling: { kind: 'cli', surface: 'harness tooling', tool: 'ffprobe 6.1' }, processLifetime: { hits: 1, instanceId: expect.any(String), pid: process.pid }, requestView: requestView({ mounted: true }), }); diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 115fb93f8..7246713c8 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -233,9 +233,91 @@ it('projects a compiled graph into the browser manifest with project-relative so mcp: { confirm: false, server: 'harness', tool: 'echo' }, routeId: 'tool:harness/echo', })); + expect(manifest.cli?.commands?.find((command) => command.routeId === 'tool:harness/echo')) + .not.toHaveProperty('projection'); expect(Object.isFrozen(manifest)).toBe(true); }); +it('projects a CLI surface projection and option aliases without leaking projectionSources', () => { + const input = Object.freeze({ + additionalProperties: false as const, + properties: Object.freeze({ + argv: Object.freeze({ items: Object.freeze({ type: 'string' as const }), type: 'array' as const }), + cwd: Object.freeze({ type: 'string' as const }), + laneKey: Object.freeze({ type: 'string' as const }), + }), + required: Object.freeze(['argv', 'cwd']), + type: 'object' as const, + }); + const toolRoute = { + config: {}, + id: 'tool:hauler/hauler_request', + inputSchema: input, + kind: 'tool' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/mcp/hauler/tools/hauler_request.tsx' }, + serverId: 'mcp:hauler', + source: '/project/src/mcp/hauler/tools/hauler_request.tsx', + }; + const graph: CompiledRouteGraph = { + ...emptyCompiledRouteGraph, + cli: { + commands: [{ + aliases: ['req'], + description: 'Submit a background cargo request', + exitCode: 'zero', + mcp: { confirm: false, server: 'hauler', tool: 'hauler_request' }, + options: [ + { key: 'argv', kind: 'string', option: 'argv', positional: 0, repeated: true, required: true }, + { key: 'cwd', kind: 'string', option: 'cwd', repeated: false, required: false }, + { aliases: ['lane-key'], key: 'laneKey', kind: 'string', option: 'lane', repeated: false, required: false }, + ], + path: ['request'], + projection: { + defaults: { cwd: '.', laneKey: ['main', 'next'] }, + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }, + rendered: true, + routeId: 'tool:hauler/hauler_request', + }], + mode: 'generated', + projectionSources: { + 'tool:hauler/hauler_request': '/project/src/mcp/hauler/tools/hauler_request.cli.ts', + }, + routes: [toolRoute], + }, + digest: 'p'.repeat(64), + servers: [{ + id: 'mcp:hauler', + mode: 'generated', + name: 'hauler', + routes: [toolRoute], + }], + }; + + const manifest = routeManifestFor(graph, revision); + const command = manifest.cli?.commands?.[0]; + + expect(command?.projection).toEqual({ + defaults: { cwd: '.', laneKey: ['main', 'next'] }, + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }); + expect(command?.projection?.defaults).not.toBe(graph.cli!.commands![0]!.projection!.defaults); + expect(command?.projection?.defaults?.['laneKey']).not.toBe(graph.cli!.commands![0]!.projection!.defaults!['laneKey']); + expect(Object.isFrozen(command?.projection?.defaults?.['laneKey'])).toBe(true); + expect(command?.options).toEqual([ + { key: 'argv', kind: 'string', option: 'argv', positional: 0, repeated: true, required: true }, + { key: 'cwd', kind: 'string', option: 'cwd', repeated: false, required: false }, + { aliases: ['lane-key'], key: 'laneKey', kind: 'string', option: 'lane', repeated: false, required: false }, + ]); + expect(manifest.cli).not.toHaveProperty('projectionSources'); + expect(Object.isFrozen(command?.projection)).toBe(true); + expect(Object.isFrozen(command?.options[2]?.aliases)).toBe(true); +}); + it('projects declared, default, and dynamic state budgets without fabricating absent state', () => { const declared = routeManifestFor( emptyCompiledRouteGraph, diff --git a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts index a4392ffd5..54170d6b4 100644 --- a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts +++ b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts @@ -98,6 +98,11 @@ export const routeHarnessContractFixtures = (): Record { 'tool:harness/plugin-root', 'tool:harness/publish-notice', 'tool:harness/strict-report', + 'tool:harness/submit', 'tool:harness/ticket', 'tool:harness/tooling', 'tool:harness/unavailable', @@ -330,7 +332,8 @@ describe('the compiled test manifest', () => { rendered: true, routeId: `tool:harness/${tool}`, }); - expect(manifest.cliCommands.filter((command) => command.mcp !== undefined)).toEqual([ + // `submit` carries an explicit `.cli.ts` projection, so it leaves the bulk set (#596). + expect(manifest.cliCommands.filter((command) => command.mcp !== undefined && command.projection === undefined)).toEqual([ projected('catalog', 'Streams the harness catalog behind one Suspense boundary.', true), projected('context', 'Returns the request identity axes observed by this route.', true), projected('echo', 'Echoes one message back with the observed workspace root.', false), @@ -348,6 +351,14 @@ describe('the compiled test manifest', () => { // The projected command inherits the tool's declared render budget (#454). projected('wait', 'Waits until aborted or holdMs elapses, for cancellation contract proof.', true, { maxElapsedMs: 120_000 }), ]); + expect(manifest.cliCommands.filter((command) => command.projection !== undefined)).toMatchObject([ + { + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + path: ['submit'], + projection: { mapInput: true, module: 'src/mcp/harness/tools/submit.cli.tsx' }, + routeId: 'tool:harness/submit', + }, + ]); }); it('reuses the compiler pass rather than compiling a second route graph', async () => { @@ -495,6 +506,29 @@ describe('the generated route registry', () => { expect(layoutLoaders).toContain('/src/mcp/harness/layout.tsx")'); }); + it('registers projection loaders through the project bundler', async () => { + const projectionLoaders = /projectionLoaders: \{\n(?[\s\S]*?)\n {2}\},/u.exec(source)?.groups?.body ?? ''; + + expect(projectionLoaders).toContain('"tool:harness/submit": () => import('); + expect(projectionLoaders).toContain('/src/mcp/harness/tools/submit.cli.tsx")'); + + const loaded: string[] = []; + await withRealmRegistry({ + loaders: {}, + manifest, + projectionLoaders: { + 'tool:harness/submit': () => { + loaded.push('tool:harness/submit'); + return Promise.resolve({ mapInput: (input: unknown) => input }); + }, + }, + version: AGENT_TEST_REGISTRY_VERSION, + }, async () => { + await registeredProjectionLoader(manifest, 'tool:harness/submit')?.(); + }); + expect(loaded).toEqual(['tool:harness/submit']); + }); + it('carries the manifest and the registry version the helpers require', () => { expect(source).toContain(`version: ${String(AGENT_TEST_REGISTRY_VERSION)}`); expect(source).toContain('globalThis[Symbol.for("agent-bundle/test-route-registry")]'); diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index fc46087b3..3488a3747 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -6,6 +6,7 @@ import type { RouteManifest, RouteManifestCliCommand, RouteManifestCliOption, + RouteManifestCliProjection, RouteManifestCliSurface, RouteManifestConfigEntry, RouteManifestProvider, @@ -120,6 +121,7 @@ const serverSchema: z.ZodType = z.strictObject({ }); const cliOptionSchema: z.ZodType = z.strictObject({ + aliases: z.array(z.string()).optional(), choices: z.array(z.string()).optional(), description: z.string().optional(), key: z.string(), @@ -130,6 +132,15 @@ const cliOptionSchema: z.ZodType = z.strictObject({ required: z.boolean(), }); +const cliProjectionSchema: z.ZodType = z.strictObject({ + // The projection's own CLI defaults: the same literal shape a schema + // `.default()` takes on the wire, keyed by canonical key. + defaults: z.record(z.string(), inputSchemaLiteral).optional(), + mapInput: z.boolean(), + module: z.string(), + relaxed: z.array(z.string()).optional(), +}); + const cliCommandSchema: z.ZodType = z.strictObject({ aliases: z.array(z.string()), description: z.string().optional(), @@ -141,6 +152,7 @@ const cliCommandSchema: z.ZodType = z.strictObject({ }).optional(), options: z.array(cliOptionSchema), path: z.array(z.string()), + projection: cliProjectionSchema.optional(), routeId: z.string(), }); diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index 79c1a9a89..ea660145d 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -140,6 +140,101 @@ it('reads the compiled manifest over the shared foreground session', async () => expect(calls).toEqual([{ method: 'GET', token: 'foreground-token', url: '/api/routes/manifest' }]); }); +it('decodes a CLI surface projection and option aliases on the strict wire', async () => { + const projected = { + ...manifest.cli.commands[0], + mcp: { confirm: false, server: 'hauler', tool: 'hauler_request' }, + options: [ + { key: 'argv', kind: 'string', option: 'argv', positional: 0, repeated: true, required: true }, + { key: 'cwd', kind: 'string', option: 'cwd', repeated: false, required: false }, + { + aliases: ['lane-key'], + key: 'laneKey', + kind: 'string', + option: 'lane', + repeated: false, + required: false, + }, + ], + path: ['request'], + projection: { + defaults: { cwd: '.', laneKey: ['main', 'next'], limit: 20, verbose: false }, + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }, + routeId: 'tool:hauler/hauler_request', + }; + const decoded = await clientFor(() => response({ + manifest: { + ...manifest, + cli: { ...manifest.cli, commands: [projected] }, + }, + })).manifest(); + + expect(decoded.cli?.commands?.[0]?.projection).toEqual({ + defaults: { cwd: '.', laneKey: ['main', 'next'], limit: 20, verbose: false }, + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }); + expect(decoded.cli?.commands?.[0]?.options).toEqual(projected.options); +}); + +it('rejects a projection default that is not a JSON literal of the argv grammar', async () => { + for (const defaults of [{ cwd: null }, { cwd: { nested: true } }, { tags: [['a']] }]) { + const client = clientFor(() => response({ + manifest: { + ...manifest, + cli: { + ...manifest.cli, + commands: [{ + ...manifest.cli.commands[0], + projection: { defaults, mapInput: false, module: 'src/mcp/library/tools/echo.cli.ts' }, + }], + }, + }, + })); + + await expect(client.manifest()).rejects.toMatchObject({ code: 'AB8123' }); + } +}); + +it('rejects projectionSources leaked onto the CLI surface', async () => { + const client = clientFor(() => response({ + manifest: { + ...manifest, + cli: { + ...manifest.cli, + projectionSources: { 'tool:hauler/hauler_request': '/abs/hauler_request.cli.ts' }, + }, + }, + })); + + await expect(client.manifest()).rejects.toMatchObject({ code: 'AB8123' }); +}); + +it('rejects an unknown field on a CLI surface projection', async () => { + const client = clientFor(() => response({ + manifest: { + ...manifest, + cli: { + ...manifest.cli, + commands: [{ + ...manifest.cli.commands[0], + projection: { + mapInput: false, + module: 'src/mcp/library/tools/echo.cli.ts', + sources: true, + }, + }], + }, + }, + })); + + await expect(client.manifest()).rejects.toMatchObject({ code: 'AB8123' }); +}); + it('preserves projected MCP provenance and confirmation policy', async () => { const projected = { ...manifest.cli.commands[0], diff --git a/packages/workbench/tests/routes-model.test.ts b/packages/workbench/tests/routes-model.test.ts index 40ca94bba..1bf980cd1 100644 --- a/packages/workbench/tests/routes-model.test.ts +++ b/packages/workbench/tests/routes-model.test.ts @@ -220,6 +220,62 @@ it('attaches the compiled command to its CLI route entry', () => { expect(entry?.command?.options.map((option) => option.key)).toEqual(['input', 'verbose']); }); +it('exposes a CLI surface projection and option aliases on the catalog entry', () => { + const catalog = routeCatalogFor({ + ...manifest, + cli: { + commands: [{ + aliases: ['req'], + description: 'Submit a background cargo request', + exitCode: 'zero', + mcp: { confirm: false, server: 'hauler', tool: 'hauler_request' }, + options: [ + { key: 'argv', kind: 'string', option: 'argv', positional: 0, repeated: true, required: true }, + { key: 'cwd', kind: 'string', option: 'cwd', repeated: false, required: false }, + { aliases: ['lane-key'], key: 'laneKey', kind: 'string', option: 'lane', repeated: false, required: false }, + ], + path: ['request'], + projection: { + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }, + routeId: 'tool:hauler/hauler_request', + }], + mode: 'generated', + routes: [{ + config: [], + id: 'tool:hauler/hauler_request', + inputSchema: { + additionalProperties: false, + properties: { + argv: { items: { type: 'string' }, type: 'array' }, + cwd: { type: 'string' }, + laneKey: { type: 'string' }, + }, + required: ['argv', 'cwd'], + type: 'object', + }, + kind: 'tool', + provenance: { kind: 'conventional' }, + source: 'src/mcp/hauler/tools/hauler_request.tsx', + }], + }, + }); + const entry = catalog.groups.find((group) => group.kind === 'cli')?.entries[0]; + + expect(entry?.command?.projection).toEqual({ + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }); + expect(entry?.command?.options).toEqual([ + { key: 'argv', kind: 'string', option: 'argv', positional: 0, repeated: true, required: true }, + { key: 'cwd', kind: 'string', option: 'cwd', repeated: false, required: false }, + { aliases: ['lane-key'], key: 'laneKey', kind: 'string', option: 'lane', repeated: false, required: false }, + ]); +}); + it('derives contract origin and other sharing routes for each catalog entry', () => { const catalog = routeCatalogFor(manifest); const tool = catalog.groups.flatMap((group) => group.entries) @@ -339,6 +395,31 @@ it('formats CLI usage and a shell-copyable invocation from validated input', () })).toBe("library audit '/Audio Books' --format json --tag fiction --tag history --verbose"); }); +it('formats usage and invocation from projected keys to option spellings', () => { + const command = { + aliases: ['req'], + exitCode: 'zero' as const, + options: [ + { key: 'argv', kind: 'string' as const, option: 'argv', positional: 0, repeated: true, required: true }, + { key: 'cwd', kind: 'string' as const, option: 'cwd', repeated: false, required: false }, + { aliases: ['lane-key'], key: 'laneKey', kind: 'string' as const, option: 'lane', repeated: false, required: false }, + ], + path: ['request'], + projection: { + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }, + routeId: 'tool:hauler/hauler_request', + }; + + expect(cliCommandUsage(command)).toBe('request [--cwd ] [--lane ]'); + expect(cliCommandInvocation(command, { + argv: ['cargo', 'check'], + laneKey: 'fast', + })).toBe('request cargo check --lane fast'); +}); + it('marks required and optional repeated named flags in CLI usage', () => { expect(cliCommandUsage({ aliases: [], diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 958fb39f3..4d8662f6c 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -91,6 +91,12 @@ bound to schemas imported from a relative module inside the project [route contract](./index.mdx#route-contracts-application-ir) in the compiled graph; the resolution rules and the `AB4838`/`AB4839` diagnostics are under [Share one schema between MCP and CLI](./package-entries.mdx#share-one-schema-between-mcp-and-cli). +A generated tool can also expose an idiomatic CLI command without a second route: a colocated +`.cli.ts` projection module is a CLI surface projection of the same operation +(`tool:/`), never a route. A route observes `kind: 'cli'` whenever it runs from +the generated CLI executable, whichever projection mechanism produced the command; the +generated MCP server still passes `kind: 'tool'`. See +[Project one tool as an idiomatic command](./package-entries.mdx#project-one-tool-as-an-idiomatic-command). A route may re-export its component and schemas from another module. This is how one tool is placed on two generated servers when only `config` differs between the placements — an MCP App diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index e038ef111..a16e94217 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -210,9 +210,12 @@ grammar as an inline schema: reference chain (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`) and the boundary that stopped the resolver; a cyclic chain is `AB4839`, whose message prints the cycle. -- Only CLI routes raise `AB4838`/`AB4839`, because only there the static grammar is load-bearing. - An MCP route whose schema the resolver cannot follow keeps working exactly as an out-of-grammar - inline schema does: the generated server derives its JSON Schema from the real zod object. +- Only CLI routes, or a tool route with a CLI projection, raise `AB4838`/`AB4839`, because only + there the static grammar is load-bearing. An MCP route without a projection whose schema the + resolver cannot follow keeps working exactly as an out-of-grammar inline schema does: the + generated server derives its JSON Schema from the real zod object. On a tool route with a CLI + projection the message prefix is `Tool route (CLI projection )` instead of + `CLI route `. - `resultSchema` may be imported the same way. Its presence is checked statically, its type flows through TypeScript, and the runtime validates with the real zod object. @@ -221,6 +224,152 @@ Both routes then bind one canonical the static MCP `inputSchema`, the generated route types, the Workbench, and `agent-bundle inspect --routes` read. +### Project one tool as an idiomatic command + +Sharing one `RouteContract` still leaves two *routes* when the CLI grammar cannot be the +automatic kebab projection of the schema. cargo-hauler keeps `src/cli/status.tsx` beside +`src/mcp/hauler/tools/hauler_status.tsx` so `--lane` can mean `laneKey`, and +`src/cli/request.tsx` beside `hauler_request.tsx` so `hauler request -- cargo check` can feed +`argv` and `mapInput` can derive `cwd`. That second module is a second operation (`cli:status`) +with its own `operationId` and typegen entry. A colocated `.cli.ts` projection module is +the CLI *surface* projection of the same operation — identity stays `tool:/` — +and is never a route. Host projection (`targets`) is unchanged and orthogonal. + +The module sits beside the tool, never under `src/cli/**`. Status needs only renames; the +parser already emits canonical keys, so there is no `mapInput`: + +```ts +// src/mcp/hauler/tools/hauler_status.cli.ts +import type { CliProjectionConfig } from 'agent-bundle/routes'; +import type { inputSchema } from './hauler_status.js'; + +export const config = { + command: ['status'], + confirm: false, + description: + 'Show the queue, in-flight cargo work, lanes, and admission state.', + flags: { + laneKey: { + description: 'Only requests in this lane key', + name: 'lane', + }, + limit: { description: 'Recent rows to show' }, + statuses: { + description: 'Only these statuses (repeatable)', + name: 'status', + }, + tickets: { + description: 'Only these tickets (repeatable)', + name: 'ticket', + }, + }, +} satisfies CliProjectionConfig; +``` + +Request needs positionals plus a synchronous `mapInput` that fills canonical-required `cwd` +from `process.cwd()` and splits comma-separated `--after` lists: + +```ts +// src/mcp/hauler/tools/hauler_request.cli.ts +import type { CliProjectionConfig } from 'agent-bundle/routes'; +import type { z } from 'zod'; + +import { parseTicketList } from '../../../client/parse.js'; +import type { inputSchema } from './hauler_request.js'; + +export const config = { + command: ['request'], + confirm: false, + description: + 'Submit a background cargo request and print its ticket: ' + + 'hauler request [--after cc-N] -- cargo check -p foo', + flags: { + after: { + description: + 'Tickets that must finish first (repeatable, or comma-separated)', + }, + cwd: { + description: 'Workspace directory (default: current directory)', + required: false, + }, + host: { description: 'Agent host name for attribution' }, + session: { description: 'Agent session id for attribution' }, + }, + positionals: ['argv'], +} satisfies CliProjectionConfig; + +type CliInput = Omit, 'cwd' | 'after'> & { + readonly after?: readonly string[]; + readonly cwd?: string; +}; + +export const mapInput = ( + input: CliInput, +): z.input => { + const after = parseTicketList(input.after ?? []); + return { + ...input, + cwd: input.cwd ?? process.cwd(), + ...(after.length === 0 ? {} : { after }), + }; +}; +``` + +`CliProjectionConfig` is exported from `agent-bundle/routes`. `flags` and `positionals` +keys are constrained to `keyof z.input`. `config` uses the same static extract grammar +as a route module (`satisfies` unwraps). `mapInput` is an ordinary named export whose presence +is recorded statically and loaded only by the CLI bin; the MCP worker never sees the module. +`mapInput` is a surface adapter, not domain logic: it only reshapes or defaults argv into the +canonical input (renames, splitting lists, deriving a working directory). Domain validation and +behaviour stay in the operation — its `inputSchema` refinements and its component. A mapper that +recreates command logic is the duplication the projection exists to remove. + +| Key | Meaning | +| --- | --- | +| `command` | Command path segments; default `[tool]`. Each must pass `safeIdentitySegment`. | +| `description` | Help text; default: the tool's `config.description`. | +| `positionals` | Canonical keys consumed as bare arguments, in order (same rules as a `src/cli` route). | +| `flags..name` | CLI spelling (kebab-case, no leading dashes); default `kebab(key)`. | +| `flags..aliases` | Extra long-form `--spellings`, kebab-case, no leading dashes. | +| `flags..description` | Overrides the schema `.describe()`. | +| `flags..default` | CLI-only default the shell applies before `mapInput` (recorded in `projection.defaults`; a schema `.default()` is zod's to apply, after `mapInput`). | +| `flags..required` | `false` relaxes a canonical-required key; legal only when `mapInput` is exported. | +| `aliases` | Command aliases (same rules as a `src/cli` route's `config.aliases`). | +| `confirm` | Default: `!(tool config.annotations.readOnlyHint === true)`. | +| `exitCode` | `'result'` or `'zero'`; default: the tool's `config.exitCode ?? 'zero'`. | + +`flags` is keyed by the canonical key of the tool's `RouteContract.input`. Discovery excludes +`.cli.{ts,tsx}` before identity derivation and pairs the file with the sibling `.{ts,tsx}`. The +suffix is reserved under `src/mcp/**`; prefix `_` parks it. An orphan, or a `.cli.*` under +`resources/`, `prompts/`, or `apps/`, is `AB4843`. A missing or dynamic `config`, a key outside +the closed set, a field of the wrong shape, a `mapInput` that is not statically a function, or +`required: false` / a CLI `default` on a canonical-required key without `mapInput`, is `AB4844`. +A `flags`/`positionals` key absent from the contract, a `name`/alias that is not kebab-case, is +reserved (`help`, `json`, `ndjson`, `version`, and `yes` when confirm), or collides, `name` or +`aliases` on a positional key (a bare argument has no `--spelling`), a contract key `yes` on a +confirming command (the shell strips `yes` as the confirmation, whatever it is spelled), or a +`command` segment that is not a safe identity segment, is `AB4845`. Every message is +`CLI projection for tool:/: .` (a misplaced module has no tool to +name: `CLI projection : .`). A tool whose schema has no static +contract becomes load-bearing once it has a projection: `AB4814`/`AB4838`/`AB4839` fire on the +tool module with the prefix `Tool route (CLI projection )`. + +`agent-bundle inspect --routes` dumps the compiled graph, so +`cli.commands[].projection` (`module`, `mapInput`, `defaults?`, `relaxed?`) and +`options[].{key,option,aliases}` appear with no extra renderer. The Workbench will surface those +facts in the selected-operation inspector. The compiled command's `routeId` is the tool id; at +run time `invocation.kind` is `'cli'` and `operationId` is that tool id. A route observes +`kind: 'cli'` whenever it runs from the generated CLI executable, whichever projection +mechanism produced the command. The generated MCP server still passes `kind: 'tool'`. + +No projection module means no command from this path — the bulk +[`routes.mcpCommands`](#projecting-mcp-tools-into-the-cli) opt-in keeps working for every other +tool. The MCP `inputSchema`, `tools/list`, annotations, and `_meta` are untouched by any +projection field; `ToolConfig` gains nothing. + +Short `-x` aliases (the shell rejects single-dash tokens today) and an async `mapInput` are +deferred. + ### The routed CLI inside host artifacts The package bin only reaches users who install the npm package, while hooks, Skills, and scripts @@ -249,13 +398,20 @@ is unchanged. `routes.mcpCommands` adds tools from generated MCP servers to the same command graph and executable, including in projects with no `src/cli/**` routes at all. `true` selects every eligible tool; the object form takes `include` and `exclude` patterns matching the -`:` identity, with `*` as the only wildcard. - -Each projected tool runs as ` ` with the protocol tool name preserved -verbatim. Its only input option is `--input` taking one JSON object. A tool is read-only only -when its static MCP annotations explicitly set `readOnlyHint: true`; every other tool is -mutation-capable and fails closed unless `--yes` is present. Every declared pattern must match at -least one eligible tool, and a misspelling fails with `AB4822` listing the available identities. +`:` identity, with `*` as the only wildcard. A tool that already has a colocated +`.cli.ts` projection module is excluded from this bulk projection: one operation compiles +to one command. An `include` pattern that matches only such tools is `AB4822` and names the +projection module. + +Each bulk-projected tool runs as ` ` with the protocol tool name +preserved verbatim. Its only input option is `--input` taking one JSON object. Both the bulk +`routes.mcpCommands` projection and an explicit `.cli.ts` are CLI surfaces, so a route +observes `kind: 'cli'` whenever it runs from the generated CLI executable; the generated MCP +server still passes `kind: 'tool'`. A tool is +read-only only when its static MCP annotations explicitly set `readOnlyHint: true`; every other +tool is mutation-capable and fails closed unless `--yes` is present. Every declared pattern must +match at least one eligible tool, and a misspelling fails with `AB4822` listing the available +identities. ## Release identity diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index fa2af2eee..534445c59 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -29,7 +29,7 @@ export default defineConfig({ | `assets` | `string[]` | The root `assets/` convention. | | `bin` | `false \| Record` | The `src/cli.ts` convention. | | `lib` | `false \| string \| { entry, dts? }` | The `src/index.ts` convention. | -| `routes` | Route-graph policy | Convention-derived. | +| `routes` | Route-graph policy (`cli`, `mcpCommands`, `servers`) | Convention-derived. A tool with a colocated `.cli.ts` is excluded from `routes.mcpCommands`. | | `output` | `{ distPath? }` | `artifact` from the CLI; `dist` from `build()` without `packageOutputs`. | | `runtime` | `{ node }` | Node 22.12. | | `payload` | `Record` | None. | @@ -59,7 +59,10 @@ route compiler still parses `routes` during discovery, so `agent-bundle validate malformed override through the route graph's diagnostics; `validateSource` never reads `evals` — the [`evals` rules below](#evals) fire when `agent-bundle eval` or the Workbench loads the config, as `EVAL_CONFIG_INVALID`, `EVAL_INCLUDE_INVALID`, or `EVAL_RUNS_DIR_INVALID` errors rather than -`AB` diagnostics. +`AB` diagnostics. A tool that already has a colocated `.cli.ts` projection module is +excluded from `routes.mcpCommands` so the bulk ` --input` command is not +also compiled for that operation. See +[Project one tool as an idiomatic command](../guide/authoring/package-entries.mdx#project-one-tool-as-an-idiomatic-command). | Field | Type definition | | --- | --- | diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 000de4cec..609edab61 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -81,6 +81,11 @@ draft-07 校验器都接受每一个合法取值。zod 元组(`z.tuple([...])` `inputSchema` 的路由——这里的一个工具、别处的一条 `src/cli/**` 命令——都会在编译后的路由图中共享一个 [路由契约](./index.mdx#路由契约application-ir)。解析规则以及 `AB4838`/`AB4839` 诊断见 [在 MCP 与 CLI 之间共享同一份 schema](./package-entries.mdx#在-mcp-与-cli-之间共享同一份-schema)。 +生成式工具也可以在不增加第二条路由的情况下暴露惯用的 CLI 命令:同位置的 `.cli.ts` 投影模块 +是同一操作(`tool:/`)的 CLI 表面投影,绝不是一条路由。只要路由从生成的 CLI +可执行文件运行,无论命令由哪种投影机制产生,它观察到的都是 `kind: 'cli'`;生成的 MCP 服务器仍传入 +`kind: 'tool'`。参见 +[将一个工具投影为惯用命令](./package-entries.mdx#将一个工具投影为惯用命令)。 路由可以从另一个模块重新导出自己的组件与 schema。当同一个工具需要放在两个生成的服务器上、而两处 只有 `config` 不同时,就用这种写法——例如 MCP App 的 `tools/call` 会到达提供该 widget 的那台服务器: diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 735744b82..1e1af67e4 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -190,9 +190,10 @@ schema 的模块中解析 zod 表达式,所用的有界语法与内联 schema - 已解析 schema 内部的语法违规仍是 `AB4814`,其位置会带上声明模块。无法跟随的引用是 `AB4838`, 消息会打印引用链(`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`)以及阻止解析器继续的边界;循环链是 `AB4839`,消息会打印该循环。 -- 只有 CLI 路由会触发 `AB4838`/`AB4839`,因为只有这里静态语法不可或缺。解析器无法跟随 schema 的 - MCP 路由仍会像 schema 不符合内联语法时一样正常工作:生成的服务器会从真实的 zod 对象派生其 - JSON Schema。 +- 只有 CLI 路由,或带 CLI 投影的工具路由,会触发 `AB4838`/`AB4839`,因为只有这里静态语法不可或缺。 + schema 无法被解析器跟随、且没有投影的 MCP 路由仍会像 schema 不符合内联语法时一样正常工作: + 生成的服务器会从真实的 zod 对象派生其 JSON Schema。在带 CLI 投影的工具路由上,消息前缀是 + `Tool route (CLI projection )`,而不是 `CLI route `。 - `resultSchema` 可以用同样方式导入。框架会静态检查它是否存在,其类型经由 TypeScript 流转,运行时则 使用真实的 zod 对象进行校验。 @@ -200,6 +201,144 @@ schema 的模块中解析 zod 表达式,所用的有界语法与内联 schema [路由契约](./index.mdx#路由契约application-ir);argv 语法、静态 MCP `inputSchema`、生成的路由类型、 Workbench 与 `agent-bundle inspect --routes` 读取的正是它。 +### 将一个工具投影为惯用命令 + +当 CLI 语法不能由 schema 自动进行 kebab 投影时,共享一个 `RouteContract` 仍会留下两条*路由*。 +cargo-hauler 把 `src/cli/status.tsx` 放在 `src/mcp/hauler/tools/hauler_status.tsx` 旁边,让 +`--lane` 可以表示 `laneKey`;又把 `src/cli/request.tsx` 放在 `hauler_request.tsx` 旁边,让 +`hauler request -- cargo check` 可以传入 `argv`,并让 `mapInput` 派生 `cwd`。第二个模块是第二个 +操作(`cli:status`),拥有自己的 `operationId` 与 typegen 条目。同位置的 `.cli.ts` 投影模块 +则是同一操作的 CLI *表面*投影——身份仍为 `tool:/`——绝不是一条路由。宿主投影 +(`targets`)保持不变,并且与此正交。 + +该模块位于工具旁边,绝不放在 `src/cli/**` 下。Status 只需重命名;解析器已经发出规范键,因此不需要 +`mapInput`: + +```ts +// src/mcp/hauler/tools/hauler_status.cli.ts +import type { CliProjectionConfig } from 'agent-bundle/routes'; +import type { inputSchema } from './hauler_status.js'; + +export const config = { + command: ['status'], + confirm: false, + description: + 'Show the queue, in-flight cargo work, lanes, and admission state.', + flags: { + laneKey: { + description: 'Only requests in this lane key', + name: 'lane', + }, + limit: { description: 'Recent rows to show' }, + statuses: { + description: 'Only these statuses (repeatable)', + name: 'status', + }, + tickets: { + description: 'Only these tickets (repeatable)', + name: 'ticket', + }, + }, +} satisfies CliProjectionConfig; +``` + +Request 需要位置参数,以及一个同步的 `mapInput`:它会用 `process.cwd()` 填充规范必填的 `cwd`,并拆分 +逗号分隔的 `--after` 列表: + +```ts +// src/mcp/hauler/tools/hauler_request.cli.ts +import type { CliProjectionConfig } from 'agent-bundle/routes'; +import type { z } from 'zod'; + +import { parseTicketList } from '../../../client/parse.js'; +import type { inputSchema } from './hauler_request.js'; + +export const config = { + command: ['request'], + confirm: false, + description: + 'Submit a background cargo request and print its ticket: ' + + 'hauler request [--after cc-N] -- cargo check -p foo', + flags: { + after: { + description: + 'Tickets that must finish first (repeatable, or comma-separated)', + }, + cwd: { + description: 'Workspace directory (default: current directory)', + required: false, + }, + host: { description: 'Agent host name for attribution' }, + session: { description: 'Agent session id for attribution' }, + }, + positionals: ['argv'], +} satisfies CliProjectionConfig; + +type CliInput = Omit, 'cwd' | 'after'> & { + readonly after?: readonly string[]; + readonly cwd?: string; +}; + +export const mapInput = ( + input: CliInput, +): z.input => { + const after = parseTicketList(input.after ?? []); + return { + ...input, + cwd: input.cwd ?? process.cwd(), + ...(after.length === 0 ? {} : { after }), + }; +}; +``` + +`CliProjectionConfig` 从 `agent-bundle/routes` 导出。`flags` 与 `positionals` 的键受限于 +`keyof z.input`。`config` 使用与路由模块相同的静态提取语法(会解包 `satisfies`)。 +`mapInput` 是普通的命名导出,其存在性会被静态记录,并且只由 CLI bin 加载;MCP worker 永远不会看到 +该模块。`mapInput` 是表面适配器,不是领域逻辑:它只把 argv 重塑或补默认值成规范输入(重命名、拆分 +列表、派生工作目录)。领域校验与行为留在操作里——它的 `inputSchema` 精化以及它的组件。在 mapper +里重写一遍命令逻辑,正是投影要去掉的重复。 + +| 键 | 含义 | +| --- | --- | +| `command` | 命令路径段;默认为 `[tool]`。每一段都必须通过 `safeIdentitySegment`。 | +| `description` | 帮助文本;默认为工具的 `config.description`。 | +| `positionals` | 按顺序作为裸参数使用的规范键(规则与 `src/cli` 路由相同)。 | +| `flags..name` | CLI 拼写(kebab-case,不带前导短横线);默认为 `kebab(key)`。 | +| `flags..aliases` | 额外的长格式 `--spellings`,使用 kebab-case,不带前导短横线。 | +| `flags..description` | 覆盖 schema 的 `.describe()`。 | +| `flags..default` | shell 在 `mapInput` 之前应用的 CLI 专用默认值(记录在 `projection.defaults` 中;schema 的 `.default()` 由 zod 在 `mapInput` 之后应用)。 | +| `flags..required` | `false` 会放宽一个规范必填键;仅当导出了 `mapInput` 时合法。 | +| `aliases` | 命令别名(规则与 `src/cli` 路由的 `config.aliases` 相同)。 | +| `confirm` | 默认为 `!(tool config.annotations.readOnlyHint === true)`。 | +| `exitCode` | `'result'` 或 `'zero'`;默认为工具的 `config.exitCode ?? 'zero'`。 | + +`flags` 以工具的 `RouteContract.input` 的规范键为键。发现阶段会在派生身份之前排除 `.cli.{ts,tsx}`, +并将该文件与同级的 `.{ts,tsx}` 配对。这个后缀在 `src/mcp/**` 下保留;加 `_` 前缀可以停用它。 +孤立文件,或位于 `resources/`、`prompts/` 或 `apps/` 下的 `.cli.*`,会触发 `AB4843`。缺失或动态的 +`config`、封闭集合之外的键、形状错误的字段、静态上不是函数的 `mapInput`,或在没有 `mapInput` 时对 +规范必填键使用 `required: false` / CLI `default`,会触发 `AB4844`。契约中不存在的 +`flags`/`positionals` 键、不符合 kebab-case、为保留名(`help`、`json`、`ndjson`、`version`,以及 +启用确认时的 `yes`)或相互冲突的 `name`/别名、位置参数键上的 `name` 或 `aliases`(裸参数没有 +`--spelling`)、启用确认的命令所投影的契约含有键 `yes`(无论如何拼写,shell 都会把 `yes` 当作确认 +剥离),以及不是安全身份段的 `command` 段,会触发 `AB4845`。 +每条消息都是 `CLI projection for tool:/: .`(位置错误的模块没有可指名的 +工具:`CLI projection : .`)。工具一旦有了投影,原本没有 +静态契约的 schema 就成为不可或缺的语法:`AB4814`/`AB4838`/`AB4839` 会在工具模块上触发,前缀为 +`Tool route (CLI projection )`。 + +`agent-bundle inspect --routes` 会转储编译后的路由图,因此无需额外的 renderer 即可看到 +`cli.commands[].projection`(`module`、`mapInput`、`defaults?`、`relaxed?`)与 +`options[].{key,option,aliases}`。Workbench 会在选中操作的 inspector 中展示这些事实。 +编译后命令的 `routeId` 是工具 id;运行时的 `invocation.kind` 为 `'cli'`,`operationId` 则为该工具 +id。只要路由从生成的 CLI 可执行文件运行,无论命令由哪种投影机制产生,它观察到的都是 +`kind: 'cli'`。生成的 MCP 服务器仍传入 `kind: 'tool'`。 + +没有投影模块,就不会通过这条路径生成命令;批量选择加入的 +[`routes.mcpCommands`](#把-mcp-工具投影进-cli) 对其他所有工具仍照常工作。任何投影字段都不会改变 MCP +`inputSchema`、`tools/list`、annotations 或 `_meta`;`ToolConfig` 不会增加任何字段。 + +短格式 `-x` 别名(shell 目前会拒绝单短横线 token)与异步 `mapInput` 暂不支持。 + ### 宿主产物中的路由式 CLI 包 bin 只能到达安装了 npm 包的用户,而 hook、Skill 与脚本是随宿主产物一起交付的。因此构建还会把同一张 @@ -223,10 +362,14 @@ Workbench 与 `agent-bundle inspect --routes` 读取的正是它。 `routes.mcpCommands` 把生成式 MCP 服务器的工具加入同一张命令图与同一个可执行文件,即使项目完全没有 `src/cli/**` 路由也可以。`true` 选中每个符合条件的工具;对象形式接受匹配 `:` 身份的 -`include` 与 `exclude` 模式,其中 `*` 是唯一的通配符。 - -每个被投影的工具以 ` ` 运行,协议工具名逐字保留。它唯一的输入选项是 -`--input`,接受一个 JSON 对象。只有当工具的静态 MCP annotations 明确设置了 `readOnlyHint: true` 时 +`include` 与 `exclude` 模式,其中 `*` 是唯一的通配符。已经有同位置 `.cli.ts` 投影模块的工具会从这次 +批量投影中排除:一个操作只编译成一个命令。仅匹配这类工具的 `include` 模式会触发 `AB4822`,并点名 +投影模块。 + +每个被批量投影的工具以 ` ` 运行,协议工具名逐字保留。它唯一的输入选项是 +`--input`,接受一个 JSON 对象。批量 `routes.mcpCommands` 投影与显式的 `.cli.ts` 都是 CLI +表面,因此只要路由从生成的 CLI 可执行文件运行,它观察到的都是 `kind: 'cli'`;生成的 MCP 服务器仍 +传入 `kind: 'tool'`。只有当工具的静态 MCP annotations 明确设置了 `readOnlyHint: true` 时 它才是只读的;其余工具都被视为可变更,并在没有 `--yes` 时失败关闭。每个声明的模式都必须至少匹配一个 符合条件的工具,拼写错误会以 `AB4822` 失败,并列出可用的身份。 diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index aa3eec257..d2b8dc125 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -29,7 +29,7 @@ export default defineConfig({ | `assets` | `string[]` | 根目录 `assets/` 约定。 | | `bin` | `false \| Record` | `src/cli.ts` 约定。 | | `lib` | `false \| string \| { entry, dts? }` | `src/index.ts` 约定。 | -| `routes` | 路由图策略 | 由约定推导。 | +| `routes` | 路由图策略(`cli`、`mcpCommands`、`servers`) | 由约定推导。同位置有 `.cli.ts` 的工具会从 `routes.mcpCommands` 中排除。 | | `output` | `{ distPath? }` | 命令行下为 `artifact`;不带 `packageOutputs` 的 `build()` 下为 `dist`。 | | `runtime` | `{ node }` | Node 22.12。 | | `payload` | `Record` | 无。 | @@ -52,8 +52,11 @@ export default defineConfig({ 它的工厂函数),`validateSource` 则用结构化诊断强制执行本页的规则。`evals` 与 `routes` 是例外:二者都不是 `AgentBundleConfig` 的成员——它们经由 `[key: string]: unknown` 索引签名传入——因此 `tsc` 不会检查它们的 形状;`evals` 的规则要到 eval 运行时才生效(`EVAL_CONFIG_INVALID`),`agent-bundle validate` 不会报告它们, -而 `routes` 覆盖块由路由图在发现阶段校验,其诊断随 `validateSource` 一并报告。下面这些精确形态在每次文档 -构建时由 TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 +而 `routes` 覆盖块由路由图在发现阶段校验,其诊断随 `validateSource` 一并报告。同位置已有 +`.cli.ts` 投影模块的工具会从 `routes.mcpCommands` 中排除,避免为同一个操作再编译一条批量 +` --input` 命令。参见 +[将一个工具投影为惯用命令](../guide/authoring/package-entries.mdx#将一个工具投影为惯用命令)。 +下面这些精确形态在每次文档构建时由 TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 | 字段 | 类型定义 | | --- | --- |