From b9342ec2a631cb27742af3d6e2982fe2209f22fe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:53:40 +0000 Subject: [PATCH 01/19] test(routes): cover CLI tool projections --- .../agent-bundle/tests/cli-projection.test.ts | 522 ++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 packages/agent-bundle/tests/cli-projection.test.ts 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..98ef5517e --- /dev/null +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -0,0 +1,522 @@ +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(),', + '}).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, + }, + { + description: 'Confirm running this mutation-capable MCP tool.', + key: 'yes', + kind: 'boolean', + option: 'yes', + repeated: false, + required: false, + }, + ], + path: ['req'], + projection: { + mapInput: true, + module: projectionPath, + relaxed: ['cwd', 'limit'], + }, + rendered: true, + routeId: 'tool:demo/submit', + }]); + }); + + 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 AB4840 for orphan 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, + 'AB4840', + orphanRoot, + ['CLI projection src/mcp/demo/tools/ghost.cli.ts', 'tool:demo/ghost', 'no sibling'], + 'src/mcp/demo/tools/ghost.cli.ts', + ); + + const misplacedRoot = await createRoot(); + await writeTree(misplacedRoot, { + 'src/mcp/demo/resources/submit.cli.ts': cliModule('{}'), + }); + const misplaced = await compileRouteGraph(misplacedRoot, fixtureConfig()); + expectOnlyDiagnostic( + misplaced, + 'AB4840', + misplacedRoot, + ['CLI projection src/mcp/demo/resources/submit.cli.ts', 'tool'], + 'src/mcp/demo/resources/submit.cli.ts', + ); + + 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 AB4841 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(['AB4841']); + 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('{}', 'export const mapInput = pipe(identity);'), toolModule(), ['mapInput', 'function']], + [ + 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, 'AB4841', result.root, fragments); + } + }); + + it('reports AB4842 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, 'AB4842', result.root, fragments); + } + }); + + 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', + 'bare', + ], + 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('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: [], + }]); + }); +}); From 5e67ad85803532f42cbe2616fabeb212ad70d7dc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:56:33 +0000 Subject: [PATCH 02/19] =?UTF-8?q?docs(routes):=20CLI=20surface=20projectio?= =?UTF-8?q?n=20of=20MCP=20tools,=20.cli.ts,=20AB4840=E2=80=93AB4842?= =?UTF-8?q?=20(#596=20lane=20M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/596-cli-surface-projection.md | 5 + docs/diagnostics.md | 40 +++-- docs/entry-conventions.md | 68 +++++++- website/docs/en/guide/authoring/mcp.mdx | 4 + .../en/guide/authoring/package-entries.mdx | 163 ++++++++++++++++-- .../docs/en/guide/development/workbench.mdx | 2 +- .../docs/en/guide/start/project-structure.mdx | 2 + website/docs/en/reference/configuration.mdx | 7 +- 8 files changed, 259 insertions(+), 32 deletions(-) create mode 100644 .changeset/596-cli-surface-projection.md diff --git a/.changeset/596-cli-surface-projection.md b/.changeset/596-cli-surface-projection.md new file mode 100644 index 000000000..bf4a242b5 --- /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. Orphan or misplaced modules are `AB4840`, an invalid projection contract is `AB4841`, and a grammar that does not bind to the tool's contract is `AB4842` (#596). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d5bbc2ef5..6337e853c 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`), 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`), a CLI surface projection of an MCP tool (`AB4840`–`AB4842`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; 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). | @@ -933,12 +933,15 @@ framework-owned plugin twice by accident. | `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. | | `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. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4839`, `AB4940`–`AB4942`) +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4842`, `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 @@ -1137,14 +1140,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`) @@ -1170,6 +1176,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 +(`AB4840`–`AB4842`). + | Code | Severity | Trigger | | --- | --- | --- | | `AB4800` | error | An MCP server has both discovered route modules under `src/mcp//` and an existing entry claim (the conventional `src/mcp/.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.` mode. | @@ -1186,7 +1201,7 @@ sharing it. | `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. | @@ -1210,8 +1225,11 @@ sharing it. | `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. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; 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/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/serve-app-command`. | -| `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 | A `.cli.{ts,tsx}` module under `src/mcp//tools/` has no sibling tool route `.{ts,tsx}` (orphan), or a `.cli.{ts,tsx}` module sits under `resources/`, `prompts/`, or `apps/`. The suffix is reserved under `src/mcp/**` only. The message is `CLI projection for tool:/: .` and `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. | +| `AB4841` | 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 statically a function, 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. | +| `AB4842` | 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; 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 3d602a658..738afc9b0 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -821,7 +821,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 @@ -843,8 +843,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 @@ -957,16 +958,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`. @@ -1001,6 +1005,54 @@ 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`) 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 `AB4840`. + +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. A contract problem is +`AB4841`; a grammar that does not bind to the tool's contract is +`AB4842`. 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. The bulk +`--input` projection is unchanged and still runs as `kind: 'tool'`. +`inspect --routes` prints `cli.commands[].projection` +(`module`, `mapInput`, `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/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index e0ccc2fb3..58caa9539 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -91,6 +91,10 @@ 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` is a CLI surface projection of the same operation (`tool:/`), not a +`cli:` route. 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 6c848e21d..3ba14d669 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,143 @@ 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` is the CLI *surface* +projection of the same operation — identity stays `tool:/` — and is not 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. + +| 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`. | +| `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 `AB4840`. 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 `AB4841`. +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, or a +`command` segment that is not a safe identity segment, is `AB4842`. Every message is +`CLI projection for tool:/: .` 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`, `relaxed?`) and +`options[].{key,option,aliases}` appear with no extra renderer. The Workbench CLI row shows the +same facts beside the canonical input editor. The compiled command's `routeId` is the tool id; +at run time `invocation.kind` is `'cli'` and `operationId` is that tool id. + +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 @@ -248,13 +388,16 @@ 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` 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. 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/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 0e18f863b..fa9b6ae07 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -30,7 +30,7 @@ These are contracts, not defaults: | Page | Contents | | --- | --- | | Overview | Project identity, normalized model, and diagnostics. | -| Routes | The compiled route catalog from the same compiler pass as `inspect --routes`: each route's source module, config summary, and a generated input editor; a route with a static contract names the contract's declaring module and any other routes sharing it. | +| Routes | The compiled route catalog from the same compiler pass as `inspect --routes`: each route's source module, config summary, and a generated input editor; a route with a static contract names the contract's declaring module and any other routes sharing it; a CLI command compiled from a `.cli.ts` names the projection module, whether `mapInput` is present, and the key ↔ option (+ aliases) table beside the canonical input editor. | | Skills | Every Skill document, including each host's lowered output. | | Artifacts | The artifact tree with provenance and epoch comparison. | | MCP | An artifact-bound playground with the raw protocol trace, MCP App previews, and a launcher for the standalone MCP Inspector. | diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index 7f33644af..06995197f 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -23,6 +23,7 @@ my-plugin/ ├── mcp/.ts # a handwritten stdio MCP server entry ├── mcp// # or a generated server, one module per route │ ├── tools/*.tsx + │ ├── tools/.cli.ts # opt-in CLI surface projection; not a route │ ├── resources/*.tsx │ ├── prompts/*.tsx │ ├── apps/*.tsx # browser MCP Apps compiled to self-contained HTML @@ -45,6 +46,7 @@ my-plugin/ | `src/rules/*.mdc` | Flat host rule documents, emitted by Cursor, which keeps `description`, `globs`, and `alwaysApply`. The same per-host judgment applies: `AB4907` for an explicit target, `AB4908` as a warning for an implicit one. | Remove the file. | | `src/mcp/.ts` | Stdio entry for a declared MCP server that names no `entry`, `command`, or `url`. | Declare `entry` explicitly. | | `src/mcp//{tools,resources,prompts}/*` | Generated MCP server routes. The path supplies identity; each module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote`. | +| `src/mcp//tools/.cli.ts` | CLI surface projection of the sibling tool route. Not a route; excluded from `routes.mcpCommands`. See [Project one tool as an idiomatic command](../authoring/package-entries.mdx#project-one-tool-as-an-idiomatic-command). | Prefix `_` to park it. | | `src/mcp//apps/*` | Browser MCP App entries compiled to self-contained HTML and registered on the generated server. Static `config.resourceUri` is required. | Use a custom server, or prefix the file with `_`. | | `src/scripts/.ts` | A plain script compiled to `scripts/.mjs` in every selected target. Nested modules are a hard error (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry. | | `src/scripts/.tsx` | A rendered script: the async default component receives `argv` and `signal` and renders through the Agent renderer with the CLI output contract. | Rename to `.ts`, prefix a path segment with `_`, or claim the file. | diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 2bec05454..3cc4d8874 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. | @@ -58,7 +58,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` CLI surface projection 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 | | --- | --- | From 9e4096c11ee830c13d804a91a9add9336cd77948 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:07:59 +0000 Subject: [PATCH 03/19] docs(zh): translate CLI tool projections (#596 lane M7) --- website/docs/zh/guide/authoring/mcp.mdx | 3 + .../zh/guide/authoring/package-entries.mdx | 144 +++++++++++++++++- .../docs/zh/guide/development/workbench.mdx | 2 +- .../docs/zh/guide/start/project-structure.mdx | 2 + website/docs/zh/reference/configuration.mdx | 9 +- 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 731238ce2..3856ef7e8 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -81,6 +81,9 @@ 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:` 路由。参见 +[将一个工具投影为惯用命令](./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 cd413528b..5c5ce6ff7 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,137 @@ 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 永远不会看到 +该模块。 + +| 键 | 含义 | +| --- | --- | +| `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 专用默认值。 | +| `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.*`,会触发 `AB4840`。缺失或动态的 +`config`、封闭集合之外的键、形状错误的字段、静态上不是函数的 `mapInput`,或在没有 `mapInput` 时对 +规范必填键使用 `required: false` / CLI `default`,会触发 `AB4841`。契约中不存在的 +`flags`/`positionals` 键、不符合 kebab-case、为保留名(`help`、`json`、`ndjson`、`version`,以及 +启用确认时的 `yes`)或相互冲突的 `name`/别名,以及不是安全身份段的 `command` 段,会触发 `AB4842`。 +每条消息都是 `CLI projection for tool:/: .`。工具一旦有了投影,原本没有 +静态契约的 schema 就成为不可或缺的语法:`AB4814`/`AB4838`/`AB4839` 会在工具模块上触发,前缀为 +`Tool route (CLI projection )`。 + +`agent-bundle inspect --routes` 会转储编译后的路由图,因此无需额外的 renderer 即可看到 +`cli.commands[].projection`(`module`、`mapInput`、`relaxed?`)与 +`options[].{key,option,aliases}`。Workbench 的 CLI 行会在规范输入编辑器旁显示同样的信息。 +编译后命令的 `routeId` 是工具 id;运行时的 `invocation.kind` 为 `'cli'`,`operationId` 则为该工具 id。 + +没有投影模块,就不会通过这条路径生成命令;批量选择加入的 +[`routes.mcpCommands`](#把-mcp-工具投影进-cli) 对其他所有工具仍照常工作。任何投影字段都不会改变 MCP +`inputSchema`、`tools/list`、annotations 或 `_meta`;`ToolConfig` 不会增加任何字段。 + +短格式 `-x` 别名(shell 目前会拒绝单短横线 token)与异步 `mapInput` 暂不支持。 + ### 宿主产物中的路由式 CLI 包 bin 只能到达安装了 npm 包的用户,而 hook、Skill 与脚本是随宿主产物一起交付的。因此构建还会把同一张 @@ -223,9 +355,11 @@ Workbench 与 `agent-bundle inspect --routes` 读取的正是它。 `routes.mcpCommands` 把生成式 MCP 服务器的工具加入同一张命令图与同一个可执行文件,即使项目完全没有 `src/cli/**` 路由也可以。`true` 选中每个符合条件的工具;对象形式接受匹配 `:` 身份的 -`include` 与 `exclude` 模式,其中 `*` 是唯一的通配符。 +`include` 与 `exclude` 模式,其中 `*` 是唯一的通配符。已经有同位置 `.cli.ts` 的工具会从这次 +批量投影中排除:一个操作只编译成一个命令。仅匹配这类工具的 `include` 模式会触发 `AB4822`,并点名 +投影模块。 -每个被投影的工具以 ` ` 运行,协议工具名逐字保留。它唯一的输入选项是 +每个被批量投影的工具以 ` ` 运行,协议工具名逐字保留。它唯一的输入选项是 `--input`,接受一个 JSON 对象。只有当工具的静态 MCP annotations 明确设置了 `readOnlyHint: true` 时 它才是只读的;其余工具都被视为可变更,并在没有 `--yes` 时失败关闭。每个声明的模式都必须至少匹配一个 符合条件的工具,拼写错误会以 `AB4822` 失败,并列出可用的身份。 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index f6271e6bf..a98e0d276 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -26,7 +26,7 @@ npx agent-bundle dev --root . --port 3100 --no-open | 页面 | 内容 | | --- | --- | | Overview | 项目标识、规范化模型与诊断。 | -| Routes | 来自与 `inspect --routes` 相同编译器 pass 的已编译路由目录:每条路由的源码模块、配置摘要与生成的输入编辑器;带静态契约的路由还会指明契约的声明模块及共享该契约的其他路由。 | +| Routes | 来自与 `inspect --routes` 相同编译器 pass 的已编译路由目录:每条路由的源码模块、配置摘要与生成的输入编辑器;带静态契约的路由还会指明契约的声明模块及共享该契约的其他路由;由 `.cli.ts` 编译而来的 CLI 命令会在规范输入编辑器旁指明投影模块、是否存在 `mapInput`,以及键 ↔ 选项(含别名)表。 | | Skills | 每个 Skill 文档,包括各宿主降级后的输出。 | | Artifacts | 带 provenance 与 epoch 对比的产物树。 | | MCP | 绑定到产物的 playground,带原始协议轨迹、MCP App 预览,以及独立 MCP Inspector 的启动器。 | diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 43867f81a..1935b14a5 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -22,6 +22,7 @@ my-plugin/ ├── mcp/.ts # 手写的 stdio MCP 服务器入口 ├── mcp// # 或生成式服务器,每个路由一个模块 │ ├── tools/*.tsx + │ ├── tools/.cli.ts # 选择加入的 CLI 表面投影;不是路由 │ ├── resources/*.tsx │ ├── prompts/*.tsx │ ├── apps/*.tsx # 编译为自包含 HTML 的浏览器 MCP App @@ -44,6 +45,7 @@ my-plugin/ | `src/rules/*.mdc` | 扁平的宿主规则文档,由 Cursor 发射,保留 `description`、`globs` 与 `alwaysApply`。同样的按宿主判定适用:显式 target 为 `AB4907`,隐式 target 为警告 `AB4908`。 | 删除该文件。 | | `src/mcp/.ts` | 某个已声明、但未指定 `entry`、`command` 或 `url` 的 MCP 服务器的 stdio 入口。 | 显式声明 `entry`。 | | `src/mcp//{tools,resources,prompts}/*` | 生成式 MCP 服务器路由。路径提供身份;每个模块提供静态 `config`、schema,以及一个 async 默认 Server Component。 | 把 `routes.servers.` 设为 `custom`、`command` 或 `remote`。 | +| `src/mcp//tools/.cli.ts` | 同级工具路由的 CLI 表面投影。不是路由;会从 `routes.mcpCommands` 中排除。参见[将一个工具投影为惯用命令](../authoring/package-entries.mdx#将一个工具投影为惯用命令)。 | 加 `_` 前缀以停用它。 | | `src/mcp//apps/*` | 浏览器 MCP App 入口,编译为自包含 HTML 并注册到生成的服务器上。必须提供静态 `config.resourceUri`。 | 使用自定义服务器,或给文件名加 `_` 前缀。 | | `src/scripts/.ts` | 一个普通脚本,在每个所选 target 中编译为 `scripts/.mjs`。嵌套模块是硬错误(`AB4808`)。 | 给某一段路径加 `_` 前缀,或用显式 `scripts` 条目认领该文件。 | | `src/scripts/.tsx` | 渲染式脚本:async 默认组件接收 `argv` 与 `signal`,并按 CLI 输出契约通过 Agent 渲染器渲染。 | 改名为 `.ts`、给某一段路径加 `_` 前缀,或认领该文件。 | diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index d68899e14..7bd5a2314 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` | 无。 | @@ -51,8 +51,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` CLI 表面投影的工具会从 `routes.mcpCommands` 中排除,避免为同一个操作再编译一条批量 +` --input` 命令。参见 +[将一个工具投影为惯用命令](../guide/authoring/package-entries.mdx#将一个工具投影为惯用命令)。 +下面这些精确形态在每次文档构建时由 TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 | 字段 | 类型定义 | | --- | --- | From 1a6000baea2a6545a7d574bdd6855c96bec4c239 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:17:50 +0000 Subject: [PATCH 04/19] test(596/M5): projection-pool and integration tests for CLI surface projections Add tool:harness/submit to the route-harness fixture with its CLI projection module (parked as _submit.cli.ts until the M1 compiler classifies projection modules; rename to submit.cli.ts at integration), the cli-dispatch-projection projection-pool suite, and a cli-routes-build describe that builds a temp project with src/mcp/demo/tools/submit.tsx + submit.cli.ts and runs the generated bin and inspect --routes. Existing pins updated for the new fixture tool: test-harness-manifest, contract-matrix-fixtures, mcp-in-memory, cli-dispatch, packed-stdio-projection. --- .../src/mcp/harness/tools/_submit.cli.ts | 45 ++++ .../src/mcp/harness/tools/submit.tsx | 51 ++++ .../tests/cli-routes-build.test.ts | 242 +++++++++++++++++- .../tests/packed-stdio-projection.test.ts | 1 + .../cli-dispatch-projection.test.ts | 215 ++++++++++++++++ .../tests/projection/cli-dispatch.test.ts | 1 + .../tests/projection/mcp-in-memory.test.ts | 3 +- .../tests/support/contract-matrix-fixtures.ts | 5 + .../tests/test-harness-manifest.test.ts | 2 + 9 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/_submit.cli.ts create mode 100644 packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx create mode 100644 packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/_submit.cli.ts b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/_submit.cli.ts new file mode 100644 index 000000000..08b602522 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/_submit.cli.ts @@ -0,0 +1,45 @@ +import type { CliProjectionConfig } from 'agent-bundle/routes'; +import type { z } from 'zod'; + +import type { inputSchema } from './submit.js'; + +/** + * The CLI surface projection of `tool:harness/submit` (#596): never a route. + * The tool stays the operation (`routeId`, `operationId`); this module only + * spells its canonical input as an idiomatic command — `laneKey` as `--lane`, + * `tags` as a repeatable `--tag`, `argv` as the trailing positionals (so + * `-- cargo check -p foo` passes flags through), and `cwd` relaxed on the CLI + * because `mapInput` derives it from the process. `confirm: false` overrides + * the `readOnlyHint: false` default, so the command runs without `--yes`. + */ +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; + +/** The parsed argv: canonical keys, with `cwd` optional because the projection relaxed it. */ +type CliInput = Omit, 'cwd'> & { readonly cwd?: string }; + +/** + * Applied by the CLI shell before the canonical `inputSchema`, synchronously. + * A tag starting with `!` is the fixture's trigger for a thrown mapping error, + * which the shell reports as an input failure (exit 2). + */ +export const mapInput = (input: CliInput): z.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 }), + }; +}; 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..d9957edbe --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.tsx @@ -0,0 +1,51 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +/** + * The operation behind the harness's explicit CLI surface projection (#596): + * `submit.cli.ts` beside this module projects it onto ` submit`. The + * structured result echoes the canonical input so the projection levels can + * prove the CLI grammar and the MCP surface reach the same operation with the + * same value; the rendered text carries the surface the route observed + * (`invocation.kind`, `operationId`, `surface`) and the `library-tooling` + * provider's view of it, which differ per surface by design. + */ +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/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 198dd0be4..542a6c4a3 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[] = []; @@ -482,3 +484,237 @@ 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', + })), + // `mcpCommands: true` alongside the projection: the bulk projection must + // skip the projected tool and still cover its neighbour. + 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')), + // The operation: canonical input echoed as the structured result, the + // observed surface in the rendered text only. + 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.' };", + 'export const inputSchema = z.object({', + ' argv: z.array(z.string()).min(1),', + ' cwd: z.string().min(1),', + ' laneKey: z.string().optional(),', + ' tags: z.array(z.string()).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(),', + '});', + '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')), + // The projection: never a route. `laneKey` as `--lane`, `tags` as a + // repeatable `--tag`, `argv` trailing (so `-- cargo check -p foo` passes + // through), `cwd` relaxed because `mapInput` derives it, and no `--yes` + // although the tool is not read-only. + 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({}); + // The bin's provenance names the projection module beside the route modules it projects. + 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', + projectionModule, + 'src/mcp/demo/tools/submit.tsx', + ])); + // The executable's compiled surface: the projected command at the root + // beside the bulk-projected neighbour, and the tool as a route exactly + // once — the projection module is not a route. + const generatedCli = built.model.packageBuild?.bins[0]?.generatedCli; + expect(generatedCli?.commands.map((command) => command.path.join(' ')).sort()).toEqual(['demo ping', 'submit']); + expect(generatedCli?.routes.map((route) => route.id).sort()).toEqual(['tool:demo/ping', '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\)\.$/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)'); + // The tree lists the projected command at the root, beside the bulk-projected group. + 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'] }); + + // Flags after `--` are the command line's, not the shell's; no `--yes` despite readOnlyHint: false. + 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' }); + + // Piped text output carries the surface the tool observed: the CLI, with the tool as the operation. + 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'); + + // The bulk projection still serves the neighbouring tool under the server path. + const ping = await execFile(binPath, ['demo', 'ping', '--json'], { cwd: root }); + expect(JSON.parse(ping.stdout)).toEqual({ pong: true }); + }); + + 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: '', + }); + // The canonical schema judges the MAPPED input, and the issue is spelled + // with the CLI option the operator typed. + 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: '', + }); + await expect(execFile(binPath, ['submit', '--yes', '--', 'cargo', 'check'], { cwd: root })).rejects.toMatchObject({ + code: 2, + stderr: expect.stringContaining('Unknown option: --yes.'), + 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', '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 }), + ], + path: ['submit'], + projection: { mapInput: true, module: projectionModule, relaxed: ['cwd'] }, + rendered: true, + routeId: 'tool:demo/submit', + }); + expect(commands.find((command) => command.routeId === 'tool:demo/ping')).not.toHaveProperty('projection'); + // The projection module is not a route. + expect(document.selected?.routes?.servers.flatMap((server) => server.routes.map((route) => route.id))).toEqual([ + 'tool:demo/ping', + 'tool:demo/submit', + ]); + }); +}); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 7a0042ae6..05074745c 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-projection.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts new file mode 100644 index 000000000..8a1e575b2 --- /dev/null +++ b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts @@ -0,0 +1,215 @@ +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.ts` 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."; +/** The `library-tooling` fixture provider's report, keyed by the surface it observed. */ +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('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: [], + // No `description` in the projection: the tool's config.description serves. + description: 'Submits one command line as lane work and echoes the accepted request.', + exitCode: 'zero', + mcp: { confirm: false, server: 'harness', tool: 'submit' }, + // `options` is the mapping: canonical `key` ↔ CLI `option`, sorted by spelling. + 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.ts', relaxed: ['cwd'] }, + rendered: true, + routeId: 'tool:harness/submit', + }); + // One command per operation: the bulk `mcpCommands: true` projection + // skips a tool that carries its own projection module. + expect(manifest.cliCommands.map((candidate) => candidate.path.join(' '))).not.toContain('harness submit'); + // The projection module is never a route. + 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); + // The MCP surface ran the same route as a tool; only the rendered surface + // wording differs, never the value. + 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 () => { + // The tool's `readOnlyHint: false` would make the bulk projection fail + // closed; the projection's explicit `confirm: false` wins. + 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.ts'); + 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', '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..2412cb20a 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -38,6 +38,7 @@ describe('the CLI dispatch level', () => { 'harness plugin-root', 'harness publish-notice', 'harness strict-report', + 'harness submit', 'harness ticket', 'harness tooling', 'harness unavailable', 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/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', @@ -342,6 +343,7 @@ describe('the compiled test manifest', () => { projected('plugin-root', 'Reports the plugin root and durable-state anchor this route observes.', false), projected('publish-notice', 'Publishes a durable notice for a later session event.', true), projected('strict-report', 'Returns a closed-object report that rejects unknown serialized keys.', true), + projected('submit', 'Submits one command line as lane work and echoes the accepted request.', true), projected('ticket', 'Returns a cargo-conductor-shaped ticket status with optional diagnostics fields.', true), projected('tooling', 'Reports the request providers an MCP tool observes.', false), projected('unavailable', 'Returns a typed unavailable result for projection checks.', true), From be75166632330a406d3489825f283ee6bbdcf47e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:35:19 +0000 Subject: [PATCH 05/19] feat(routes): compile MCP tool CLI surface projections (#596) Discover `src/mcp//tools/.cli.{ts,tsx}` as projection modules paired with the sibling tool route (never a route of their own; AB4840 for orphan, misplaced, or duplicate modules; skipped silently when the server is not generated). `extractCliProjection` validates the closed `CliProjectionConfig` key set through the unchanged route-config grammar and the `mapInput` export (AB4841), and binds flags, positionals, and command segments to the tool's contract (AB4842). `compileProjectedCliCommands` compiles one command per projected tool under the CLI-route argv policy: `cli-argv.ts` takes a per-key override policy (`name`, `aliases`, `description`, `default`, `required: false`) applied inside `cliOptionFor` so kebab-case, reserved-name (`yes` when confirming), and collision rules judge the final spellings, and reports the canonical-required keys it relaxed. Projected tools leave the bulk `routes.mcpCommands` projection (AB4822 when an include pattern reaches only them); the AB4813 collision pass covers projected commands with the projection recovery wording. A tool without a static contract is parsed again under the `Tool route (CLI projection )` label so AB4814/AB4838/AB4839 name it. The CLI surface exists whenever cli routes, the bulk projection, or projections exist (AB4801 third arm); `projectionSources` rides the surface and the normalized generated bin outside the digest identity. --- packages/agent-bundle/src/api.ts | 2 + packages/agent-bundle/src/config/normalize.ts | 12 +- packages/agent-bundle/src/core/types.ts | 2 + packages/agent-bundle/src/routes/cli-argv.ts | 263 ++++++++-- .../agent-bundle/src/routes/cli-commands.ts | 473 ++++++++++++++---- .../agent-bundle/src/routes/cli-projection.ts | 401 +++++++++++++++ packages/agent-bundle/src/routes/graph.ts | 147 +++++- packages/agent-bundle/src/routes/index.ts | 38 +- packages/agent-bundle/src/routes/public.ts | 73 +++ packages/agent-bundle/src/routes/types.ts | 51 +- .../agent-bundle/tests/cli-projection.test.ts | 3 +- 11 files changed, 1284 insertions(+), 181 deletions(-) create mode 100644 packages/agent-bundle/src/routes/cli-projection.ts diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 0db293a9a..344572de3 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -69,6 +69,7 @@ export type { AgentProviderFactory, AppRouteConfig, CanonicalAgentEvent, + CliProjectionConfig, PromptConfig, ResourceConfig, RouteSchema, @@ -84,6 +85,7 @@ export type { CapabilityState, CompiledAgentRoute, CompiledCliMode, + CompiledCliProjection, CompiledCliSurface, CompiledProvider, CompiledRouteGraph, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 7e56ca929..82e8d1aa7 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -256,8 +256,18 @@ const generatedCliBinEntry = ( const commandRouteIds = new Set(commands.map((command) => command.routeId)); const routes = routeCli.routes.filter((route) => commandRouteIds.has(route.id)); const source = routes[0]!.source; + // 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(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/core/types.ts b/packages/agent-bundle/src/core/types.ts index 242089d14..a868e7b20 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -490,6 +490,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/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index 4ec278213..8af197f25 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,46 @@ 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; +} + +/** + * 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). + */ +export interface CliOptionPolicy { + readonly label?: string; + readonly overrideError?: (detail: string) => Diagnostic; + readonly overrides?: Readonly>; + readonly reserved?: readonly string[]; +} + const grammarRecovery = `Restrict the inputSchema initializer to the bounded argv grammar (${cliArgvGrammar}), then inspect again.`; const argvError = (message: string, sourcePath: string): Diagnostic => ({ @@ -60,18 +92,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 +128,135 @@ const optionNameOf = (key: string): string => key .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1-$2') .toLowerCase(); +const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + interface CliPropertyProjection { readonly diagnostic?: Diagnostic; readonly option?: CompiledCliOption; + /** True when a projection override made a canonical-required key optional on argv. */ + readonly relaxed?: boolean; } -/** The argv policy for one schema property: flag rule, kebab-case naming, and reserved names. */ +/** 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) => Diagnostic; + readonly overrides: Readonly>; + readonly reserved: ReadonlySet; + readonly sourcePath: string; +} + +const resolvePolicy = (policy: CliOptionPolicy, relativePath: string, sourcePath: string): ResolvedCliOptionPolicy => ({ + label: policy.label ?? defaultLabel(relativePath), + // 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(`${policy.label ?? defaultLabel(relativePath)} ${detail}.`, sourcePath)), + overrides: policy.overrides ?? {}, + reserved: new Set([...reservedCliOptionNames, ...(policy.reserved ?? [])]), + sourcePath, +}); + +const describeKind = (base: ScalarBase): string => + base.kind === 'enum' ? `one of ${(base.choices ?? []).map((choice) => JSON.stringify(choice)).join(', ')}` : base.kind; + +/** True when `value` is one value of the property's scalar base. */ +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: 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; + 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, + ...(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 +265,75 @@ 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. `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 diagnostics: readonly Diagnostic[]; readonly options?: readonly CompiledCliOption[]; + readonly relaxed?: readonly string[]; +} + +/** Who claimed one `--spelling`: the key, and whether the projection's override spelled it. */ +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 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); + const projected = cliOptionFor(entry.property, policy); if (projected.diagnostic !== undefined) { diagnostics.push(projected.diagnostic); continue; } + if (projected.relaxed === true) relaxed.push(entry.property.key); 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, - )); + const override = policy.overrides[option.key] ?? {}; + const spellings: readonly SpellingClaim[] = [ + { key: option.key, overridden: override.name !== undefined }, + ...(option.aliases ?? []).map(() => ({ key: option.key, overridden: true })), + ]; + const collision = [option.option, ...(option.aliases ?? [])] + .map((spelling, index) => ({ claimed: seenSpellings.get(spelling), claim: spellings[index]!, spelling })) + .find((candidate) => candidate.claimed !== undefined); + 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 [option.option, ...(option.aliases ?? [])].entries()) { + seenSpellings.set(spelling, spellings[index]!); + } options.push(option); } if (diagnostics.length > 0) return { diagnostics }; return { diagnostics: [], options: [...options].sort((left, right) => left.option.localeCompare(right.option)), + ...(relaxed.length === 0 ? {} : { relaxed: [...relaxed].sort((left, right) => left.localeCompare(right)) }), }; }; @@ -211,40 +368,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 72e6ab7c4..e0ea43355 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -1,16 +1,34 @@ import { extname } from 'node:path'; -import { extractCliArgv, projectInputSchemaOptions, type ExtractedCliArgv } from './cli-argv.ts'; +import { + extractCliArgv, + projectInputSchemaOptions, + type CliOptionOverride, + type CliOptionPolicy, + 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 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'; /** @@ -35,8 +53,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, @@ -45,22 +61,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', }); @@ -89,11 +122,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; @@ -157,43 +185,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 AB4842 (#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; } @@ -205,10 +227,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 }; @@ -266,16 +285,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') @@ -284,12 +311,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) { @@ -303,7 +348,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}`, )); } @@ -315,7 +360,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}`, )); @@ -360,37 +408,204 @@ 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.'; + +/** + * 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) => binding(detail), + overrides, + ...(confirm ? { reserved: ['yes'] } : {}), + }); + // 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 of Object.keys(config.flags ?? {})) { + if (keys.has(key)) continue; + diagnostics.push(binding(unknownInputKeyDetail('flags', key), keyRecovery)); + bound = false; + } + for (const key of argv.relaxed ?? []) { + if (extracted.mapInput) continue; + diagnostics.push(cliProjectionContractError( + relativePath, + tool.id, + relaxationWithoutMapInputDetail(key, config.flags![key]!), + 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); + // Every key here has a compiled command whose `projection.module` is the + // relative twin of this absolute path; a pair that failed lists nothing. + 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: { + 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; @@ -425,7 +640,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; @@ -443,32 +659,62 @@ export const compileCliCommands = async ( }); } + /** + * One claimed command path and the module that claims it: a custom route + * (no `provenance`), a bulk-projected tool (its tool route), or a + * projection module (the module itself, whose `command` chose the path). + */ 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()]; + /** The projected side of a collision (the later claim when both are), and the file the fix belongs in. */ + 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('/'); @@ -477,18 +723,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) { @@ -500,17 +741,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, )); } @@ -520,6 +762,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(); @@ -532,62 +779,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..eea77e45b --- /dev/null +++ b/packages/agent-bundle/src/routes/cli-projection.ts @@ -0,0 +1,401 @@ +import { extractRouteConfig, routeConfigGrammar } from './config-extract.ts'; +import { scanRouteModuleExports } 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 `AB4840`–`AB4842` 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']!; + 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 (AB4840). */ +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 AB4841 fired on it. */ +export interface ExtractedCliProjection { + readonly config: CliProjectionConfigRecord; + /** AB4841 (module contract) and AB4842 (grammar binding) in that order. */ + readonly diagnostics: readonly Diagnostic[]; + /** True when the module exports `mapInput` as a synchronous function. */ + readonly mapInput: boolean; +} + +export interface CliProjectionExtractionOptions { + /** Absolute project root; const string references inside `config` resolve inside it only. */ + readonly projectRoot?: string; +} + +/** Every key a projection `config` may declare. */ +const projectionConfigKeys: readonly string[] = ['aliases', 'command', 'confirm', 'description', 'exitCode', 'flags', 'positionals']; + +/** Every key one `flags.` entry may declare. */ +const flagConfigKeys: readonly string[] = ['aliases', 'default', 'description', 'name', 'required']; + +const emptyProjectionConfig: CliProjectionConfigRecord = deepFreeze({}); + +/** How the diagnostics address one projection module. */ +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 arrow or function expression (`export const mapInput = (input) => ({ ... })`), 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.'; + +/** AB4841: 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: 'AB4841', + message: `${projectionSubject(module, toolId)}: ${detail}.`, + recovery, + severity: 'error', + sourcePath, +}); + +/** AB4842: 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: 'AB4842', + message: `${projectionSubject(module, toolId)}: ${detail}.`, + recovery, + severity: 'error', + sourcePath, +}); + +/** AB4840: 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: 'AB4840', + message: `CLI projection ${relativePath} has no sibling tool route src/mcp/${module.server}/tools/${module.stem}.{ts,tsx} to project (${module.siblingId}); 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, +}); + +/** AB4840: a `.cli.{ts,tsx}` module under `resources/`, `prompts/`, or `apps/`, where no route takes a CLI projection. */ +/** AB4840 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: 'AB4840', + message: `CLI projection modules ${existingRelativePath} and ${relativePath} both project ${module.siblingId}; 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, +}); + +export const misplacedCliProjectionError = (relativePath: string, sourcePath: string): Diagnostic => ({ + code: 'AB4840', + message: `CLI projection ${relativePath} sits where only a tool route takes the .cli suffix (src/mcp//tools/.cli.{ts,tsx}); 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): boolean => + typeof entry === 'boolean' || typeof entry === 'string' || (typeof entry === 'number' && Number.isFinite(entry)); + return scalar(value) || (Array.isArray(value) && value.every(scalar)); +}; + +/** The reason an `extractRouteConfig` diagnostic gives, without the `Route module ` subject the projection replaces. */ +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; +}; + +interface FlagValidation { + readonly detail?: string; + readonly flag?: CliProjectionFlagConfig; +} + +/** Validates one `flags.` entry's shape; the detail names the offending field. */ +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` }; + if (value.default !== undefined && !isFlagDefault(value.default)) { + return { detail: `config.flags.${key}.default must be a boolean, number, string, or an array of those` }; + } + if (value.description !== undefined && typeof value.description !== 'string') { + return { detail: `config.flags.${key}.description must be a string` }; + } + if (value.name !== undefined && typeof value.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 }), + ...(value.default === undefined ? {} : { default: value.default as CliProjectionFlagDefault }), + ...(value.description === undefined ? {} : { description: value.description as string }), + ...(value.name === undefined ? {} : { name: value.name as string }), + ...(value.required === undefined ? {} : { required: false as const }), + }, + }; +}; + +interface ConfigValidation { + readonly config?: CliProjectionConfigRecord; + readonly details: readonly string[]; +} + +/** Validates the extracted `config` against the closed key set and field shapes; every detail is one AB4841. */ +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 (validated.detail !== undefined) 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: [], + }; +}; + +/** + * Binds a validated config to the tool's canonical contract: `flags` and + * `positionals` must name contract keys and `command` segments must be safe + * identity segments (AB4842); relaxing a canonical-required key needs + * `mapInput` to supply it (AB4841). 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.', + )); + } + } + // 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; +}; + +/** AB4842 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.`; + +/** + * AB4841 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.`; + +/** + * 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 synchronous `mapInput` function + * (`scanRouteModuleExports`). The module is parsed, never executed. Every + * failure is `AB4841` (the module's own contract) or `AB4842` (binding to + * the tool's argv grammar), addressed as + * `CLI projection for tool:/: .` on the + * module's own path; a module with any AB4841 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 }); + let mapInput = false; + if (exports.named.has('mapInput')) { + if (exports.namedAsyncFunctions.has('mapInput')) { + diagnostics.push(report.contract( + 'exports an async mapInput, but the shell applies mapInput synchronously before the canonical inputSchema validates', + mapInputRecovery, + )); + } else if (!exports.namedFunctions.has('mapInput')) { + diagnostics.push(report.contract('exports mapInput, which is not statically a function', mapInputRecovery)); + } else { + mapInput = true; + } + } + + 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/graph.ts b/packages/agent-bundle/src/routes/graph.ts index ea097d204..26a8350f0 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, @@ -38,6 +48,7 @@ import { validateRouteRenderConfig } from './render-budget.ts'; import { validateRouteExecutionConfig } from './task-support.ts'; import { emptyRouteConfig, + safeIdentitySegment, type CompiledAgentRoute, type CompiledCliMode, type CompiledCliSurface, @@ -79,9 +90,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. */ @@ -156,6 +164,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'; @@ -667,11 +687,26 @@ 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; + // 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 @@ -753,6 +788,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 (AB4840). 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[] = []; @@ -1011,11 +1074,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) { @@ -1024,11 +1116,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.`, @@ -1036,22 +1128,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', @@ -1060,6 +1169,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 1eda43e9a..c29dc2506 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, @@ -116,6 +142,9 @@ export type { AgentProviderWorkspaceIdentity, AppRouteConfig, CanonicalAgentEvent, + CliProjectionConfig, + CliProjectionFlagConfig, + CliProjectionFlagDefault, CliRouteConfig, CliRouteProps, PromptConfig, @@ -123,6 +152,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 f943c5ba0..24df24a0c 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -514,6 +514,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 4182d8de0..0a07c8a66 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -36,6 +36,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 @@ -201,9 +208,15 @@ 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 static `.default()` value, surfaced in generated help — or a + * CLI projection's `flags..default`, which the shell applies to an + * absent option before the canonical `inputSchema` reads the input. + */ readonly defaultValue?: unknown; /** The static `.describe('')` string, surfaced in generated help. */ readonly description?: string; @@ -214,10 +227,30 @@ 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 { + /** 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 @@ -243,6 +276,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 @@ -263,6 +302,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/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index 98ef5517e..6e15da45a 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -466,7 +466,8 @@ describe('MCP tool CLI surface projections', () => { [ `Tool route ${toolPath} (CLI projection ${projectionPath})`, 'inputSchema -> external', - 'bare', + // The reason is input-schema.ts's existing AB4838 wording, relabelled. + 'imported from "schema-package", which is not a relative module path', ], toolPath, ); From 6edd1e6c41b6746f080339faa6d370daa809a84b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:55:51 +0000 Subject: [PATCH 06/19] feat(cli): execute tool surface projections --- packages/agent-bundle/src/build/cli-bins.ts | 33 ++++++-- .../agent-bundle/src/build/entry-shell.ts | 61 ++++++++++++-- .../agent-bundle/src/build/package-build.ts | 17 ++-- packages/agent-bundle/src/cli-entry.ts | 12 ++- packages/agent-bundle/src/test/cli.ts | 24 ++++-- packages/agent-bundle/src/test/render.ts | 83 +++++++++++++++++-- .../agent-bundle/tests/entry-shell.test.ts | 71 ++++++++++++++++ .../tests/projection/cli-dispatch.test.ts | 60 ++++++++++++++ 8 files changed, 316 insertions(+), 45 deletions(-) diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 079feede4..997631fa6 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -5,6 +5,7 @@ import type { TargetRegistry } from '../adapters/registry.ts'; import { routedCliBinLayout, type TargetArtifactEntry } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; +import type { CompiledCliSurface } from '../routes/types.ts'; import { resolveArtifactDestination } from './emit.ts'; import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts'; import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; @@ -60,11 +61,30 @@ interface PlannedCliBin extends CompiledCliBin { readonly rendered: boolean; } -const generatedCli = (bin: NormalizedBinEntry): NonNullable => { +export type GeneratedCliBinSurface = NonNullable + & Pick; + +const generatedCli = (bin: NormalizedBinEntry): GeneratedCliBinSurface => { if (bin.generatedCli === undefined) { throw new Error(`Bin ${JSON.stringify(bin.name)} is not a framework-generated routed CLI.`); } - return bin.generatedCli; + return bin.generatedCli as GeneratedCliBinSurface; +}; + +/** Every project source that can change a generated routed-CLI executable. */ +export const cliBinSourceInputs = ( + model: NormalizedPlugin, + bin: NormalizedBinEntry, +): readonly string[] => { + const cli = generatedCli(bin); + return Object.freeze([...new Set([ + bin.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 = ( @@ -75,13 +95,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, - ...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, @@ -130,6 +144,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 b4ab4899c..13bbc8abd 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -191,6 +191,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[]; @@ -362,6 +364,15 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions): string => { const commandRoutes = options.routes.filter((route) => options.commands.some((command) => command.routeId === route.id)); + const projectedCommands = options.commands.filter((command) => command.projection !== undefined); + const projectionSources = projectedCommands.map((command) => { + const source = options.projectionSources?.[command.routeId]; + if (source === undefined) { + throw new Error(`Generated CLI projection ${JSON.stringify(command.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.'); @@ -376,7 +387,7 @@ export const generatedCliBinEntrySource = (options: 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, CliUsageError, cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" : "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", @@ -384,6 +395,8 @@ export const generatedCliBinEntrySource = (options: 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), '', pluginRootDeclaration(stateFallback), @@ -391,19 +404,41 @@ export const generatedCliBinEntrySource = (options: 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). + // Projection defaults are identified by command.projection plus an + // option's own defaultValue field. This deliberately reapplies static zod + // defaults on projected commands; zod defaults are idempotent, while + // non-projected commands retain their existing schema-owned behavior. 'const parseInput = (command, route, input) => {', + ' let mapped = { ...input };', + ' if (command.projection !== undefined && command.mcp?.confirm === true) {', + " if (mapped.yes !== true) throw new CliUsageError(`MCP tool ${command.mcp.server}:${command.mcp.tool} is mutation-capable per its MCP annotations and requires --yes.`);", + ' delete mapped.yes;', + ' }', + ' if (command.projection !== undefined) {', + ' for (const option of command.options) {', + " if (!Object.hasOwn(mapped, option.key) && Object.hasOwn(option, 'defaultValue')) mapped[option.key] = option.defaultValue;", + ' }', + ' }', + ' if (command.projection?.mapInput === true) {', + " if (typeof route.projection?.mapInput !== 'function') throw new TypeError(`CLI projection ${command.projection.module} 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);', ' }', '};', '', @@ -453,6 +488,18 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) 'const render = (command, input, context) => {', ' const route = routes[command.routeId];', ' const parsed = parseInput(command, route, input);', + ' if (command.projection !== undefined) {', + ' return openRenderedSession({', + " invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },", + ' limits: command.render,', + ' props: { input: parsed },', + " request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", + ' routeId: command.routeId,', + ' signal: context.signal,', + ' terminal: context.terminal,', + ' validate: (value) => route.module.resultSchema.parse(value),', + ' });', + ' }', ' if (command.mcp !== undefined) {', ' return openRenderedSession({', " invocation: { kind: 'tool', props: { input: parsed, operationId: command.routeId } },", diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 15f0f1f03..86eb3c73c 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, type GeneratedCliBinSurface } from './cli-bins.ts'; import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts'; import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; @@ -122,13 +123,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 as GeneratedCliBinSurface; entries.push({ aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, banner: binShebang, @@ -139,7 +135,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, @@ -147,7 +143,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 060e663af..f9c0e1a9d 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -246,7 +246,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)}`; @@ -438,6 +438,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) => [ @@ -451,7 +452,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)'] : []), @@ -549,7 +550,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[] = []; @@ -646,6 +651,7 @@ const parseMcpCommandInput = ( command: CompiledCliCommand, parsed: ParsedArgv, ): ParsedArgv => { + if (command.projection !== undefined) return parsed; if (command.mcp === undefined) return parsed; const raw = parsed.input['input']; let input: unknown = {}; diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index fc1f7f5cb..cc5841e9d 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 { + loadCliProjectionModules, + parseCliCommandInput, + prepareCliRenderHost, + type HarnessOptionsArguments, + type RenderRouteContextInit, +} from './render.ts'; import { harnessTerminal } from './terminal.ts'; import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts'; @@ -186,6 +192,7 @@ export const invokeCli = async ( // process identity at module load, so every separate run starts at hit 1. const processLifetime = createProviderProcessLifetime(); const renderedCommands = manifest.cliCommands.filter((command) => command.rendered); + const projectionModules = await loadCliProjectionModules(manifest, manifest.cliCommands); let executed: CompiledCliCommand | undefined; let value: unknown; @@ -206,6 +213,7 @@ export const invokeCli = async ( modules: renderedModules, onValidated: (validated) => { value = validated; }, processLifetime, + projectionModules, provenance: { kind: 'cli', manifestDigest: manifest.digest, @@ -244,12 +252,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, + projectionModules.get(command.routeId), + input, + ); const root = process.cwd(); const plugin = harnessPluginRoot({ context, manifest, resolvePluginRoot: runtime.resolvePluginRoot }); // Same provider invocation the generated plain-command path builds (#366). diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index fb5ed6ea9..b9d11c91a 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,7 @@ import type { } from '@agent-bundle/runtime'; import type * as React from 'react'; -import { cliInputError } from '../cli-entry.ts'; +import { CliInputError, CliUsageError, cliInputError } from '../cli-entry.ts'; import type { CliRenderedEvent, GeneratedCliRenderContext, @@ -1029,6 +1030,7 @@ export interface PrepareCliRenderHostOptions { readonly manifest: AgentBundleTestManifest; readonly modules: ReadonlyMap; readonly onValidated: (value: unknown) => void; + readonly projectionModules: ReadonlyMap>>; /** The invoking CLI's process identity; the rendered command runs inside that same simulated executable. */ readonly processLifetime: ProviderProcessLifetime; readonly provenance: RenderedRouteProvenance; @@ -1044,6 +1046,69 @@ export interface PreparedCliRenderHost { ) => GeneratedCliRenderSession; } +/** Loads every explicit CLI projection exactly as the generated bin imports it. */ +export const loadCliProjectionModules = async ( + manifest: AgentBundleTestManifest, + commands: readonly CompiledCliCommand[], +): Promise>>> => { + const modules = new Map>>(); + for (const command of commands) { + if (command.projection === undefined || modules.has(command.routeId)) continue; + const source = pathToFileURL(join(manifest.projectRoot, command.projection.module)).href; + modules.set(command.routeId, await import(source) as Readonly>); + } + return modules; +}; + +/** + * Mirrors the generated bin's projection boundary. A `defaultValue` on an + * explicit projection is applied when absent; this may also reapply a static + * zod default, which is idempotent. + */ +export const parseCliCommandInput = ( + command: CompiledCliCommand, + module: AgentRouteModule, + projectionModule: Readonly> | undefined, + input: Readonly>, +): unknown => { + let mapped: Readonly> = { ...input }; + if (command.projection !== undefined && command.mcp?.confirm === true) { + if (mapped['yes'] !== true) { + throw new CliUsageError( + `MCP tool ${command.mcp.server}:${command.mcp.tool} is mutation-capable per its MCP annotations and requires --yes.`, + ); + } + const withoutConfirmation = { ...mapped }; + delete withoutConfirmation['yes']; + mapped = withoutConfirmation; + } + if (command.projection !== undefined) { + const withDefaults: Record = { ...mapped }; + for (const option of command.options) { + if (!Object.hasOwn(withDefaults, option.key) && Object.hasOwn(option, 'defaultValue')) { + withDefaults[option.key] = option.defaultValue; + } + } + mapped = withDefaults; + } + if (command.projection?.mapInput === true) { + const mapInput = projectionModule?.['mapInput']; + if (typeof mapInput !== 'function') { + throw new TypeError(`CLI projection ${command.projection.module} must export a mapInput function.`); + } + try { + mapped = mapInput(mapped) as Readonly>; + } catch (error) { + throw new CliInputError(error instanceof Error ? error.message : String(error)); + } + } + try { + return module.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. @@ -1097,14 +1162,14 @@ 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, + options.projectionModules.get(command.routeId), + input, + ); const commandName = command.path.join(' '); - const invocation: AgentRenderInvocation = command.mcp === undefined + const invocation: AgentRenderInvocation = command.mcp === undefined || command.projection !== undefined ? { kind: 'cli', props: { args: execution.args, command: commandName }, @@ -1150,7 +1215,7 @@ export const prepareCliRenderHost = async ( ...context, ...mounted.context, providers, - invocation: command.mcp === undefined + invocation: command.mcp === undefined || command.projection !== undefined ? { kind: 'cli', operationId: command.routeId, diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 52ffb27bf..0325bafc1 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -633,6 +633,77 @@ 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: 'main', + key: 'laneKey', + kind: 'string', + option: 'lane', + repeated: false, + required: false, + }, + { + key: 'yes', + kind: 'boolean', + option: 'yes', + repeated: false, + required: false, + }, + ], + path: ['submit'], + projection: { + 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 confirmation = source.indexOf('if (command.projection !== undefined && command.mcp?.confirm === true)'); + const defaults = source.indexOf("if (!Object.hasOwn(mapped, option.key) && Object.hasOwn(option, 'defaultValue'))"); + const mapping = source.indexOf('mapped = route.projection.mapInput(mapped)'); + const validation = source.indexOf('return route.module.inputSchema.parse(mapped)'); + expect(confirmation).toBeGreaterThan(-1); + expect(confirmation).toBeLessThan(defaults); + expect(defaults).toBeLessThan(mapping); + expect(mapping).toBeLessThan(validation); + expect(source).toContain('delete mapped.yes;'); + expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} 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/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 2412cb20a..c92c11e26 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,64 @@ import { cliJson, invokeCli } from '../../src/test/cli.ts'; * session contract that the generated executable wires around the shell. */ describe('the CLI dispatch level', () => { + 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.ts', + }, + 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.ts'); + 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']); From 2e98f04076cf893ad532a05f7bf387011e0b04cb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:54:43 +0000 Subject: [PATCH 07/19] feat(routes): project CLI surface projections into the route manifest and Workbench (#596) Copy command.projection and option.aliases onto the browser catalog so a colocated .cli.ts is visible beside usage; leave projectionSources on the compiler surface only. --- packages/agent-bundle/src/contracts/routes.ts | 1 + packages/agent-bundle/src/dev/index.ts | 1 + .../src/dev/routes/route-manifest.ts | 18 +++++ .../tests/route-manifest-routes.test.ts | 77 ++++++++++++++++++ .../src/routes/route-manifest-client.ts | 9 +++ packages/workbench/src/routes/routes-page.css | 6 ++ packages/workbench/src/routes/routes-page.tsx | 38 ++++++++- .../tests/route-manifest-client.test.ts | 74 +++++++++++++++++ packages/workbench/tests/routes-model.test.ts | 81 +++++++++++++++++++ packages/workbench/tests/routes-page.test.ts | 81 +++++++++++++++++++ 10 files changed, 383 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/contracts/routes.ts b/packages/agent-bundle/src/contracts/routes.ts index bc8b60315..1b68ffa04 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, 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..47f26436f 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -10,6 +10,7 @@ import type { CompiledCliCommand, CompiledCliMode, CompiledCliOption, + CompiledCliProjection, CompiledCliSurface, CompiledProvider, CompiledRouteGraph, @@ -94,6 +95,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 +106,13 @@ export interface RouteManifestCliOption { readonly required: boolean; } +/** Mirrors {@link CompiledCliProjection}: the explicit CLI surface projection of one tool. */ +export interface RouteManifestCliProjection { + 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 +121,7 @@ export interface RouteManifestCliCommand { readonly mcp?: NonNullable; readonly options: readonly RouteManifestCliOption[]; readonly path: readonly string[]; + readonly projection?: RouteManifestCliProjection; readonly routeId: string; } @@ -221,6 +231,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 +242,12 @@ const manifestCliOption = (option: CompiledCliOption): RouteManifestCliOption => required: option.required, }); +const manifestCliProjection = (projection: CompiledCliProjection): RouteManifestCliProjection => ({ + 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 +255,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/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 115fb93f8..b0caa557a 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -233,9 +233,86 @@ 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: { + 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({ + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }); + 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/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index fc46087b3..959fabbc6 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,12 @@ const cliOptionSchema: z.ZodType = z.strictObject({ required: z.boolean(), }); +const cliProjectionSchema: z.ZodType = z.strictObject({ + 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 +149,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/src/routes/routes-page.css b/packages/workbench/src/routes/routes-page.css index e5ca89a3f..ca3ee0394 100644 --- a/packages/workbench/src/routes/routes-page.css +++ b/packages/workbench/src/routes/routes-page.css @@ -36,6 +36,12 @@ .route-table tbody th { font-weight: 600; width: 27%; } .route-id { display: block; font: 13px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-weight: 700; overflow-wrap: anywhere; } .route-event, .route-command { color: #345080; display: block; font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin-top: 4px; overflow-wrap: anywhere; } +.route-projection, .route-projection-relaxed { color: #345080; font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin: 6px 0 0; overflow-wrap: anywhere; } +.route-projection-relaxed { color: #596372; } +.route-projection-map { border-collapse: collapse; margin-top: 8px; width: 100%; } +.route-table .route-projection-map th, .route-table .route-projection-map td { border-bottom: 1px solid #e4e8ef; font: 11px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; padding: 3px 8px 3px 0; text-align: left; vertical-align: top; width: auto; } +.route-table .route-projection-map thead th { color: #596372; font-size: 10px; font-weight: 750; letter-spacing: .03em; text-transform: uppercase; } +.route-table .route-projection-map tbody th { font-weight: 600; } .route-description { color: #596372; display: block; font-size: 13px; font-weight: 400; margin-top: 4px; } .route-source { font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; width: 25%; } .route-provenance { color: #7a8492; display: block; font-family: inherit; font-size: 11px; margin-top: 4px; text-transform: uppercase; } diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index b342274c5..b8aeaaeff 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { RouteInputPropertySchema, + RouteManifestCliCommand, RouteManifestState, } from '../../../agent-bundle/src/contracts/routes.ts'; import { routeEditorKey, routeEditorStateAtom } from './route-editor-atoms.ts'; @@ -299,8 +300,39 @@ const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: { ; }; -const commandSummary = (entry: RouteCatalogEntry): string | undefined => - entry.command === undefined ? undefined : cliCommandUsage(entry.command); +const projectionCell = (option: RouteManifestCliCommand['options'][number], empty: string): string => + option.aliases === undefined || option.aliases.length === 0 ? empty : option.aliases.join(', '); + +const CliCommandSurface = ({ command }: { readonly command: RouteManifestCliCommand }) => { + const projection = command.projection; + return <> + {cliCommandUsage(command)} + {projection === undefined ? undefined : <> +

+ Projection {projection.module} · mapInput {projection.mapInput ? 'yes' : 'no'} +

+ + + + + + + + + + {command.options.map((option) => + + + + + )} +
KeyOptionAliasesPositional
{option.key}{option.option}{projectionCell(option, '—')}{option.positional === undefined ? '—' : String(option.positional)}
+ {projection.relaxed === undefined || projection.relaxed.length === 0 + ? undefined + :

Relaxed on the CLI: {projection.relaxed.join(', ')}

} + } + ; +}; const RouteGroup = ({ digest, group, onOpenMcp }: { readonly digest: string; @@ -320,7 +352,7 @@ const RouteGroup = ({ digest, group, onOpenMcp }: { {entry.id} {entry.event === undefined ? undefined : {entry.event}} - {commandSummary(entry) === undefined ? undefined : {commandSummary(entry)}} + {entry.command === undefined ? undefined : } {entry.description === undefined ? undefined : {entry.description}} {entry.source}{entry.provenance} diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index 79c1a9a89..b2fe7e204 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -140,6 +140,80 @@ 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: { + 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({ + mapInput: true, + module: 'src/mcp/hauler/tools/hauler_request.cli.ts', + relaxed: ['cwd'], + }); + expect(decoded.cli?.commands?.[0]?.options).toEqual(projected.options); +}); + +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 8f37a948e..b9bc6f4f4 100644 --- a/packages/workbench/tests/routes-model.test.ts +++ b/packages/workbench/tests/routes-model.test.ts @@ -223,6 +223,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) @@ -342,6 +398,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/packages/workbench/tests/routes-page.test.ts b/packages/workbench/tests/routes-page.test.ts index 2a7b4b01b..684e2befc 100644 --- a/packages/workbench/tests/routes-page.test.ts +++ b/packages/workbench/tests/routes-page.test.ts @@ -158,11 +158,92 @@ it('renders honest state absence without an alert', () => { expect(markup.match(/This project declares no state module\.<\/p>/u)?.[0]).not.toContain('role="alert"'); }); +it('renders a CLI surface projection beside usage and keeps the canonical editor', () => { + const projected: RouteManifest = { + ...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: [], + description: 'Submit a background cargo request', + 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 markup = render(routeCatalogFor(projected)); + + expect(markup).toContain('request <argv...> [--cwd <string>] [--lane <string>]'); + expect(markup).toContain('Projection src/mcp/hauler/tools/hauler_request.cli.ts · mapInput yes'); + expect(markup).toContain('aria-label="CLI option mapping"'); + expect(markup).toContain('>laneKey<'); + expect(markup).toContain('>lane<'); + expect(markup).toContain('>lane-key<'); + expect(markup).toContain('>0<'); + expect(markup).toContain('Relaxed on the CLI: cwd'); + expect(markup).toContain('Generated input editor'); + expect(markup).toContain('Argv (required)'); + expect(markup).toContain('Cwd (required)'); + expect(markup).toContain('Lane Key'); +}); + it('shows the argv projection of a compiled CLI command', () => { const markup = render(routeCatalogFor(manifest)); expect(markup).toContain('library audit <input> [--verbose]'); expect(markup).toContain('Schema not statically projectable'); + expect(markup).not.toContain('Projection '); + expect(markup).not.toContain('Relaxed on the CLI'); +}); + +it('renders mapInput no and omits the relaxed line when the projection has none', () => { + const projected: RouteManifest = { + ...manifest, + cli: { + ...manifest.cli!, + commands: [{ + ...manifest.cli!.commands![0]!, + projection: { + mapInput: false, + module: 'src/mcp/hauler/tools/hauler_status.cli.ts', + }, + }], + }, + }; + const markup = render(routeCatalogFor(projected)); + + expect(markup).toContain('Projection src/mcp/hauler/tools/hauler_status.cli.ts · mapInput no'); + expect(markup).not.toContain('Relaxed on the CLI'); }); it('leads the usage line with positionals in argv order regardless of option order', () => { From 339d65666df898e832e548330213a432379fdb31 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:46:00 +0000 Subject: [PATCH 08/19] test(routes): pin the harness submit projection as an explicit command; document duplicate-projection AB4840 and conventional-CLI AB4804 cases --- docs/diagnostics.md | 4 ++-- .../harness/tools/{_submit.cli.ts => submit.cli.ts} | 0 .../tests/projection/cli-dispatch-projection.test.ts | 2 +- .../tests/projection/cli-dispatch.test.ts | 2 +- .../agent-bundle/tests/test-harness-manifest.test.ts | 12 ++++++++++-- 5 files changed, 14 insertions(+), 6 deletions(-) rename packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/{_submit.cli.ts => submit.cli.ts} (100%) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6337e853c..d3ddb7c22 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1191,7 +1191,7 @@ compile has no correct partial output, so every finding is an error | `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. | @@ -1227,7 +1227,7 @@ compile has no correct partial output, so every finding is an error | `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. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; 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/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/serve-app-command`. | | `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 | A `.cli.{ts,tsx}` module under `src/mcp//tools/` has no sibling tool route `.{ts,tsx}` (orphan), or a `.cli.{ts,tsx}` module sits under `resources/`, `prompts/`, or `apps/`. The suffix is reserved under `src/mcp/**` only. The message is `CLI projection for tool:/: .` and `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. | +| `AB4840` | 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:/: .` and `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. | | `AB4841` | 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 statically a function, 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. | | `AB4842` | 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; 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 }`. | diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/_submit.cli.ts b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.ts similarity index 100% rename from packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/_submit.cli.ts rename to packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.ts diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts index 8a1e575b2..bcb99cef1 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts @@ -193,7 +193,7 @@ describe('the CLI surface projection of tool:harness/submit', () => { 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', 'harness submit']) { + for (const absent of ['--lane-key', '--tags', '--input', '--yes', 'route-harness harness submit']) { expect(help.stdout).not.toContain(absent); } }); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index c92c11e26..35afb3e11 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -98,13 +98,13 @@ describe('the CLI dispatch level', () => { 'harness plugin-root', 'harness publish-notice', 'harness strict-report', - 'harness submit', 'harness ticket', 'harness tooling', 'harness unavailable', 'harness wait', 'inventory', 'report', + 'submit', 'tooling inspect', 'tooling report', ], diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 31601ad4e..c921957db 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -331,7 +331,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), @@ -343,13 +344,20 @@ describe('the compiled test manifest', () => { projected('plugin-root', 'Reports the plugin root and durable-state anchor this route observes.', false), projected('publish-notice', 'Publishes a durable notice for a later session event.', true), projected('strict-report', 'Returns a closed-object report that rejects unknown serialized keys.', true), - projected('submit', 'Submits one command line as lane work and echoes the accepted request.', true), projected('ticket', 'Returns a cargo-conductor-shaped ticket status with optional diagnostics fields.', true), projected('tooling', 'Reports the request providers an MCP tool observes.', false), projected('unavailable', 'Returns a typed unavailable result for projection checks.', true), // 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.ts' }, + routeId: 'tool:harness/submit', + }, + ]); }); it('reuses the compiler pass rather than compiling a second route graph', async () => { From e0ccd31c3adaee15af75e58005b5576761f42e8d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:49:10 +0000 Subject: [PATCH 09/19] chore: changeset names PR #616 --- .changeset/596-cli-surface-projection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/596-cli-surface-projection.md b/.changeset/596-cli-surface-projection.md index bf4a242b5..5e6337a1a 100644 --- a/.changeset/596-cli-surface-projection.md +++ b/.changeset/596-cli-surface-projection.md @@ -2,4 +2,4 @@ "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. Orphan or misplaced modules are `AB4840`, an invalid projection contract is `AB4841`, and a grammar that does not bind to the tool's contract is `AB4842` (#596). +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. Orphan or misplaced modules are `AB4840`, an invalid projection contract is `AB4841`, and a grammar that does not bind to the tool's contract is `AB4842` (#616). From 735e4db1ba1a0e65dd92563d3b71033afcb236c8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:11:57 +0000 Subject: [PATCH 10/19] fix(routes): record projection defaults apart from schema defaults; AB4842 for a confirming key yes and positional spellings; AB4840 message shape (#616) - CompiledCliProjection.defaults: canonical key -> the projection's own flags..default literal (sorted, present only when declared), so the shell applies projection defaults alone before mapInput and a zod .default() stays zod's. CompiledCliOption.defaultValue keeps the effective default for help. Mirrored on RouteManifestCliProjection and the Workbench strict decoder; CliProjectionFlagDefault exported from contracts/routes. - A tool contract key `yes` on a confirming projection is AB4842 against the projection module whatever the key is spelled (CliOptionPolicy .reservedKeys), no longer AB4814 against the tool. - flags..name / .aliases on a key config.positionals consumes is AB4842; description, default, and required: false stay legal there. - AB4840 orphan and duplicate messages use the common `CLI projection for tool:/: .` shape; the misplaced form is `CLI projection : .`; stray duplicated doc comment removed. docs/diagnostics.md rows and the package-entries pages (en, zh) describe the actual forms. --- docs/diagnostics.md | 4 +- docs/entry-conventions.md | 2 +- packages/agent-bundle/src/contracts/routes.ts | 1 + .../src/dev/routes/route-manifest.ts | 9 ++ packages/agent-bundle/src/routes/cli-argv.ts | 54 ++++++-- .../agent-bundle/src/routes/cli-commands.ts | 16 ++- .../agent-bundle/src/routes/cli-projection.ts | 41 ++++-- packages/agent-bundle/src/routes/types.ts | 19 ++- .../agent-bundle/tests/cli-projection.test.ts | 131 +++++++++++++++++- .../tests/route-manifest-routes.test.ts | 5 + .../src/routes/route-manifest-client.ts | 3 + .../tests/route-manifest-client.test.ts | 21 +++ .../en/guide/authoring/package-entries.mdx | 11 +- .../zh/guide/authoring/package-entries.mdx | 11 +- 14 files changed, 290 insertions(+), 38 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d3ddb7c22..003ec9482 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1227,9 +1227,9 @@ compile has no correct partial output, so every finding is an error | `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. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; 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/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/serve-app-command`. | | `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 | 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:/: .` and `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. | +| `AB4840` | 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. | | `AB4841` | 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 statically a function, 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. | -| `AB4842` | 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; 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. | +| `AB4842` | 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 738afc9b0..dfd36a849 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1051,7 +1051,7 @@ id; at run time the tool runs with `agent().invocation.kind` while the operation stays the tool. The bulk `--input` projection is unchanged and still runs as `kind: 'tool'`. `inspect --routes` prints `cli.commands[].projection` -(`module`, `mapInput`, `relaxed?`) and `options[].{key,option,aliases}`. +(`module`, `mapInput`, `defaults?`, `relaxed?`) and `options[].{key,option,aliases}`. ### The stdio MCP lifecycle shell diff --git a/packages/agent-bundle/src/contracts/routes.ts b/packages/agent-bundle/src/contracts/routes.ts index 1b68ffa04..ca0c6af25 100644 --- a/packages/agent-bundle/src/contracts/routes.ts +++ b/packages/agent-bundle/src/contracts/routes.ts @@ -21,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/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 47f26436f..5bf7e37fd 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -5,6 +5,7 @@ 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, @@ -108,6 +109,8 @@ export interface RouteManifestCliOption { /** 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[]; @@ -243,6 +246,12 @@ const manifestCliOption = (option: CompiledCliOption): RouteManifestCliOption => }); 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] }), diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index 8af197f25..bb82129d7 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -62,6 +62,19 @@ export interface CliOptionOverride { 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 @@ -71,13 +84,15 @@ export interface CliOptionOverride { * 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). + * 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) => Diagnostic; + 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.`; @@ -140,9 +155,10 @@ interface CliPropertyProjection { /** 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) => Diagnostic; + readonly overrideError: (detail: string, recovery?: string) => Diagnostic; readonly overrides: Readonly>; readonly reserved: ReadonlySet; + readonly reservedKeys: Readonly>; readonly sourcePath: string; } @@ -154,6 +170,7 @@ const resolvePolicy = (policy: CliOptionPolicy, relativePath: string, sourcePath ?? ((detail) => argvError(`${policy.label ?? defaultLabel(relativePath)} ${detail}.`, sourcePath)), overrides: policy.overrides ?? {}, reserved: new Set([...reservedCliOptionNames, ...(policy.reserved ?? [])]), + reservedKeys: policy.reservedKeys ?? {}, sourcePath, }); @@ -179,16 +196,21 @@ const matchesKind = (base: ScalarBase, value: unknown): boolean => { }; /** - * The argv policy for one schema property: 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. + * 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, policy: ResolvedCliOptionPolicy, ): CliPropertyProjection => { 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); @@ -250,6 +272,8 @@ const cliOptionFor = ( option: { ...(aliases.length === 0 ? {} : { aliases }), ...(property.base.choices === undefined ? {} : { choices: property.base.choices }), + // 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 @@ -267,11 +291,15 @@ const cliOptionFor = ( /** * The option surface one schema projects onto; `options` is absent whenever a - * diagnostic fired. `relaxed` lists, sorted, the canonical-required keys a - * projection override (`required: false` or a CLI `default`) made optional on - * argv; absent when none was. + * 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[]; @@ -288,6 +316,7 @@ const projectOptions = ( entries: readonly ParsedInputSchemaEntry[], policy: ResolvedCliOptionPolicy, ): ProjectedCliOptions => { + const defaults: Record = {}; const diagnostics: Diagnostic[] = []; const options: CompiledCliOption[] = []; const relaxed: string[] = []; @@ -305,6 +334,7 @@ const projectOptions = ( 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: readonly SpellingClaim[] = [ { key: option.key, overridden: override.name !== undefined }, ...(option.aliases ?? []).map(() => ({ key: option.key, overridden: true })), @@ -330,7 +360,11 @@ const projectOptions = ( options.push(option); } if (diagnostics.length > 0) return { diagnostics }; + const defaultKeys = Object.keys(defaults).sort((left, right) => left.localeCompare(right)); return { + ...(defaultKeys.length === 0 + ? {} + : { defaults: Object.fromEntries(defaultKeys.map((key) => [key, defaults[key]!])) }), diagnostics: [], options: [...options].sort((left, right) => left.option.localeCompare(right.option)), ...(relaxed.length === 0 ? {} : { relaxed: [...relaxed].sort((left, right) => left.localeCompare(right)) }), diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index e0ea43355..0c982bf7f 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -5,6 +5,7 @@ import { projectInputSchemaOptions, type CliOptionOverride, type CliOptionPolicy, + type CliReservedKey, type ExtractedCliArgv, } from './cli-argv.ts'; import { @@ -454,6 +455,16 @@ export interface CompiledProjectedCliCommandSurface extends CompiledMcpCliComman const positionalsRecovery = 'Name existing scalar inputSchema keys in argument order; only the last positional may be an array. Then inspect again.'; +/** + * AB4842 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 @@ -511,9 +522,9 @@ export const compileProjectedCliCommands = ( 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) => binding(detail), + overrideError: (detail, recovery) => binding(detail, recovery), overrides, - ...(confirm ? { reserved: ['yes'] } : {}), + ...(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. @@ -573,6 +584,7 @@ export const compileProjectedCliCommands = ( options, path: config.command ?? [module.stem], projection: { + ...(argv.defaults === undefined ? {} : { defaults: argv.defaults }), mapInput: extracted.mapInput, module: relativePath, ...(argv.relaxed === undefined ? {} : { relaxed: argv.relaxed }), diff --git a/packages/agent-bundle/src/routes/cli-projection.ts b/packages/agent-bundle/src/routes/cli-projection.ts index eea77e45b..e64577071 100644 --- a/packages/agent-bundle/src/routes/cli-projection.ts +++ b/packages/agent-bundle/src/routes/cli-projection.ts @@ -125,13 +125,12 @@ export const orphanCliProjectionError = ( sourcePath: string, ): Diagnostic => ({ code: 'AB4840', - message: `CLI projection ${relativePath} has no sibling tool route src/mcp/${module.server}/tools/${module.stem}.{ts,tsx} to project (${module.siblingId}); a projection is never a route of its own.`, + 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, }); -/** AB4840: a `.cli.{ts,tsx}` module under `resources/`, `prompts/`, or `apps/`, where no route takes a CLI projection. */ /** AB4840 for the second of `.cli.ts` and `.cli.tsx`: a tool takes one projection module. */ export const duplicateCliProjectionError = ( relativePath: string, @@ -140,15 +139,20 @@ export const duplicateCliProjectionError = ( sourcePath: string, ): Diagnostic => ({ code: 'AB4840', - message: `CLI projection modules ${existingRelativePath} and ${relativePath} both project ${module.siblingId}; a tool takes one projection module.`, + 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, }); +/** + * AB4840: 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: 'AB4840', - message: `CLI projection ${relativePath} sits where only a tool route takes the .cli suffix (src/mcp//tools/.cli.{ts,tsx}); resources, prompts, and Apps have no argv surface to project.`, + 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, @@ -261,12 +265,16 @@ const validateProjectionConfig = (raw: Readonly>): Confi }; }; +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 and `command` segments must be safe - * identity segments (AB4842); relaxing a canonical-required key needs - * `mapInput` to supply it (AB4841). Spelling rules run later, inside the one - * argv policy, on the final `--options`. + * `positionals` must name contract keys, a positional key takes no option + * spelling, and `command` segments must be safe identity segments (AB4842); + * relaxing a canonical-required key needs `mapInput` to supply it (AB4841). + * Spelling rules run later, inside the one argv policy, on the final + * `--options`. */ const bindProjectionConfig = ( config: CliProjectionConfigRecord, @@ -286,6 +294,23 @@ const bindProjectionConfig = ( )); } } + // 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; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 0a07c8a66..aa5153d9e 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 @@ -213,9 +213,11 @@ export interface CompiledCliOption { /** Accepted values of a `z.enum([...])` base. */ readonly choices?: readonly string[]; /** - * The static `.default()` value, surfaced in generated help — or a - * CLI projection's `flags..default`, which the shell applies to an - * absent option before the canonical `inputSchema` reads the input. + * 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. */ @@ -243,6 +245,15 @@ export interface CompiledCliOption { * `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. */ diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index 6e15da45a..c996a42e5 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -150,6 +150,7 @@ describe('MCP tool CLI surface projections', () => { ' laneKey: z.string(),', ' limit: z.number(),', ' tickets: z.array(z.string()).optional(),', + ' verbose: z.boolean().default(false),', '}).strict()', ].join('\n'); const projection = cliModule([ @@ -213,6 +214,14 @@ describe('MCP tool CLI surface projections', () => { 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', @@ -224,6 +233,9 @@ describe('MCP tool CLI surface projections', () => { ], path: ['req'], projection: { + // Only the projection's own default is the shell's to apply; the + // schema-defaulted `verbose` shows its default in help and is absent. + defaults: { limit: 20 }, mapInput: true, module: projectionPath, relaxed: ['cwd', 'limit'], @@ -233,6 +245,37 @@ describe('MCP tool CLI surface projections', () => { }]); }); + 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']); + // Help shows the projection's default over the schema's. + 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, { @@ -278,7 +321,7 @@ describe('MCP tool CLI surface projections', () => { expect(commands['tool:demo/override']!.options.map((option) => option.option)).not.toContain('yes'); }); - it('reports AB4840 for orphan and misplaced projections while private projections stay parked', async () => { + it('reports AB4840 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('{}'), @@ -288,10 +331,24 @@ describe('MCP tool CLI surface projections', () => { orphan, 'AB4840', orphanRoot, - ['CLI projection src/mcp/demo/tools/ghost.cli.ts', 'tool:demo/ghost', 'no sibling'], + ['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, + 'AB4840', + 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('{}'), @@ -301,9 +358,10 @@ describe('MCP tool CLI surface projections', () => { misplaced, 'AB4840', misplacedRoot, - ['CLI projection src/mcp/demo/resources/submit.cli.ts', 'tool'], + ['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, { @@ -387,6 +445,73 @@ describe('MCP tool CLI surface projections', () => { } }); + it('reports AB4842 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, 'AB4842', 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([]); + } + + // Without confirmation the key is an ordinary option. + 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 }); + }); + + it('rejects name and aliases on a positional key with AB4842 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, 'AB4842', 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 } }), diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index b0caa557a..7246713c8 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -273,6 +273,7 @@ it('projects a CLI surface projection and option aliases without leaking project ], path: ['request'], projection: { + defaults: { cwd: '.', laneKey: ['main', 'next'] }, mapInput: true, module: 'src/mcp/hauler/tools/hauler_request.cli.ts', relaxed: ['cwd'], @@ -299,10 +300,14 @@ it('projects a CLI surface projection and option aliases without leaking project 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 }, diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index 959fabbc6..3488a3747 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -133,6 +133,9 @@ const cliOptionSchema: z.ZodType = z.strictObject({ }); 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(), diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index b2fe7e204..ea660145d 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -158,6 +158,7 @@ it('decodes a CLI surface projection and option aliases on the strict wire', asy ], 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'], @@ -172,6 +173,7 @@ it('decodes a CLI surface projection and option aliases on the strict wire', asy })).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'], @@ -179,6 +181,25 @@ it('decodes a CLI surface projection and option aliases on the strict wire', asy 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: { diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 3ba14d669..09d1caf2f 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -328,7 +328,7 @@ is recorded statically and loaded only by the CLI bin; the MCP worker never sees | `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`. | +| `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)`. | @@ -341,14 +341,17 @@ suffix is reserved under `src/mcp/**`; prefix `_` parks it. An orphan, or a `.cl 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 `AB4841`. 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, or a +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 `AB4842`. Every message is -`CLI projection for tool:/: .` A tool whose schema has no static +`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`, `relaxed?`) and +`cli.commands[].projection` (`module`, `mapInput`, `defaults?`, `relaxed?`) and `options[].{key,option,aliases}` appear with no extra renderer. The Workbench CLI row shows the same facts beside the canonical input editor. The compiled command's `routeId` is the tool id; at run time `invocation.kind` is `'cli'` and `operationId` is that tool id. diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 5c5ce6ff7..377c8e675 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -304,7 +304,7 @@ export const mapInput = ( | `flags..name` | CLI 拼写(kebab-case,不带前导短横线);默认为 `kebab(key)`。 | | `flags..aliases` | 额外的长格式 `--spellings`,使用 kebab-case,不带前导短横线。 | | `flags..description` | 覆盖 schema 的 `.describe()`。 | -| `flags..default` | shell 在 `mapInput` 之前应用的 CLI 专用默认值。 | +| `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)`。 | @@ -316,13 +316,16 @@ export const mapInput = ( `config`、封闭集合之外的键、形状错误的字段、静态上不是函数的 `mapInput`,或在没有 `mapInput` 时对 规范必填键使用 `required: false` / CLI `default`,会触发 `AB4841`。契约中不存在的 `flags`/`positionals` 键、不符合 kebab-case、为保留名(`help`、`json`、`ndjson`、`version`,以及 -启用确认时的 `yes`)或相互冲突的 `name`/别名,以及不是安全身份段的 `command` 段,会触发 `AB4842`。 -每条消息都是 `CLI projection for tool:/: .`。工具一旦有了投影,原本没有 +启用确认时的 `yes`)或相互冲突的 `name`/别名、位置参数键上的 `name` 或 `aliases`(裸参数没有 +`--spelling`)、启用确认的命令所投影的契约含有键 `yes`(无论如何拼写,shell 都会把 `yes` 当作确认 +剥离),以及不是安全身份段的 `command` 段,会触发 `AB4842`。 +每条消息都是 `CLI projection for tool:/: .`(位置错误的模块没有可指名的 +工具:`CLI projection : .`)。工具一旦有了投影,原本没有 静态契约的 schema 就成为不可或缺的语法:`AB4814`/`AB4838`/`AB4839` 会在工具模块上触发,前缀为 `Tool route (CLI projection )`。 `agent-bundle inspect --routes` 会转储编译后的路由图,因此无需额外的 renderer 即可看到 -`cli.commands[].projection`(`module`、`mapInput`、`relaxed?`)与 +`cli.commands[].projection`(`module`、`mapInput`、`defaults?`、`relaxed?`)与 `options[].{key,option,aliases}`。Workbench 的 CLI 行会在规范输入编辑器旁显示同样的信息。 编译后命令的 `routeId` 是工具 id;运行时的 `invocation.kind` 为 `'cli'`,`operationId` 则为该工具 id。 From 6bffd098c14724e6de78bbf0bc4efca10d5c2bba Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:08:32 +0000 Subject: [PATCH 11/19] fix(test): bundle CLI projection loaders --- .../route-harness/src/lib/submit-helpers.ts | 2 + .../tools/{submit.cli.ts => submit.cli.tsx} | 5 +- packages/agent-bundle/src/build/cli-bins.ts | 8 +-- .../agent-bundle/src/build/entry-shell.ts | 16 ++--- .../agent-bundle/src/build/package-build.ts | 4 +- packages/agent-bundle/src/cli-entry.ts | 12 ++-- .../agent-bundle/src/rstest/setup-module.ts | 14 ++++ packages/agent-bundle/src/test/cli.ts | 15 +++-- packages/agent-bundle/src/test/registry.ts | 16 ++++- packages/agent-bundle/src/test/render.ts | 66 +++++++++++-------- .../tests/cli-routes-build.test.ts | 47 +++++++++++-- .../agent-bundle/tests/entry-shell.test.ts | 16 ++++- .../cli-dispatch-projection.test.ts | 6 +- .../tests/projection/cli-dispatch.test.ts | 4 +- .../tests/test-harness-manifest.test.ts | 26 +++++++- 15 files changed, 184 insertions(+), 73 deletions(-) create mode 100644 packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts rename packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/{submit.cli.ts => submit.cli.tsx} (90%) 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..62ea3e59f --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts @@ -0,0 +1,2 @@ +/** Preserves first occurrence order while dropping duplicate fixture values. */ +export const dedupe = (values: readonly Value[]): readonly Value[] => [...new Set(values)]; diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.ts b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.tsx similarity index 90% rename from packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.ts rename to packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.tsx index 08b602522..682cb6e02 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.ts +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/submit.cli.tsx @@ -1,6 +1,7 @@ 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'; /** @@ -32,7 +33,7 @@ type CliInput = Omit, 'cwd'> & { readonly cwd?: stri * which the shell reports as an input failure (exit 2). */ export const mapInput = (input: CliInput): z.input => { - const tags = input.tags === undefined ? undefined : [...new Set(input.tags)]; + 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 "!".`); @@ -40,6 +41,6 @@ export const mapInput = (input: CliInput): z.input => { return { ...input, cwd: input.cwd ?? process.cwd(), - ...(tags === undefined ? {} : { tags }), + ...(tags === undefined ? {} : { tags: [...tags] }), }; }; diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 997631fa6..2a2cc2b72 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -5,7 +5,6 @@ import type { TargetRegistry } from '../adapters/registry.ts'; import { routedCliBinLayout, type TargetArtifactEntry } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts'; -import type { CompiledCliSurface } from '../routes/types.ts'; import { resolveArtifactDestination } from './emit.ts'; import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts'; import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; @@ -61,14 +60,11 @@ interface PlannedCliBin extends CompiledCliBin { readonly rendered: boolean; } -export type GeneratedCliBinSurface = NonNullable - & Pick; - -const generatedCli = (bin: NormalizedBinEntry): GeneratedCliBinSurface => { +const generatedCli = (bin: NormalizedBinEntry): NonNullable => { if (bin.generatedCli === undefined) { throw new Error(`Bin ${JSON.stringify(bin.name)} is not a framework-generated routed CLI.`); } - return bin.generatedCli as GeneratedCliBinSurface; + return bin.generatedCli; }; /** Every project source that can change a generated routed-CLI executable. */ diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 13bbc8abd..fae68c3a2 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -387,7 +387,7 @@ export const generatedCliBinEntrySource = (options: 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, CliUsageError, cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + `import { CliInputError, CliUsageError, cliInputError, confirmationRequiredMessage, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" : "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", @@ -412,23 +412,19 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '', `const commands = Object.freeze(${stableJson(options.commands)});`, '', - // Projection defaults are identified by command.projection plus an - // option's own defaultValue field. This deliberately reapplies static zod - // defaults on projected commands; zod defaults are idempotent, while - // non-projected commands retain their existing schema-owned behavior. 'const parseInput = (command, route, input) => {', ' let mapped = { ...input };', ' if (command.projection !== undefined && command.mcp?.confirm === true) {', - " if (mapped.yes !== true) throw new CliUsageError(`MCP tool ${command.mcp.server}:${command.mcp.tool} is mutation-capable per its MCP annotations and requires --yes.`);", + ' if (mapped.yes !== true) throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool));', ' delete mapped.yes;', ' }', - ' if (command.projection !== undefined) {', - ' for (const option of command.options) {', - " if (!Object.hasOwn(mapped, option.key) && Object.hasOwn(option, 'defaultValue')) mapped[option.key] = option.defaultValue;", + ' 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} must export a mapInput function.`);", + " 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) {', diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 86eb3c73c..59f292449 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -5,7 +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, type GeneratedCliBinSurface } from './cli-bins.ts'; +import { cliBinSourceInputs } from './cli-bins.ts'; import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts'; import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; @@ -124,7 +124,7 @@ export const planPackageEntries = async ( const rendered = bin.generatedCli.commands.some((command) => command.rendered); const workerFile = `${bin.name}-flight.mjs`; const sourceInputs = cliBinSourceInputs(model, bin); - const generatedCli = bin.generatedCli as GeneratedCliBinSurface; + const generatedCli = bin.generatedCli; entries.push({ aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, banner: binShebang, diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index f9c0e1a9d..9975f071a 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. @@ -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). @@ -666,9 +670,7 @@ const parseMcpCommandInput = ( 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.`, - ); + throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); } return { ...parsed, input: input as Readonly> }; }; @@ -945,7 +947,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/rstest/setup-module.ts b/packages/agent-bundle/src/rstest/setup-module.ts index a588c69e2..3b3882d1f 100644 --- a/packages/agent-bundle/src/rstest/setup-module.ts +++ b/packages/agent-bundle/src/rstest/setup-module.ts @@ -41,6 +41,17 @@ 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), + })) + .filter((projection, index, projections) => + projections.findIndex((candidate) => candidate.routeId === projection.routeId) === index) + .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 +69,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 cc5841e9d..81d78215a 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -30,7 +30,7 @@ import { parseCanonicalJsonLine, parseRenderedEventLines } from './output-modes. import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; import { - loadCliProjectionModules, + loadCliProjectionModule, parseCliCommandInput, prepareCliRenderHost, type HarnessOptionsArguments, @@ -192,7 +192,6 @@ export const invokeCli = async ( // process identity at module load, so every separate run starts at hit 1. const processLifetime = createProviderProcessLifetime(); const renderedCommands = manifest.cliCommands.filter((command) => command.rendered); - const projectionModules = await loadCliProjectionModules(manifest, manifest.cliCommands); let executed: CompiledCliCommand | undefined; let value: unknown; @@ -213,7 +212,6 @@ export const invokeCli = async ( modules: renderedModules, onValidated: (validated) => { value = validated; }, processLifetime, - projectionModules, provenance: { kind: 'cli', manifestDigest: manifest.digest, @@ -255,7 +253,7 @@ export const invokeCli = async ( const parsed = parseCliCommandInput( command, module, - projectionModules.get(command.routeId), + await loadCliProjectionModule(manifest, command), input, ); const root = process.cwd(); @@ -300,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 b9d11c91a..df3ba1c12 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -26,7 +26,12 @@ import type { } from '@agent-bundle/runtime'; import type * as React from 'react'; -import { CliInputError, CliUsageError, cliInputError } from '../cli-entry.ts'; +import { + CliInputError, + CliUsageError, + cliInputError, + confirmationRequiredMessage, +} from '../cli-entry.ts'; import type { CliRenderedEvent, GeneratedCliRenderContext, @@ -43,6 +48,7 @@ import { claimProcessHit, harnessPluginRoot, mountProviders } from './providers. import { routeKindTerminal } from './terminal.ts'; import { registeredManifestIdentity, + registeredProjectionLoader, registeredRouteLoader, registeredStateLoader, testManifest, @@ -1030,7 +1036,6 @@ export interface PrepareCliRenderHostOptions { readonly manifest: AgentBundleTestManifest; readonly modules: ReadonlyMap; readonly onValidated: (value: unknown) => void; - readonly projectionModules: ReadonlyMap>>; /** The invoking CLI's process identity; the rendered command runs inside that same simulated executable. */ readonly processLifetime: ProviderProcessLifetime; readonly provenance: RenderedRouteProvenance; @@ -1043,28 +1048,38 @@ export interface PreparedCliRenderHost { command: CompiledCliCommand, input: Readonly>, context: GeneratedCliRenderContext, + projectionModule?: Readonly>, ) => GeneratedCliRenderSession; } -/** Loads every explicit CLI projection exactly as the generated bin imports it. */ -export const loadCliProjectionModules = async ( +/** Loads the dispatched command's explicit CLI projection through the generated registry. */ +export const loadCliProjectionModule = async ( manifest: AgentBundleTestManifest, - commands: readonly CompiledCliCommand[], -): Promise>>> => { - const modules = new Map>>(); - for (const command of commands) { - if (command.projection === undefined || modules.has(command.routeId)) continue; - const source = pathToFileURL(join(manifest.projectRoot, command.projection.module)).href; - modules.set(command.routeId, await import(source) as Readonly>); + 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.', + }, + ); } - return modules; }; -/** - * Mirrors the generated bin's projection boundary. A `defaultValue` on an - * explicit projection is applied when absent; this may also reapply a static - * zod default, which is idempotent. - */ +/** Mirrors the generated bin's confirmation, explicit defaults, mapping, and canonical validation boundary. */ export const parseCliCommandInput = ( command: CompiledCliCommand, module: AgentRouteModule, @@ -1074,27 +1089,23 @@ export const parseCliCommandInput = ( let mapped: Readonly> = { ...input }; if (command.projection !== undefined && command.mcp?.confirm === true) { if (mapped['yes'] !== true) { - throw new CliUsageError( - `MCP tool ${command.mcp.server}:${command.mcp.tool} is mutation-capable per its MCP annotations and requires --yes.`, - ); + throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); } const withoutConfirmation = { ...mapped }; delete withoutConfirmation['yes']; mapped = withoutConfirmation; } - if (command.projection !== undefined) { + if (command.projection?.defaults !== undefined) { const withDefaults: Record = { ...mapped }; - for (const option of command.options) { - if (!Object.hasOwn(withDefaults, option.key) && Object.hasOwn(option, 'defaultValue')) { - withDefaults[option.key] = option.defaultValue; - } + for (const [key, value] of Object.entries(command.projection.defaults)) { + if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; } mapped = withDefaults; } if (command.projection?.mapInput === true) { const mapInput = projectionModule?.['mapInput']; if (typeof mapInput !== 'function') { - throw new TypeError(`CLI projection ${command.projection.module} must export a mapInput function.`); + throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); } try { mapped = mapInput(mapped) as Readonly>; @@ -1140,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) { @@ -1165,7 +1177,7 @@ export const prepareCliRenderHost = async ( const parsed = parseCliCommandInput( command, module, - options.projectionModules.get(command.routeId), + projectionModule, input, ); const commandName = command.path.join(' '); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 542a6c4a3..bdc4908ee 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -536,6 +536,26 @@ describe('the CLI surface projection in the generated routed-CLI executable', () '}', '', ].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')), // The operation: canonical input echoed as the structured result, the // observed surface in the rendered text only. writeProjectFile(root, 'src/mcp/demo/tools/submit.tsx', [ @@ -544,7 +564,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () "export const config = { annotations: { readOnlyHint: false }, description: 'Submits one command line as lane work.' };", 'export const inputSchema = z.object({', ' argv: z.array(z.string()).min(1),', - ' cwd: z.string().min(1),', + " cwd: z.string().min(1).default('.'),", ' laneKey: z.string().optional(),', ' tags: z.array(z.string()).optional(),', '});', @@ -607,6 +627,8 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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', ])); @@ -614,8 +636,8 @@ describe('the CLI surface projection in the generated routed-CLI executable', () // beside the bulk-projected neighbour, and the tool as a route exactly // once — the projection module is not a route. const generatedCli = built.model.packageBuild?.bins[0]?.generatedCli; - expect(generatedCli?.commands.map((command) => command.path.join(' ')).sort()).toEqual(['demo ping', 'submit']); - expect(generatedCli?.routes.map((route) => route.id).sort()).toEqual(['tool:demo/ping', 'tool:demo/submit']); + 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 () => { @@ -626,7 +648,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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\)\.$/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'); @@ -655,6 +677,18 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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('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, @@ -696,7 +730,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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', 'submit']); + 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: [ @@ -706,7 +740,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () expect.objectContaining({ key: 'tags', option: 'tag', repeated: true, required: false }), ], path: ['submit'], - projection: { mapInput: true, module: projectionModule, relaxed: ['cwd'] }, + projection: { mapInput: true, module: projectionModule }, rendered: true, routeId: 'tool:demo/submit', }); @@ -714,6 +748,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () // The projection module is not a route. 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 0325bafc1..bb0f3e566 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -648,6 +648,14 @@ it('imports explicit CLI projections and maps their input before canonical valid 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', @@ -666,6 +674,7 @@ it('imports explicit CLI projections and maps their input before canonical valid ], path: ['submit'], projection: { + defaults: { laneKey: 'main' }, mapInput: true, module: 'src/mcp/curator/tools/submit.cli.ts', relaxed: ['laneKey'], @@ -686,7 +695,7 @@ it('imports explicit CLI projections and maps their input before canonical valid '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); const confirmation = source.indexOf('if (command.projection !== undefined && command.mcp?.confirm === true)'); - const defaults = source.indexOf("if (!Object.hasOwn(mapped, option.key) && Object.hasOwn(option, 'defaultValue'))"); + 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(confirmation).toBeGreaterThan(-1); @@ -694,7 +703,10 @@ it('imports explicit CLI projections and maps their input before canonical valid expect(defaults).toBeLessThan(mapping); expect(mapping).toBeLessThan(validation); expect(source).toContain('delete mapped.yes;'); - expect(source).toContain("throw new TypeError(`CLI projection ${command.projection.module} must export a mapInput function.`)"); + expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); + expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); + expect(source).toContain('confirmationRequiredMessage(command.mcp.server, command.mcp.tool)'); + 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(' ') } }", diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts index bcb99cef1..1f8f9ff57 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts @@ -6,7 +6,7 @@ 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.ts` projects `tool:harness/submit` + * 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 @@ -40,7 +40,7 @@ describe('the CLI surface projection of tool:harness/submit', () => { 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.ts', relaxed: ['cwd'] }, + projection: { mapInput: true, module: 'src/mcp/harness/tools/submit.cli.tsx', relaxed: ['cwd'] }, rendered: true, routeId: 'tool:harness/submit', }); @@ -186,7 +186,7 @@ describe('the CLI surface projection of tool:harness/submit', () => { 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.ts'); + 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); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 35afb3e11..b6268cb8e 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -34,7 +34,7 @@ describe('the CLI dispatch level', () => { path: ['submit'], projection: { mapInput: false, - module: 'src/mcp/harness/tools/submit.cli.ts', + module: 'src/mcp/harness/tools/submit.cli.tsx', }, rendered: false, routeId: 'tool:harness/submit', @@ -62,7 +62,7 @@ describe('the CLI dispatch level', () => { 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.ts'); + 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) => { diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index c921957db..8740e05fd 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -11,6 +11,7 @@ import { compileTestManifest, proofLevelLabel, testManifestFromRouteGraph } from import { AGENT_TEST_REGISTRY_SYMBOL_KEY, AGENT_TEST_REGISTRY_VERSION, + registeredProjectionLoader, registerTestRoutes, testManifest, } from '../src/test/registry.ts'; @@ -354,7 +355,7 @@ describe('the compiled test manifest', () => { { mcp: { confirm: false, server: 'harness', tool: 'submit' }, path: ['submit'], - projection: { mapInput: true, module: 'src/mcp/harness/tools/submit.cli.ts' }, + projection: { mapInput: true, module: 'src/mcp/harness/tools/submit.cli.tsx' }, routeId: 'tool:harness/submit', }, ]); @@ -505,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")]'); From 0e54f54f60c9d071bfb151472c1788e50d66ddad Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:38:22 +0000 Subject: [PATCH 12/19] fix(workbench): drop Routes-page projection UI; document CLI invocation.kind (#616) Projection details belong in the #600 operation inspector, not this page. A route observes kind: 'cli' from the generated CLI executable, including the bulk mcpCommands projection. --- .changeset/596-cli-surface-projection.md | 2 +- docs/entry-conventions.md | 32 +++++++++------ packages/workbench/src/routes/routes-page.css | 6 --- packages/workbench/src/routes/routes-page.tsx | 40 +++---------------- packages/workbench/tests/routes-page.test.ts | 35 +++------------- website/docs/en/guide/authoring/mcp.mdx | 6 ++- .../en/guide/authoring/package-entries.mdx | 28 ++++++++----- .../docs/en/guide/development/workbench.mdx | 2 +- .../docs/en/guide/start/project-structure.mdx | 4 +- website/docs/en/reference/configuration.mdx | 2 +- website/docs/zh/guide/authoring/mcp.mdx | 6 ++- .../zh/guide/authoring/package-entries.mdx | 22 ++++++---- .../docs/zh/guide/development/workbench.mdx | 2 +- .../docs/zh/guide/start/project-structure.mdx | 4 +- website/docs/zh/reference/configuration.mdx | 2 +- 15 files changed, 80 insertions(+), 113 deletions(-) diff --git a/.changeset/596-cli-surface-projection.md b/.changeset/596-cli-surface-projection.md index 5e6337a1a..5e8551f65 100644 --- a/.changeset/596-cli-surface-projection.md +++ b/.changeset/596-cli-surface-projection.md @@ -2,4 +2,4 @@ "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. Orphan or misplaced modules are `AB4840`, an invalid projection contract is `AB4841`, and a grammar that does not bind to the tool's contract is `AB4842` (#616). +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 `AB4840`, an invalid projection contract is `AB4841`, and a grammar that does not bind to the tool's contract is `AB4842` (#616). diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 176a6f8d2..2cb13a649 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1007,13 +1007,13 @@ 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`) 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 `AB4840`. +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 `AB4840`. The module exports a static `config` that satisfies `CliProjectionConfig` from `agent-bundle/routes` (the same extract grammar as a route `config`) @@ -1039,9 +1039,14 @@ and, optionally, a synchronous `mapInput`: `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. A contract problem is -`AB4841`; a grammar that does not bind to the tool's contract is -`AB4842`. Message shape: +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 `AB4841`; a grammar that does not bind to the tool's +contract is `AB4842`. Message shape: `CLI projection for tool:/: .` The explicit projection takes precedence over the bulk `mcpCommands` @@ -1050,8 +1055,11 @@ 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. The bulk -`--input` projection is unchanged and still runs as `kind: 'tool'`. +`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}`. diff --git a/packages/workbench/src/routes/routes-page.css b/packages/workbench/src/routes/routes-page.css index ca3ee0394..e5ca89a3f 100644 --- a/packages/workbench/src/routes/routes-page.css +++ b/packages/workbench/src/routes/routes-page.css @@ -36,12 +36,6 @@ .route-table tbody th { font-weight: 600; width: 27%; } .route-id { display: block; font: 13px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-weight: 700; overflow-wrap: anywhere; } .route-event, .route-command { color: #345080; display: block; font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin-top: 4px; overflow-wrap: anywhere; } -.route-projection, .route-projection-relaxed { color: #345080; font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin: 6px 0 0; overflow-wrap: anywhere; } -.route-projection-relaxed { color: #596372; } -.route-projection-map { border-collapse: collapse; margin-top: 8px; width: 100%; } -.route-table .route-projection-map th, .route-table .route-projection-map td { border-bottom: 1px solid #e4e8ef; font: 11px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; padding: 3px 8px 3px 0; text-align: left; vertical-align: top; width: auto; } -.route-table .route-projection-map thead th { color: #596372; font-size: 10px; font-weight: 750; letter-spacing: .03em; text-transform: uppercase; } -.route-table .route-projection-map tbody th { font-weight: 600; } .route-description { color: #596372; display: block; font-size: 13px; font-weight: 400; margin-top: 4px; } .route-source { font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; width: 25%; } .route-provenance { color: #7a8492; display: block; font-family: inherit; font-size: 11px; margin-top: 4px; text-transform: uppercase; } diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index b8aeaaeff..4252a6c14 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -3,7 +3,6 @@ import React from 'react'; import type { RouteInputPropertySchema, - RouteManifestCliCommand, RouteManifestState, } from '../../../agent-bundle/src/contracts/routes.ts'; import { routeEditorKey, routeEditorStateAtom } from './route-editor-atoms.ts'; @@ -300,39 +299,10 @@ const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: { ; }; -const projectionCell = (option: RouteManifestCliCommand['options'][number], empty: string): string => - option.aliases === undefined || option.aliases.length === 0 ? empty : option.aliases.join(', '); - -const CliCommandSurface = ({ command }: { readonly command: RouteManifestCliCommand }) => { - const projection = command.projection; - return <> - {cliCommandUsage(command)} - {projection === undefined ? undefined : <> -

- Projection {projection.module} · mapInput {projection.mapInput ? 'yes' : 'no'} -

- - - - - - - - - - {command.options.map((option) => - - - - - )} -
KeyOptionAliasesPositional
{option.key}{option.option}{projectionCell(option, '—')}{option.positional === undefined ? '—' : String(option.positional)}
- {projection.relaxed === undefined || projection.relaxed.length === 0 - ? undefined - :

Relaxed on the CLI: {projection.relaxed.join(', ')}

} - } - ; -}; +// command.projection is decoded and available on the manifest; it surfaces in +// the selected-operation inspector (#600), not on this page. +const commandSummary = (entry: RouteCatalogEntry): string | undefined => + entry.command === undefined ? undefined : cliCommandUsage(entry.command); const RouteGroup = ({ digest, group, onOpenMcp }: { readonly digest: string; @@ -352,7 +322,7 @@ const RouteGroup = ({ digest, group, onOpenMcp }: { {entry.id} {entry.event === undefined ? undefined : {entry.event}} - {entry.command === undefined ? undefined : } + {commandSummary(entry) === undefined ? undefined : {commandSummary(entry)}} {entry.description === undefined ? undefined : {entry.description}} {entry.source}{entry.provenance} diff --git a/packages/workbench/tests/routes-page.test.ts b/packages/workbench/tests/routes-page.test.ts index 684e2befc..164f46fda 100644 --- a/packages/workbench/tests/routes-page.test.ts +++ b/packages/workbench/tests/routes-page.test.ts @@ -158,7 +158,7 @@ it('renders honest state absence without an alert', () => { expect(markup.match(/This project declares no state module\.<\/p>/u)?.[0]).not.toContain('role="alert"'); }); -it('renders a CLI surface projection beside usage and keeps the canonical editor', () => { +it('keeps usage and the canonical editor for a projected command without projection UI', () => { const projected: RouteManifest = { ...manifest, cli: { @@ -204,17 +204,14 @@ it('renders a CLI surface projection beside usage and keeps the canonical editor const markup = render(routeCatalogFor(projected)); expect(markup).toContain('request <argv...> [--cwd <string>] [--lane <string>]'); - expect(markup).toContain('Projection src/mcp/hauler/tools/hauler_request.cli.ts · mapInput yes'); - expect(markup).toContain('aria-label="CLI option mapping"'); - expect(markup).toContain('>laneKey<'); - expect(markup).toContain('>lane<'); - expect(markup).toContain('>lane-key<'); - expect(markup).toContain('>0<'); - expect(markup).toContain('Relaxed on the CLI: cwd'); expect(markup).toContain('Generated input editor'); expect(markup).toContain('Argv (required)'); expect(markup).toContain('Cwd (required)'); expect(markup).toContain('Lane Key'); + expect(markup).not.toContain('Projection '); + expect(markup).not.toContain('mapInput'); + expect(markup).not.toContain('CLI option mapping'); + expect(markup).not.toContain('Relaxed on the CLI'); }); it('shows the argv projection of a compiled CLI command', () => { @@ -222,28 +219,6 @@ it('shows the argv projection of a compiled CLI command', () => { expect(markup).toContain('library audit <input> [--verbose]'); expect(markup).toContain('Schema not statically projectable'); - expect(markup).not.toContain('Projection '); - expect(markup).not.toContain('Relaxed on the CLI'); -}); - -it('renders mapInput no and omits the relaxed line when the projection has none', () => { - const projected: RouteManifest = { - ...manifest, - cli: { - ...manifest.cli!, - commands: [{ - ...manifest.cli!.commands![0]!, - projection: { - mapInput: false, - module: 'src/mcp/hauler/tools/hauler_status.cli.ts', - }, - }], - }, - }; - const markup = render(routeCatalogFor(projected)); - - expect(markup).toContain('Projection src/mcp/hauler/tools/hauler_status.cli.ts · mapInput no'); - expect(markup).not.toContain('Relaxed on the CLI'); }); it('leads the usage line with positionals in argv order regardless of option order', () => { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index a92f6fbb0..5c6d10459 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -92,8 +92,10 @@ bound to schemas imported from a relative module inside the project 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` is a CLI surface projection of the same operation (`tool:/`), not a -`cli:` route. See +`.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 diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 00a4a06c1..f758e7885 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -231,9 +231,9 @@ automatic kebab projection of the schema. cargo-hauler keeps `src/cli/status.tsx `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` is the CLI *surface* -projection of the same operation — identity stays `tool:/` — and is not a route. -Host projection (`targets`) is unchanged and orthogonal. +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`: @@ -319,6 +319,10 @@ export const mapInput = ( 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 | | --- | --- | @@ -352,9 +356,11 @@ 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 CLI row shows the -same facts beside the canonical input editor. The compiled command's `routeId` is the tool id; -at run time `invocation.kind` is `'cli'` and `operationId` is that tool id. +`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 @@ -393,11 +399,15 @@ is unchanged. 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. A tool that already has a colocated -`.cli.ts` 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. +`.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. A tool is +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 diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index b6e7b602a..f6d849852 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -30,7 +30,7 @@ These are contracts, not defaults: | Page | Contents | | --- | --- | | Overview | Project identity, normalized model, and diagnostics. | -| Routes | The compiled route catalog from the same compiler pass as `inspect --routes`: each route's source module, config summary, and a generated input editor; a route with a static contract names the contract's declaring module and any other routes sharing it; a CLI command compiled from a `.cli.ts` names the projection module, whether `mapInput` is present, and the key ↔ option (+ aliases) table beside the canonical input editor. | +| Routes | The compiled route catalog from the same compiler pass as `inspect --routes`: each route's source module, config summary, and a generated input editor; a route with a static contract names the contract's declaring module and any other routes sharing it. The route manifest carries `cli.commands[].projection` (`inspect --routes` shows it); the Workbench will surface that in the selected-operation inspector. | | Skills | Every Skill document, including each host's lowered output. | | Artifacts | The composite plugin root — one tree, whichever selected host is in focus — with provenance and epoch comparison. | | MCP | An artifact-bound playground with the raw protocol trace, MCP App previews, and a launcher for the standalone MCP Inspector. | diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index 2f56af77a..a80915be2 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -23,7 +23,7 @@ my-plugin/ ├── mcp/.ts # a handwritten stdio MCP server entry ├── mcp// # or a generated server, one module per route │ ├── tools/*.tsx - │ ├── tools/.cli.ts # opt-in CLI surface projection; not a route + │ ├── tools/.cli.ts # colocated projection module; never a route │ ├── resources/*.tsx │ ├── prompts/*.tsx │ ├── apps/*.tsx # browser MCP Apps compiled to self-contained HTML @@ -46,7 +46,7 @@ my-plugin/ | `src/rules/*.mdc` | Flat host rule documents, emitted by Cursor, which keeps `description`, `globs`, and `alwaysApply`. The same per-host judgment applies: `AB4907` for an explicit target, `AB4908` as a warning for an implicit one. | Remove the file. | | `src/mcp/.ts` | Stdio entry for a declared MCP server that names no `entry`, `command`, or `url`. | Declare `entry` explicitly. | | `src/mcp//{tools,resources,prompts}/*` | Generated MCP server routes. The path supplies identity; each module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote`. | -| `src/mcp//tools/.cli.ts` | CLI surface projection of the sibling tool route. Not a route; excluded from `routes.mcpCommands`. See [Project one tool as an idiomatic command](../authoring/package-entries.mdx#project-one-tool-as-an-idiomatic-command). | Prefix `_` to park it. | +| `src/mcp//tools/.cli.ts` | Colocated projection module for the sibling tool. Never a route; excluded from `routes.mcpCommands`. See [Project one tool as an idiomatic command](../authoring/package-entries.mdx#project-one-tool-as-an-idiomatic-command). | Prefix `_` to park it. | | `src/mcp//apps/*` | Browser MCP App entries compiled to self-contained HTML and registered on the generated server. Static `config.resourceUri` is required. | Use a custom server, or prefix the file with `_`. | | `src/scripts/.ts` | A plain script compiled once to `scripts/.mjs` at the artifact root, shared by every selected host. Nested modules are a hard error (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry. | | `src/scripts/.tsx` | A rendered script: the async default component receives `argv` and `signal` and renders through the Agent renderer with the CLI output contract. | Rename to `.ts`, prefix a path segment with `_`, or claim the file. | diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 6644a241f..d1f17fcdd 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -58,7 +58,7 @@ 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. A tool that already has a colocated `.cli.ts` CLI surface projection is +`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). diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 1f9a989b3..c2f37da3d 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -81,8 +81,10 @@ 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:` 路由。参见 +生成式工具也可以在不增加第二条路由的情况下暴露惯用的 CLI 命令:同位置的 `.cli.ts` 投影模块 +是同一操作(`tool:/`)的 CLI 表面投影,绝不是一条路由。只要路由从生成的 CLI +可执行文件运行,无论命令由哪种投影机制产生,它观察到的都是 `kind: 'cli'`;生成的 MCP 服务器仍传入 +`kind: 'tool'`。参见 [将一个工具投影为惯用命令](./package-entries.mdx#将一个工具投影为惯用命令)。 路由可以从另一个模块重新导出自己的组件与 schema。当同一个工具需要放在两个生成的服务器上、而两处 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 9870828ad..2079c794d 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -207,9 +207,9 @@ Workbench 与 `agent-bundle inspect --routes` 读取的正是它。 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`) -保持不变,并且与此正交。 +操作(`cli:status`),拥有自己的 `operationId` 与 typegen 条目。同位置的 `.cli.ts` 投影模块 +则是同一操作的 CLI *表面*投影——身份仍为 `tool:/`——绝不是一条路由。宿主投影 +(`targets`)保持不变,并且与此正交。 该模块位于工具旁边,绝不放在 `src/cli/**` 下。Status 只需重命名;解析器已经发出规范键,因此不需要 `mapInput`: @@ -294,7 +294,9 @@ export const mapInput = ( `CliProjectionConfig` 从 `agent-bundle/routes` 导出。`flags` 与 `positionals` 的键受限于 `keyof z.input`。`config` 使用与路由模块相同的静态提取语法(会解包 `satisfies`)。 `mapInput` 是普通的命名导出,其存在性会被静态记录,并且只由 CLI bin 加载;MCP worker 永远不会看到 -该模块。 +该模块。`mapInput` 是表面适配器,不是领域逻辑:它只把 argv 重塑或补默认值成规范输入(重命名、拆分 +列表、派生工作目录)。领域校验与行为留在操作里——它的 `inputSchema` 精化以及它的组件。在 mapper +里重写一遍命令逻辑,正是投影要去掉的重复。 | 键 | 含义 | | --- | --- | @@ -326,8 +328,10 @@ export const mapInput = ( `agent-bundle inspect --routes` 会转储编译后的路由图,因此无需额外的 renderer 即可看到 `cli.commands[].projection`(`module`、`mapInput`、`defaults?`、`relaxed?`)与 -`options[].{key,option,aliases}`。Workbench 的 CLI 行会在规范输入编辑器旁显示同样的信息。 -编译后命令的 `routeId` 是工具 id;运行时的 `invocation.kind` 为 `'cli'`,`operationId` 则为该工具 id。 +`options[].{key,option,aliases}`。Workbench 会在选中操作的 inspector 中展示这些事实。 +编译后命令的 `routeId` 是工具 id;运行时的 `invocation.kind` 为 `'cli'`,`operationId` 则为该工具 +id。只要路由从生成的 CLI 可执行文件运行,无论命令由哪种投影机制产生,它观察到的都是 +`kind: 'cli'`。生成的 MCP 服务器仍传入 `kind: 'tool'`。 没有投影模块,就不会通过这条路径生成命令;批量选择加入的 [`routes.mcpCommands`](#把-mcp-工具投影进-cli) 对其他所有工具仍照常工作。任何投影字段都不会改变 MCP @@ -358,12 +362,14 @@ export const mapInput = ( `routes.mcpCommands` 把生成式 MCP 服务器的工具加入同一张命令图与同一个可执行文件,即使项目完全没有 `src/cli/**` 路由也可以。`true` 选中每个符合条件的工具;对象形式接受匹配 `:` 身份的 -`include` 与 `exclude` 模式,其中 `*` 是唯一的通配符。已经有同位置 `.cli.ts` 的工具会从这次 +`include` 与 `exclude` 模式,其中 `*` 是唯一的通配符。已经有同位置 `.cli.ts` 投影模块的工具会从这次 批量投影中排除:一个操作只编译成一个命令。仅匹配这类工具的 `include` 模式会触发 `AB4822`,并点名 投影模块。 每个被批量投影的工具以 ` ` 运行,协议工具名逐字保留。它唯一的输入选项是 -`--input`,接受一个 JSON 对象。只有当工具的静态 MCP annotations 明确设置了 `readOnlyHint: true` 时 +`--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/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index c4317dc89..c64dcba82 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -26,7 +26,7 @@ npx agent-bundle dev --root . --port 3100 --no-open | 页面 | 内容 | | --- | --- | | Overview | 项目标识、规范化模型与诊断。 | -| Routes | 来自与 `inspect --routes` 相同编译器 pass 的已编译路由目录:每条路由的源码模块、配置摘要与生成的输入编辑器;带静态契约的路由还会指明契约的声明模块及共享该契约的其他路由;由 `.cli.ts` 编译而来的 CLI 命令会在规范输入编辑器旁指明投影模块、是否存在 `mapInput`,以及键 ↔ 选项(含别名)表。 | +| Routes | 来自与 `inspect --routes` 相同编译器 pass 的已编译路由目录:每条路由的源码模块、配置摘要与生成的输入编辑器;带静态契约的路由还会指明契约的声明模块及共享该契约的其他路由。路由清单携带 `cli.commands[].projection`(`inspect --routes` 会显示它);Workbench 会在选中操作的 inspector 中展示。 | | Skills | 每个 Skill 文档,包括各宿主降级后的输出。 | | Artifacts | 复合插件根目录——无论聚焦哪个选中宿主,都是同一棵树——以及 provenance 与 epoch 对比。 | | MCP | 绑定到产物的 playground,带原始协议轨迹、MCP App 预览,以及独立 MCP Inspector 的启动器。 | diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index dd3b00555..0b51a7149 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -22,7 +22,7 @@ my-plugin/ ├── mcp/.ts # 手写的 stdio MCP 服务器入口 ├── mcp// # 或生成式服务器,每个路由一个模块 │ ├── tools/*.tsx - │ ├── tools/.cli.ts # 选择加入的 CLI 表面投影;不是路由 + │ ├── tools/.cli.ts # 同位置的投影模块;绝不是路由 │ ├── resources/*.tsx │ ├── prompts/*.tsx │ ├── apps/*.tsx # 编译为自包含 HTML 的浏览器 MCP App @@ -45,7 +45,7 @@ my-plugin/ | `src/rules/*.mdc` | 扁平的宿主规则文档,由 Cursor 发射,保留 `description`、`globs` 与 `alwaysApply`。同样的按宿主判定适用:显式 target 为 `AB4907`,隐式 target 为警告 `AB4908`。 | 删除该文件。 | | `src/mcp/.ts` | 某个已声明、但未指定 `entry`、`command` 或 `url` 的 MCP 服务器的 stdio 入口。 | 显式声明 `entry`。 | | `src/mcp//{tools,resources,prompts}/*` | 生成式 MCP 服务器路由。路径提供身份;每个模块提供静态 `config`、schema,以及一个 async 默认 Server Component。 | 把 `routes.servers.` 设为 `custom`、`command` 或 `remote`。 | -| `src/mcp//tools/.cli.ts` | 同级工具路由的 CLI 表面投影。不是路由;会从 `routes.mcpCommands` 中排除。参见[将一个工具投影为惯用命令](../authoring/package-entries.mdx#将一个工具投影为惯用命令)。 | 加 `_` 前缀以停用它。 | +| `src/mcp//tools/.cli.ts` | 同级工具的同位置投影模块。绝不是路由;会从 `routes.mcpCommands` 中排除。参见[将一个工具投影为惯用命令](../authoring/package-entries.mdx#将一个工具投影为惯用命令)。 | 加 `_` 前缀以停用它。 | | `src/mcp//apps/*` | 浏览器 MCP App 入口,编译为自包含 HTML 并注册到生成的服务器上。必须提供静态 `config.resourceUri`。 | 使用自定义服务器,或给文件名加 `_` 前缀。 | | `src/scripts/.ts` | 一个普通脚本,只编译一次,输出为产物根目录下的 `scripts/.mjs`,所有所选宿主共用。嵌套模块是硬错误(`AB4808`)。 | 给某一段路径加 `_` 前缀,或用显式 `scripts` 条目认领该文件。 | | `src/scripts/.tsx` | 渲染式脚本:async 默认组件接收 `argv` 与 `signal`,并按 CLI 输出契约通过 Agent 渲染器渲染。 | 改名为 `.ts`、给某一段路径加 `_` 前缀,或认领该文件。 | diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index e61f71e3f..b6177ab9e 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -52,7 +52,7 @@ export default defineConfig({ `AgentBundleConfig` 的成员——它们经由 `[key: string]: unknown` 索引签名传入——因此 `tsc` 不会检查它们的 形状;`evals` 的规则要到 eval 运行时才生效(`EVAL_CONFIG_INVALID`),`agent-bundle validate` 不会报告它们, 而 `routes` 覆盖块由路由图在发现阶段校验,其诊断随 `validateSource` 一并报告。同位置已有 -`.cli.ts` CLI 表面投影的工具会从 `routes.mcpCommands` 中排除,避免为同一个操作再编译一条批量 +`.cli.ts` 投影模块的工具会从 `routes.mcpCommands` 中排除,避免为同一个操作再编译一条批量 ` --input` 命令。参见 [将一个工具投影为惯用命令](../guide/authoring/package-entries.mdx#将一个工具投影为惯用命令)。 下面这些精确形态在每次文档构建时由 TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 From 541d5cdbf05be7ce6410bdb82693d4490791d854 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:42:21 +0000 Subject: [PATCH 13/19] fix(agent-bundle): normalize projected CLI invocation --- .../src/mcp/harness/tools/mutation-probe.tsx | 4 +- .../src/providers/library-tooling.ts | 7 ++- .../agent-bundle/src/build/entry-shell.ts | 10 ++--- packages/agent-bundle/src/cli-entry.ts | 12 ++++-- packages/agent-bundle/src/test/render.ts | 43 +++++-------------- .../tests/cli-routes-build.test.ts | 14 +++--- .../agent-bundle/tests/entry-shell.test.ts | 15 +++---- .../tests/projection/cli-dispatch-mcp.test.ts | 2 +- .../cli-dispatch-projection.test.ts | 17 ++++++++ .../tests/projection/cli-dispatch.test.ts | 40 +++++++++++++++++ .../tests/projection/providers.test.ts | 4 +- 11 files changed, 103 insertions(+), 65 deletions(-) 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/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/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index d36227291..4e564f766 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -388,7 +388,7 @@ export const generatedCliBinEntrySource = (options: 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, CliUsageError, cliInputError, confirmationRequiredMessage, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + `import { CliInputError, cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered ? "import { available, createAgentRenderDispatcher, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';" : "import { available, resolvePluginRoot, runAgentRequest, unavailable } from '@agent-bundle/runtime';", @@ -415,10 +415,6 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '', 'const parseInput = (command, route, input) => {', ' let mapped = { ...input };', - ' if (command.projection !== undefined && command.mcp?.confirm === true) {', - ' if (mapped.yes !== true) throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool));', - ' delete mapped.yes;', - ' }', ' if (command.projection?.defaults !== undefined) {', ' for (const [key, value] of Object.entries(command.projection.defaults)) {', ' if (!Object.hasOwn(mapped, key)) mapped[key] = value;', @@ -499,10 +495,10 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ' }', ' if (command.mcp !== undefined) {', ' return openRenderedSession({', - " invocation: { kind: 'tool', props: { input: parsed, operationId: command.routeId } },", + " invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },", ' limits: command.render,', ' props: { input: parsed },', - ` request: { artifactEpoch: ${JSON.stringify(generatedRouteArtifactEpoch(options.plugin))}, kind: 'tool', operationId: command.routeId, surface: command.mcp.tool },`, + " request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", ' routeId: command.routeId,', ' signal: context.signal,', ' terminal: context.terminal,', diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index 9975f071a..eec34846f 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -655,8 +655,15 @@ const parseMcpCommandInput = ( command: CompiledCliCommand, parsed: ParsedArgv, ): ParsedArgv => { - if (command.projection !== undefined) return parsed; 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) { + const input = { ...parsed.input }; + delete input['yes']; + return { ...parsed, input }; + } const raw = parsed.input['input']; let input: unknown = {}; if (raw !== undefined) { @@ -669,9 +676,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(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); - } return { ...parsed, input: input as Readonly> }; }; diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 09160bd8b..70de3aef6 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -28,9 +28,7 @@ import type * as React from 'react'; import { CliInputError, - CliUsageError, cliInputError, - confirmationRequiredMessage, } from '../cli-entry.ts'; import type { CliRenderedEvent, @@ -1096,14 +1094,6 @@ export const parseCliCommandInput = ( input: Readonly>, ): unknown => { let mapped: Readonly> = { ...input }; - if (command.projection !== undefined && command.mcp?.confirm === true) { - if (mapped['yes'] !== true) { - throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); - } - const withoutConfirmation = { ...mapped }; - delete withoutConfirmation['yes']; - mapped = withoutConfirmation; - } if (command.projection?.defaults !== undefined) { const withDefaults: Record = { ...mapped }; for (const [key, value] of Object.entries(command.projection.defaults)) { @@ -1190,15 +1180,10 @@ export const prepareCliRenderHost = async ( input, ); const commandName = command.path.join(' '); - const invocation: AgentRenderInvocation = command.mcp === undefined || command.projection !== 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({ @@ -1236,20 +1221,12 @@ export const prepareCliRenderHost = async ( ...context, ...mounted.context, providers, - invocation: command.mcp === undefined || command.projection !== 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-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 1698f2f73..a54061b63 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -205,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 });", @@ -219,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 };', @@ -355,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, [ @@ -383,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', }); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 0835f65ce..afef5ad85 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -601,7 +601,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', @@ -633,8 +633,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 @@ -704,18 +704,17 @@ it('imports explicit CLI projections and maps their input before canonical valid expect(source).toContain( '"tool:curator/submit": Object.freeze({ module: route0, projection: projection0 })', ); - const confirmation = source.indexOf('if (command.projection !== undefined && command.mcp?.confirm === true)'); 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(confirmation).toBeGreaterThan(-1); - expect(confirmation).toBeLessThan(defaults); + 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('delete mapped.yes;'); expect(source).toContain('if (!Object.hasOwn(mapped, key)) mapped[key] = value;'); expect(source).not.toContain("Object.hasOwn(option, 'defaultValue')"); - expect(source).toContain('confirmationRequiredMessage(command.mcp.server, command.mcp.tool)'); 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( 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 index 1f8f9ff57..fc76215f2 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts @@ -22,6 +22,23 @@ 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'); diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index b6268cb8e..55e3df9ab 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -17,6 +17,46 @@ 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('accepts projected option aliases, prints projection help, and spells schema failures as projected flags', async () => { const command: CompiledCliCommand = { aliases: [], 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 }), }); From fd38d3cca5018b389415e41a058cbe3193b1f0b2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:43:03 +0000 Subject: [PATCH 14/19] fix(routes): AB4841 for ambient, generator, async, and unfollowable re-exported mapInput (#616) scanRouteModuleExports records namedAmbient, namedGeneratorFunctions, and namedUnresolved (name -> specifier) and keeps declare-d bindings out of namedFunctions, so a projection module's mapInput compiles only when it is a synchronous, non-generator function with a runtime binding. A relative re-export is followed to where the function is declared; one the scan cannot follow is rejected. --- docs/diagnostics.md | 2 +- .../agent-bundle/src/routes/cli-projection.ts | 60 ++++++--- packages/agent-bundle/src/routes/contract.ts | 80 +++++++---- .../agent-bundle/tests/cli-projection.test.ts | 126 +++++++++++++++++- 4 files changed, 225 insertions(+), 43 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 40ca23f4a..13fec9533 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1251,7 +1251,7 @@ compile has no correct partial output, so every finding is an error | `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 | 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. | -| `AB4841` | 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 statically a function, 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. | +| `AB4841` | 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 `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. | | `AB4842` | 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. | diff --git a/packages/agent-bundle/src/routes/cli-projection.ts b/packages/agent-bundle/src/routes/cli-projection.ts index e64577071..0e7122d78 100644 --- a/packages/agent-bundle/src/routes/cli-projection.ts +++ b/packages/agent-bundle/src/routes/cli-projection.ts @@ -1,5 +1,5 @@ import { extractRouteConfig, routeConfigGrammar } from './config-extract.ts'; -import { scanRouteModuleExports } from './contract.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'; @@ -63,7 +63,7 @@ export interface ExtractedCliProjection { readonly config: CliProjectionConfigRecord; /** AB4841 (module contract) and AB4842 (grammar binding) in that order. */ readonly diagnostics: readonly Diagnostic[]; - /** True when the module exports `mapInput` as a synchronous function. */ + /** True when the module exports `mapInput` as a synchronous, non-generator function with a runtime binding. */ readonly mapInput: boolean; } @@ -85,7 +85,7 @@ const projectionSubject = (module: string, toolId: string): string => `CLI proje 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 arrow or function expression (`export const mapInput = (input) => ({ ... })`), or remove the export; 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.'; /** AB4841: the projection module's own contract — `config` shape and `mapInput` — is not met. */ @@ -355,12 +355,46 @@ export const relaxationWithoutMapInputDetail = (key: string, flag: CliProjection 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 AB4841 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 synchronous `mapInput` function - * (`scanRouteModuleExports`). The module is parsed, never executed. Every + * and whether it exports a `mapInput` the shell can call (`judgeMapInput` + * over `scanRouteModuleExports`). The module is parsed, never executed. Every * failure is `AB4841` (the module's own contract) or `AB4842` (binding to * the tool's argv grammar), addressed as * `CLI projection for tool:/: .` on the @@ -382,19 +416,9 @@ export const extractCliProjection = ( }; const diagnostics: Diagnostic[] = []; const exports = scanRouteModuleExports(moduleText, relativePath, { source: sourcePath }); - let mapInput = false; - if (exports.named.has('mapInput')) { - if (exports.namedAsyncFunctions.has('mapInput')) { - diagnostics.push(report.contract( - 'exports an async mapInput, but the shell applies mapInput synchronously before the canonical inputSchema validates', - mapInputRecovery, - )); - } else if (!exports.namedFunctions.has('mapInput')) { - diagnostics.push(report.contract('exports mapInput, which is not statically a function', mapInputRecovery)); - } else { - mapInput = true; - } - } + 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)); diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 598fa65a2..bf0c94371 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; } @@ -87,34 +105,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 = ( @@ -122,13 +141,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(); @@ -148,10 +173,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); @@ -161,9 +188,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) { 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; @@ -219,20 +249,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)); 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) { @@ -241,12 +271,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 { @@ -254,10 +286,12 @@ const scanModuleExports = ( defaultFunction, ...(resolvedDefaultReExport === undefined ? {} : { defaultReExport: Object.freeze(resolvedDefaultReExport) }), named, + namedAmbient, namedAsyncFunctions, namedFunctions, + namedGeneratorFunctions, + namedUnresolved, splitExport, - unresolvedNamed, }; }; @@ -270,7 +304,7 @@ const followReExport = ( specifier: string, options: ScanRouteModuleOptions, visited: ReadonlySet, -): ScannedModuleExports | undefined => { +): RouteModuleExports | undefined => { if (options.source === undefined || !isRelativeSpecifier(specifier)) return undefined; const read = options.readModule ?? readModuleFromDisk; const seen = new Set([...visited, options.source]); diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index c996a42e5..79984047f 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -399,7 +399,6 @@ describe('MCP tool CLI surface projections', () => { 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('{}', 'export const mapInput = pipe(identity);'), toolModule(), ['mapInput', 'function']], [ cliModule("{ flags: { laneKey: { required: false } } }"), toolModule(), @@ -417,6 +416,131 @@ describe('MCP tool CLI surface projections', () => { } }); + describe('mapInput must be a synchronous, non-generator function with a runtime binding', () => { + const subject = `CLI projection ${projectionPath} for tool:demo/submit: mapInput`; + + /** One rejected form: exactly one AB4841 naming the form, and no command compiled. */ + const expectRejectedMapInput = async (mapInput: string, fragments: readonly string[]): Promise => { + const { graph, root } = await compileProjection(cliModule('{}', mapInput)); + expectOnlyDiagnostic(graph, 'AB4841', 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 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, 'AB4841', 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 AB4842 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()', From 92346d8ddd5e8891cee37c4f6a8f4bf8448762d9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:46:55 +0000 Subject: [PATCH 15/19] chore(596): deslop CLI surface projection branch - resolvePolicy computes the fallback label once instead of repeating the coalesce inside overrideError - parseCliCommandInput mutates its own copy like the generated bin it mirrors, instead of re-cloning for the confirmation strip and defaults - the generated projection loaders trust the compiled manifest (one projection command per tool) like every sibling loader table; the dedupe pass is gone - projectionCell inlines its only empty-cell text Co-authored-by: Zack Jackson --- packages/agent-bundle/src/routes/cli-argv.ts | 24 ++++++++++--------- .../agent-bundle/src/rstest/setup-module.ts | 2 -- packages/agent-bundle/src/test/render.ts | 12 ++++------ packages/workbench/src/routes/routes-page.tsx | 6 ++--- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index bb82129d7..d474b1d6f 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -162,17 +162,19 @@ interface ResolvedCliOptionPolicy { readonly sourcePath: string; } -const resolvePolicy = (policy: CliOptionPolicy, relativePath: string, sourcePath: string): ResolvedCliOptionPolicy => ({ - label: policy.label ?? defaultLabel(relativePath), - // 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(`${policy.label ?? defaultLabel(relativePath)} ${detail}.`, sourcePath)), - overrides: policy.overrides ?? {}, - reserved: new Set([...reservedCliOptionNames, ...(policy.reserved ?? [])]), - reservedKeys: policy.reservedKeys ?? {}, - sourcePath, -}); +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; diff --git a/packages/agent-bundle/src/rstest/setup-module.ts b/packages/agent-bundle/src/rstest/setup-module.ts index 3b3882d1f..b1ec56138 100644 --- a/packages/agent-bundle/src/rstest/setup-module.ts +++ b/packages/agent-bundle/src/rstest/setup-module.ts @@ -47,8 +47,6 @@ export const routeTestSetupSource = (manifest: AgentBundleTestManifest): string routeId: command.routeId, source: resolve(manifest.projectRoot, command.projection!.module), })) - .filter((projection, index, projections) => - projections.findIndex((candidate) => candidate.routeId === projection.routeId) === index) .sort((left, right) => left.routeId.localeCompare(right.routeId)) .map((projection) => ` ${JSON.stringify(projection.routeId)}: () => import(${JSON.stringify(specifier(projection.source))}),`); diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 09160bd8b..ee442d5b8 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -1095,21 +1095,17 @@ export const parseCliCommandInput = ( projectionModule: Readonly> | undefined, input: Readonly>, ): unknown => { - let mapped: Readonly> = { ...input }; + let mapped: Record = { ...input }; if (command.projection !== undefined && command.mcp?.confirm === true) { if (mapped['yes'] !== true) { throw new CliUsageError(confirmationRequiredMessage(command.mcp.server, command.mcp.tool)); } - const withoutConfirmation = { ...mapped }; - delete withoutConfirmation['yes']; - mapped = withoutConfirmation; + delete mapped['yes']; } if (command.projection?.defaults !== undefined) { - const withDefaults: Record = { ...mapped }; for (const [key, value] of Object.entries(command.projection.defaults)) { - if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; + if (!Object.hasOwn(mapped, key)) mapped[key] = value; } - mapped = withDefaults; } if (command.projection?.mapInput === true) { const mapInput = projectionModule?.['mapInput']; @@ -1117,7 +1113,7 @@ export const parseCliCommandInput = ( throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); } try { - mapped = mapInput(mapped) as Readonly>; + mapped = mapInput(mapped) as Record; } catch (error) { throw new CliInputError(error instanceof Error ? error.message : String(error)); } diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index b8aeaaeff..189d8b254 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -300,8 +300,8 @@ const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: { ; }; -const projectionCell = (option: RouteManifestCliCommand['options'][number], empty: string): string => - option.aliases === undefined || option.aliases.length === 0 ? empty : option.aliases.join(', '); +const projectionCell = (option: RouteManifestCliCommand['options'][number]): string => + option.aliases === undefined || option.aliases.length === 0 ? '—' : option.aliases.join(', '); const CliCommandSurface = ({ command }: { readonly command: RouteManifestCliCommand }) => { const projection = command.projection; @@ -323,7 +323,7 @@ const CliCommandSurface = ({ command }: { readonly command: RouteManifestCliComm {command.options.map((option) => {option.key} {option.option} - {projectionCell(option, '—')} + {projectionCell(option)} {option.positional === undefined ? '—' : String(option.positional)} )} From cba686084449a57b2c12af53b88f9c32a2d067a0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:51:29 +0000 Subject: [PATCH 16/19] chore(596): deslop (#616) --- .../route-harness/src/lib/submit-helpers.ts | 1 - .../src/mcp/harness/tools/submit.cli.tsx | 16 +----- .../src/mcp/harness/tools/submit.tsx | 9 --- .../agent-bundle/src/build/entry-shell.ts | 9 +-- packages/agent-bundle/src/cli-entry.ts | 2 +- packages/agent-bundle/src/routes/cli-argv.ts | 57 ++++++++++--------- .../agent-bundle/src/routes/cli-commands.ts | 38 +++++-------- .../agent-bundle/src/routes/cli-projection.ts | 39 ++++++------- packages/agent-bundle/src/test/cli.ts | 2 +- packages/agent-bundle/src/test/render.ts | 12 ++-- .../agent-bundle/tests/cli-projection.test.ts | 5 -- .../tests/cli-routes-build.test.ts | 19 ------- .../cli-dispatch-projection.test.ts | 10 ---- 13 files changed, 76 insertions(+), 143 deletions(-) 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 index 62ea3e59f..f69eb737d 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts +++ b/packages/agent-bundle/fixtures/route-harness/src/lib/submit-helpers.ts @@ -1,2 +1 @@ -/** Preserves first occurrence order while dropping duplicate fixture values. */ export const dedupe = (values: readonly Value[]): readonly Value[] => [...new Set(values)]; 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 index 682cb6e02..e546dcd09 100644 --- 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 @@ -4,15 +4,6 @@ import type { z } from 'zod'; import { dedupe } from '../../../lib/submit-helpers.js'; import type { inputSchema } from './submit.js'; -/** - * The CLI surface projection of `tool:harness/submit` (#596): never a route. - * The tool stays the operation (`routeId`, `operationId`); this module only - * spells its canonical input as an idiomatic command — `laneKey` as `--lane`, - * `tags` as a repeatable `--tag`, `argv` as the trailing positionals (so - * `-- cargo check -p foo` passes flags through), and `cwd` relaxed on the CLI - * because `mapInput` derives it from the process. `confirm: false` overrides - * the `readOnlyHint: false` default, so the command runs without `--yes`. - */ export const config = { command: ['submit'], confirm: false, @@ -24,14 +15,9 @@ export const config = { positionals: ['argv'], } satisfies CliProjectionConfig; -/** The parsed argv: canonical keys, with `cwd` optional because the projection relaxed it. */ type CliInput = Omit, 'cwd'> & { readonly cwd?: string }; -/** - * Applied by the CLI shell before the canonical `inputSchema`, synchronously. - * A tag starting with `!` is the fixture's trigger for a thrown mapping error, - * which the shell reports as an input failure (exit 2). - */ +// 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('!')); 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 index d9957edbe..e2b4b1375 100644 --- 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 @@ -1,15 +1,6 @@ import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -/** - * The operation behind the harness's explicit CLI surface projection (#596): - * `submit.cli.ts` beside this module projects it onto ` submit`. The - * structured result echoes the canonical input so the projection levels can - * prove the CLI grammar and the MCP surface reach the same operation with the - * same value; the rendered text carries the surface the route observed - * (`invocation.kind`, `operationId`, `surface`) and the `library-tooling` - * provider's view of it, which differ per surface by design. - */ export const config = { annotations: { readOnlyHint: false }, description: 'Submits one command line as lane work and echoes the accepted request.', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 4e564f766..3848495f9 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -365,15 +365,16 @@ const renderedSessionSource = (workerFile: string): readonly string[] => [ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions): string => { const commandRoutes = options.routes.filter((route) => options.commands.some((command) => command.routeId === route.id)); - const projectedCommands = options.commands.filter((command) => command.projection !== undefined); - const projectionSources = projectedCommands.map((command) => { + 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(command.projection!.module)} for ${command.routeId} requires an absolute source path.`); + 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 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.'); diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index eec34846f..d2be033f1 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -273,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); diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index bb82129d7..a86b744e3 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -145,12 +145,13 @@ const optionNameOf = (key: string): string => key const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; -interface CliPropertyProjection { - readonly diagnostic?: Diagnostic; - readonly option?: CompiledCliOption; - /** True when a projection override made a canonical-required key optional on argv. */ - readonly relaxed?: boolean; -} +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 { @@ -177,7 +178,6 @@ const resolvePolicy = (policy: CliOptionPolicy, relativePath: string, sourcePath const describeKind = (base: ScalarBase): string => base.kind === 'enum' ? `one of ${(base.choices ?? []).map((choice) => JSON.stringify(choice)).join(', ')}` : base.kind; -/** True when `value` is one value of the property's scalar base. */ const matchesKind = (base: ScalarBase, value: unknown): boolean => { switch (base.kind) { case 'boolean': @@ -305,13 +305,11 @@ export interface ProjectedCliOptions { readonly relaxed?: readonly string[]; } -/** Who claimed one `--spelling`: the key, and whether the projection's override spelled it. */ 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[], policy: ResolvedCliOptionPolicy, @@ -327,44 +325,48 @@ const projectOptions = ( continue; } const projected = cliOptionFor(entry.property, policy); - if (projected.diagnostic !== undefined) { + if ('diagnostic' in projected) { diagnostics.push(projected.diagnostic); continue; } if (projected.relaxed === true) relaxed.push(entry.property.key); - const option = projected.option!; + const option = projected.option; const override = policy.overrides[option.key] ?? {}; if (override.default !== undefined) defaults[option.key] = override.default; - const spellings: readonly SpellingClaim[] = [ - { key: option.key, overridden: override.name !== undefined }, - ...(option.aliases ?? []).map(() => ({ key: option.key, overridden: true })), - ]; - const collision = [option.option, ...(option.aliases ?? [])] - .map((spelling, index) => ({ claimed: seenSpellings.get(spelling), claim: spellings[index]!, spelling })) - .find((candidate) => candidate.claimed !== undefined); + 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 + 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`, + `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.label} properties ${JSON.stringify(claimed.key)} and ${JSON.stringify(claim.key)} both project onto --${spelling}.`, policy.sourcePath, )); continue; } - for (const [index, spelling] of [option.option, ...(option.aliases ?? [])].entries()) { - seenSpellings.set(spelling, spellings[index]!); + 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 defaultKeys = Object.keys(defaults).sort((left, right) => left.localeCompare(right)); + const sortedDefaults = Object.fromEntries( + Object.entries(defaults).sort(([left], [right]) => left.localeCompare(right)), + ); return { - ...(defaultKeys.length === 0 - ? {} - : { defaults: Object.fromEntries(defaultKeys.map((key) => [key, defaults[key]!])) }), + ...(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)) }), @@ -376,7 +378,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, diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index 6391db9b5..cdaf0c08a 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -540,21 +540,21 @@ export const compileProjectedCliCommands = ( const keys = new Set(options.map((option) => option.key)); const keyRecovery = inputKeysRecovery([...keys]); let bound = true; - for (const key of Object.keys(config.flags ?? {})) { - if (keys.has(key)) continue; - diagnostics.push(binding(unknownInputKeyDetail('flags', key), keyRecovery)); - bound = false; - } - for (const key of argv.relaxed ?? []) { - if (extracted.mapInput) continue; - diagnostics.push(cliProjectionContractError( - relativePath, - tool.id, - relaxationWithoutMapInputDetail(key, config.flags![key]!), - source, - relaxationRecovery(key), - )); - bound = false; + 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; } @@ -574,8 +574,6 @@ export const compileProjectedCliCommands = ( // reported once, there); the projected command inherits the value. const render = routeRenderLimits(tool.config); routes.push(tool); - // Every key here has a compiled command whose `projection.module` is the - // relative twin of this absolute path; a pair that failed lists nothing. projectionSources[tool.id] = source; commands.push({ aliases, @@ -672,11 +670,6 @@ export const compileCliCommands = async ( }); } - /** - * One claimed command path and the module that claims it: a custom route - * (no `provenance`), a bulk-projected tool (its tool route), or a - * projection module (the module itself, whose `command` chose the path). - */ interface PathClaim { readonly path: readonly string[]; readonly provenance?: McpPathProvenance; @@ -717,7 +710,6 @@ export const compileCliCommands = async ( }); } const claims = [...claimByRouteId.values()]; - /** The projected side of a collision (the later claim when both are), and the file the fix belongs in. */ const sides = (claim: PathClaim, existing: PathClaim): { readonly mcp: PathClaim; readonly other: PathClaim; diff --git a/packages/agent-bundle/src/routes/cli-projection.ts b/packages/agent-bundle/src/routes/cli-projection.ts index 0e7122d78..8e6474f23 100644 --- a/packages/agent-bundle/src/routes/cli-projection.ts +++ b/packages/agent-bundle/src/routes/cli-projection.ts @@ -35,8 +35,9 @@ const misplacedModulePath = /^src\/mcp\/[^/]+\/(?:resources|prompts|apps)\/[^/]+ 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']!; + const server = match.groups['server']; + const stem = match.groups['stem']; + if (server === undefined || stem === undefined) return undefined; return { server, siblingId: `tool:${server}/${stem}`, stem }; }; @@ -72,15 +73,12 @@ export interface CliProjectionExtractionOptions { readonly projectRoot?: string; } -/** Every key a projection `config` may declare. */ const projectionConfigKeys: readonly string[] = ['aliases', 'command', 'confirm', 'description', 'exitCode', 'flags', 'positionals']; -/** Every key one `flags.` entry may declare. */ const flagConfigKeys: readonly string[] = ['aliases', 'default', 'description', 'name', 'required']; const emptyProjectionConfig: CliProjectionConfigRecord = deepFreeze({}); -/** How the diagnostics address one projection module. */ 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.'; @@ -165,46 +163,46 @@ export const stringArray = (value: unknown): readonly string[] | undefined => : undefined; const isFlagDefault = (value: unknown): value is CliProjectionFlagDefault => { - const scalar = (entry: unknown): boolean => + 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)); }; -/** The reason an `extractRouteConfig` diagnostic gives, without the `Route module ` subject the projection replaces. */ 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; }; -interface FlagValidation { - readonly detail?: string; - readonly flag?: CliProjectionFlagConfig; -} +type FlagValidation = + | { readonly detail: string } + | { readonly flag: CliProjectionFlagConfig }; -/** Validates one `flags.` entry's shape; the detail names the offending field. */ 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` }; - if (value.default !== undefined && !isFlagDefault(value.default)) { + 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` }; } - if (value.description !== undefined && typeof value.description !== 'string') { + const description = value.description; + if (description !== undefined && typeof description !== 'string') { return { detail: `config.flags.${key}.description must be a string` }; } - if (value.name !== undefined && typeof value.name !== 'string') return { detail: `config.flags.${key}.name 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 }), - ...(value.default === undefined ? {} : { default: value.default as CliProjectionFlagDefault }), - ...(value.description === undefined ? {} : { description: value.description as string }), - ...(value.name === undefined ? {} : { name: value.name as string }), + ...(defaultValue === undefined ? {} : { default: defaultValue }), + ...(description === undefined ? {} : { description }), + ...(name === undefined ? {} : { name }), ...(value.required === undefined ? {} : { required: false as const }), }, }; @@ -215,7 +213,6 @@ interface ConfigValidation { readonly details: readonly string[]; } -/** Validates the extracted `config` against the closed key set and field shapes; every detail is one AB4841. */ const validateProjectionConfig = (raw: Readonly>): ConfigValidation => { const details: string[] = []; for (const key of Object.keys(raw)) { @@ -242,8 +239,8 @@ const validateProjectionConfig = (raw: Readonly>): Confi } else if (declaredFlags !== undefined) { for (const [key, value] of Object.entries(declaredFlags)) { const validated = validateFlag(key, value); - if (validated.detail !== undefined) details.push(validated.detail); - else flags[key] = validated.flag!; + if ('detail' in validated) details.push(validated.detail); + else flags[key] = validated.flag; } } const positionals = raw['positionals'] === undefined ? undefined : stringArray(raw['positionals']); diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index 81d78215a..0ef961a07 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -252,7 +252,7 @@ export const invokeCli = async ( } const parsed = parseCliCommandInput( command, - module, + module.inputSchema, await loadCliProjectionModule(manifest, command), input, ); diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 70de3aef6..1378b64cb 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -1089,13 +1089,13 @@ export const loadCliProjectionModule = async ( /** Mirrors the generated bin's confirmation, explicit defaults, mapping, and canonical validation boundary. */ export const parseCliCommandInput = ( command: CompiledCliCommand, - module: AgentRouteModule, + inputSchema: AgentRouteSchema, projectionModule: Readonly> | undefined, input: Readonly>, ): unknown => { - let mapped: Readonly> = { ...input }; + let mapped: unknown = { ...input }; if (command.projection?.defaults !== undefined) { - const withDefaults: Record = { ...mapped }; + const withDefaults: Record = { ...input }; for (const [key, value] of Object.entries(command.projection.defaults)) { if (!Object.hasOwn(withDefaults, key)) withDefaults[key] = value; } @@ -1107,13 +1107,13 @@ export const parseCliCommandInput = ( throw new TypeError(`CLI projection ${command.projection.module} for ${command.routeId} must export a mapInput function.`); } try { - mapped = mapInput(mapped) as Readonly>; + mapped = mapInput(mapped); } catch (error) { throw new CliInputError(error instanceof Error ? error.message : String(error)); } } try { - return module.inputSchema!.parse(mapped); + return inputSchema.parse(mapped); } catch (error) { throw cliInputError(command, mapped, error); } @@ -1175,7 +1175,7 @@ export const prepareCliRenderHost = async ( } const parsed = parseCliCommandInput( command, - module, + module.inputSchema, projectionModule, input, ); diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index 79984047f..3412a4d50 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -233,8 +233,6 @@ describe('MCP tool CLI surface projections', () => { ], path: ['req'], projection: { - // Only the projection's own default is the shell's to apply; the - // schema-defaulted `verbose` shows its default in help and is absent. defaults: { limit: 20 }, mapInput: true, module: projectionPath, @@ -265,7 +263,6 @@ describe('MCP tool CLI surface projections', () => { module: projectionPath, }); expect(Object.keys(command.projection!.defaults!)).toEqual(['mode', 'tags']); - // Help shows the projection's default over the schema's. expect(command.options.find((option) => option.key === 'mode')).toMatchObject({ defaultValue: 'full', required: false }); expect(command.options.find((option) => option.key === 'retries')).not.toHaveProperty('defaultValue'); @@ -419,7 +416,6 @@ describe('MCP tool CLI surface projections', () => { describe('mapInput must be a synchronous, non-generator function with a runtime binding', () => { const subject = `CLI projection ${projectionPath} for tool:demo/submit: mapInput`; - /** One rejected form: exactly one AB4841 naming the form, and no command compiled. */ const expectRejectedMapInput = async (mapInput: string, fragments: readonly string[]): Promise => { const { graph, root } = await compileProjection(cliModule('{}', mapInput)); expectOnlyDiagnostic(graph, 'AB4841', root, [subject, ...fragments]); @@ -586,7 +582,6 @@ describe('MCP tool CLI surface projections', () => { expect(result.graph.cli?.commands).toEqual([]); } - // Without confirmation the key is an ordinary option. 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']); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index a54061b63..1b5754a29 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -514,8 +514,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () type: 'module', version: '1.0.0', })), - // `mcpCommands: true` alongside the projection: the bulk projection must - // skip the projected tool and still cover its neighbour. writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", 'export default defineConfig({', @@ -556,8 +554,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () '};', '', ].join('\n')), - // The operation: canonical input echoed as the structured result, the - // observed surface in the rendered text only. writeProjectFile(root, 'src/mcp/demo/tools/submit.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { z } from 'zod';", @@ -587,10 +583,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () '}', '', ].join('\n')), - // The projection: never a route. `laneKey` as `--lane`, `tags` as a - // repeatable `--tag`, `argv` trailing (so `-- cargo check -p foo` passes - // through), `cwd` relaxed because `mapInput` derives it, and no `--yes` - // although the tool is not read-only. writeProjectFile(root, projectionModule, [ 'export const config = {', " command: ['submit'],", @@ -623,7 +615,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () { name: 'cli-projection-fixture', provenance: { kind: 'conventional' } }, ]); await expect(stat(binPath)).resolves.toMatchObject({}); - // The bin's provenance names the projection module beside the route modules it projects. 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', @@ -632,9 +623,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () projectionModule, 'src/mcp/demo/tools/submit.tsx', ])); - // The executable's compiled surface: the projected command at the root - // beside the bulk-projected neighbour, and the tool as a route exactly - // once — the projection module is not a route. 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']); @@ -654,7 +642,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () expect(help.stdout).not.toContain('requires --yes'); expect(help.stdout).not.toContain('--input'); expect(help.stdout).not.toContain('(required)'); - // The tree lists the projected command at the root, beside the bulk-projected group. 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); @@ -664,15 +651,12 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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'] }); - // Flags after `--` are the command line's, not the shell's; no `--yes` despite readOnlyHint: false. 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' }); - // Piped text output carries the surface the tool observed: the CLI, with the tool as the operation. 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'); - // The bulk projection still serves the neighbouring tool under the server path. const ping = await execFile(binPath, ['demo', 'ping', '--json'], { cwd: root }); expect(JSON.parse(ping.stdout)).toEqual({ pong: true }); }); @@ -699,8 +683,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () ].join('\n'), stdout: '', }); - // The canonical schema judges the MAPPED input, and the issue is spelled - // with the CLI option the operator typed. await expect(execFile(binPath, ['submit', '--cwd', '', '--', 'cargo', 'check'], { cwd: root })).rejects.toMatchObject({ code: 2, stderr: [ @@ -745,7 +727,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () routeId: 'tool:demo/submit', }); expect(commands.find((command) => command.routeId === 'tool:demo/ping')).not.toHaveProperty('projection'); - // The projection module is not a route. expect(document.selected?.routes?.servers.flatMap((server) => server.routes.map((route) => route.id))).toEqual([ 'tool:demo/ping', 'tool:demo/purge', diff --git a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts index fc76215f2..0cd516518 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch-projection.test.ts @@ -17,7 +17,6 @@ import { testManifest } from '../../src/test/registry.ts'; const cwd = process.cwd(); const usage = 'Usage: route-harness submit [options] '; const helpHint = "Run 'route-harness submit --help' for usage."; -/** The `library-tooling` fixture provider's report, keyed by the surface it observed. */ const providerLine = (kind: 'cli' | 'tool', surface: string): string => `provider: ${JSON.stringify({ kind, surface, tool: 'ffprobe 6.1' })}`; @@ -45,11 +44,9 @@ describe('the CLI surface projection of tool:harness/submit', () => { expect(command).toEqual({ aliases: [], - // No `description` in the projection: the tool's config.description serves. description: 'Submits one command line as lane work and echoes the accepted request.', exitCode: 'zero', mcp: { confirm: false, server: 'harness', tool: 'submit' }, - // `options` is the mapping: canonical `key` ↔ CLI `option`, sorted by spelling. 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 }), @@ -61,10 +58,7 @@ describe('the CLI surface projection of tool:harness/submit', () => { rendered: true, routeId: 'tool:harness/submit', }); - // One command per operation: the bulk `mcpCommands: true` projection - // skips a tool that carries its own projection module. expect(manifest.cliCommands.map((candidate) => candidate.path.join(' '))).not.toContain('harness submit'); - // The projection module is never a route. expect(Object.keys(manifest.routes).filter((id) => id.includes('submit'))).toEqual(['tool:harness/submit']); }); @@ -81,8 +75,6 @@ describe('the CLI surface projection of tool:harness/submit', () => { 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); - // The MCP surface ran the same route as a tool; only the rendered surface - // wording differs, never the value. expect(mcp.content).toEqual([ { text: 'submit: cargo check', type: 'text' }, { text: 'invocation: tool tool:harness/submit submit', type: 'text' }, @@ -184,8 +176,6 @@ describe('the CLI surface projection of tool:harness/submit', () => { }); it('runs without --yes because the projection sets confirm: false, and knows no --yes option', async () => { - // The tool's `readOnlyHint: false` would make the bulk projection fail - // closed; the projection's explicit `confirm: false` wins. const run = await invokeCli(['submit', '--', 'cargo', 'check']); expect(run.exitCode).toBe(0); expect(run.stderr).toBe(''); From 56a93f98ca8a06a9afb7a8e3736c606b83b1fc0f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 09:02:53 +0000 Subject: [PATCH 17/19] test(layout-build): bulk-projected tool observes invocation.kind cli from the bin (#616) --- packages/agent-bundle/tests/layout-build.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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, From 8f035fe45727f34bfaf80bd724bfd4b2f27bccda Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 09:32:00 +0000 Subject: [PATCH 18/19] fix(cli): strip --yes only on confirming projections; AB4841 for bodyless mapInput overloads; one render branch (#616) - parseMcpCommandInput leaves a non-confirming projection's canonical yes key untouched (the compiler reserves yes only when confirm is true) - scanRouteModuleExports counts a function declaration as a runtime binding only when it has a body, so a lone overload signature is AB4841 - the bin template's three identical openRenderedSession branches are one - tests: non-confirming yes passthrough, bodyless overload, AB4804 with routes.cli conventional beside a projection module - docs: bin/ trigger in project-structure names every routed-CLI source --- docs/diagnostics.md | 2 +- .../agent-bundle/src/build/entry-shell.ts | 24 ------------ packages/agent-bundle/src/cli-entry.ts | 1 + packages/agent-bundle/src/routes/contract.ts | 2 +- .../agent-bundle/tests/cli-projection.test.ts | 20 ++++++++++ .../tests/projection/cli-dispatch.test.ts | 38 +++++++++++++++++++ .../docs/en/guide/start/project-structure.mdx | 4 +- .../docs/zh/guide/start/project-structure.mdx | 4 +- 8 files changed, 65 insertions(+), 30 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 13fec9533..cad3b8269 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1251,7 +1251,7 @@ compile has no correct partial output, so every finding is an error | `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 | 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. | -| `AB4841` | 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 `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. | +| `AB4841` | 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. | | `AB4842` | 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. | diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 3848495f9..764d3c029 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -482,30 +482,6 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) 'const render = (command, input, context) => {', ' const route = routes[command.routeId];', ' const parsed = parseInput(command, route, input);', - ' if (command.projection !== undefined) {', - ' return openRenderedSession({', - " invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },", - ' limits: command.render,', - ' props: { input: parsed },', - " request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", - ' routeId: command.routeId,', - ' signal: context.signal,', - ' terminal: context.terminal,', - ' validate: (value) => route.module.resultSchema.parse(value),', - ' });', - ' }', - ' if (command.mcp !== undefined) {', - ' return openRenderedSession({', - " invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },", - ' limits: command.render,', - ' props: { input: parsed },', - " request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", - ' 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/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index d2be033f1..b8cb65dd2 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -660,6 +660,7 @@ const parseMcpCommandInput = ( 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 }; diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index bf0c94371..2326faa05 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -190,7 +190,7 @@ const scanModuleExports = ( if (ts.isFunctionDeclaration(statement)) { if (statement.name !== undefined && ambient(statement)) { ambientBindings.add(statement.name.text); - } else if (statement.name !== undefined) { + } 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); diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index 3412a4d50..e3f57881f 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -487,6 +487,13 @@ describe('MCP tool CLI surface projections', () => { ); }); + 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';", @@ -750,6 +757,19 @@ describe('MCP tool CLI surface projections', () => { 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' } } }), diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 55e3df9ab..82d46c94b 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -57,6 +57,44 @@ describe('the CLI dispatch level', () => { 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('accepts projected option aliases, prints projection help, and spells schema failures as projected flags', async () => { const command: CompiledCliCommand = { aliases: [], diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index a80915be2..fb1a8fce1 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -126,7 +126,7 @@ artifact/ │ ├── ..mjs # one wrapper per host for a hook several reach │ └── hooks-flight.mjs ├── mcp/mcp--.mjs # compiled MCP entries, emitted once -├── bin/.mjs # the routed CLI, when src/cli/** exists +├── bin/.mjs # the routed CLI, when the project has one ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # components, emitted once ├── INSTALL.md # one section per selected host ├── install.mjs # when cursor or portable is selected @@ -138,7 +138,7 @@ Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/` `assets/` are emitted once and shared. Hook and MCP documents appear when the project declares hooks or MCP servers — plus one empty Codex or Cursor document whenever another selected host claims the conventional `hooks/hooks.json`, `.mcp.json`, or `mcp.json` path, so that host's folder discovery -never loads the other host's file — and `bin/` only when it has a routed CLI. Two selected hosts that +never loads the other host's file — and `bin/` only when it has a routed CLI (`src/cli/**` routes, bulk `routes.mcpCommands`, or a projection module). Two selected hosts that would write the same path with different bytes cannot share the root, and the build fails with `AB4103`; a command or rule scoped to some of the selected hosts but sitting in a directory another selected host scans is `AB4105`. Both recover by making the component diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 0b51a7149..61374fe00 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -121,7 +121,7 @@ artifact/ │ ├── ..mjs # 到达多个宿主的钩子,每个宿主一个包装脚本 │ └── hooks-flight.mjs ├── mcp/mcp--.mjs # 编译后的 MCP 入口,只输出一次 -├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 +├── bin/.mjs # 路由式 CLI,项目拥有路由式 CLI 时出现 ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 组件目录,只输出一次 ├── INSTALL.md # 每个所选宿主一节 ├── install.mjs # 选择了 cursor 或 portable 时出现 @@ -132,7 +132,7 @@ artifact/ 宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 所有宿主共用。钩子与 MCP 文档在项目声明了钩子或 MCP 服务器时出现——此外,只要另一个所选宿主占用了 约定路径 `hooks/hooks.json`、`.mcp.json` 或 `mcp.json`,Codex 或 Cursor 就会额外输出一份空文档,使该宿主的目录 -发现永远不会加载到其他宿主的文件——`bin/` 只在项目有路由式 CLI 时出现。两个所选宿主若要以不同字节写出同一路径,就无法共用根目录,构建会以 `AB4103` 失败;一个只面向 +发现永远不会加载到其他宿主的文件——`bin/` 只在项目有路由式 CLI(`src/cli/**` 路由、批量 `routes.mcpCommands` 或投影模块)时出现。两个所选宿主若要以不同字节写出同一路径,就无法共用根目录,构建会以 `AB4103` 失败;一个只面向 部分所选宿主的命令或规则,却位于另一个所选宿主会扫描的目录中,则是 `AB4105`。两者的恢复方式 相同:让该组件对每个所选宿主都完全一致,或把这些宿主分别构建到不同的产物中。 From 0901ae92d503517ae7cdb0992d3daabc5378b15b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:07:32 +0000 Subject: [PATCH 19/19] test(cli): regression coverage for application-owned yes on non-confirming projections (#616) - verified on tip: parseMcpCommandInput already strips yes only when command.mcp.confirm is true, and AB4842 reserves the canonical yes key on confirming projections, so stripping is exactly framework-owned - built executable: the projection fixture's submit tool declares an optional yes: z.boolean() with confirm: false; --yes reaches the tool through the canonical schema (and its absence stays absent) - shell: boolean --yes passes through untouched on a non-confirming projection; a renamed flag keeps its canonical yes key and --yes stays unknown when nothing confirms - compiler: a non-confirming projection may respell its yes key (flags: { yes: { name: 'assume' } }) with the canonical key intact Co-authored-by: Zack Jackson --- .../agent-bundle/tests/cli-projection.test.ts | 10 +++ .../tests/cli-routes-build.test.ts | 22 +++-- .../tests/projection/cli-dispatch.test.ts | 84 +++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/tests/cli-projection.test.ts b/packages/agent-bundle/tests/cli-projection.test.ts index e3f57881f..288113147 100644 --- a/packages/agent-bundle/tests/cli-projection.test.ts +++ b/packages/agent-bundle/tests/cli-projection.test.ts @@ -594,6 +594,16 @@ describe('MCP tool CLI surface projections', () => { 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 AB4842 while description, default, and required stay legal', async () => { diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 1b5754a29..9818fe264 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -558,11 +558,15 @@ describe('the CLI surface projection in the generated routed-CLI executable', () "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),', @@ -570,6 +574,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () ' 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();', @@ -673,6 +678,17 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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, @@ -698,11 +714,6 @@ describe('the CLI surface projection in the generated routed-CLI executable', () stderr: expect.stringContaining('Missing required argument: .'), stdout: '', }); - await expect(execFile(binPath, ['submit', '--yes', '--', 'cargo', 'check'], { cwd: root })).rejects.toMatchObject({ - code: 2, - stderr: expect.stringContaining('Unknown option: --yes.'), - stdout: '', - }); }); it('shows the projection on the compiled command through inspect --routes', async () => { @@ -720,6 +731,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 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 }, diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 82d46c94b..090b79da3 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -95,6 +95,90 @@ describe('the CLI dispatch level', () => { 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: [],