diff --git a/.changeset/routed-cli-stage2.md b/.changeset/routed-cli-stage2.md new file mode 100644 index 000000000..eca78e612 --- /dev/null +++ b/.changeset/routed-cli-stage2.md @@ -0,0 +1,19 @@ +--- +"agent-bundle": minor +--- + +Compile `src/cli/**` routes into a routed CLI (#102 stage 2). Conventional +command routes now compile into one collision-checked command graph — +path nesting is identity (`src/cli/library/audit.ts` runs as +` library audit`), the static `config` export supplies description, +aliases, positionals, and the exit-code policy, and a bounded, documented +zod grammar projects each route's `inputSchema` onto argv (options, +positionals, arrays, defaults) with named `AB4814` diagnostics for +constructs outside it. The graph feeds the existing package-build pipeline +as one generated Rslib executable named after the plugin, superseding the +`src/cli.ts` bin convention for that project; commands run inside the typed +Agent request context, write one canonical JSON line to stdout, accept +`--json`, and map exit codes deterministically (0/1/2, 130/143 on signals). +Command-tree and alias collisions, contract violations, and rendered +(`.tsx`) command routes fail source validation with the new +`AB4813`–`AB4816` diagnostics instead of building silently. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 61625014e..a69ca3227 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -118,7 +118,7 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | -## Route graph (`AB4800`–`AB4812`) +## Route graph (`AB4800`–`AB4816`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -152,6 +152,41 @@ explicit `scripts` entries (#102 stage 1): a plain module directly under artifact with `provenance.kind: 'conventional'`. Script routes that pipeline cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions. +Conventional `src/cli/**` routes compile into one collision-checked command +graph (#102 stage 2): the file path below the CLI root is the command +nesting (`src/cli/library/audit.ts` runs as ` library audit`), the +static `config` export supplies `description`, `aliases`, `positionals`, and +the `exitCode` policy, and the graph feeds one framework-generated package +executable named after the plugin (`dist/bin/.js`), replacing +the `src/cli.ts` convention for that project. A plain command route exports +`inputSchema` and `resultSchema` zod schemas plus one async default function +receiving `{ input, signal }`; the command runs inside the typed Agent +request context, writes one canonical JSON line to stdout, and exits 0 (or +the validated result's integer `exitCode` under `config.exitCode: 'result'`), +1 on execution failure, 2 on usage or input-validation failure, 130/143 +after SIGINT/SIGTERM. `--help`, `--json`, and `--version` are owned by the +generated shell. + +The argv projection of `inputSchema` is extracted statically — the module is +parsed, never executed — from a bounded zod grammar: the top level is +`z.object({ ... })` or `z.strictObject({ ... })` (optionally `.strict()`); +each property chains from `z.string()`, `z.number()`, `z.boolean()`, +`z.enum([...string literals])`, or `z.array()`; +chains may add `.optional()`, `.default()`, and +`.describe('')`, plus validation-only refinements the +projection accepts without interpreting (strings: `min`/`max`/`length`/ +`regex`/`startsWith`/`endsWith`/`includes`; numbers: `int`/`min`/`max`/`gt`/ +`gte`/`lt`/`lte`/`positive`/`nonnegative`/`negative`/`nonpositive`/`finite`/ +`safe`/`multipleOf`/`step`; arrays: `min`/`max`/`length`/`nonempty`) because +the module's real zod schema still validates every input at run time. Keys +project onto kebab-case options (`maxFiles` becomes `--max-files`); booleans +are flags and must carry `.optional()` or `.default(...)`; +`config.positionals` names the keys consumed as bare arguments in order, +where only the trailing positional may be a `z.array(...)` (variadic). +Anything outside that grammar — identifier references (including shared +schema constants), unions, nested objects, transforms, coercions — raises +`AB4814` naming the offending construct. + | 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. | @@ -167,6 +202,10 @@ cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions. | `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. | | `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 (the message names the offending construct and position), a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. | +| `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` | error | A conventional `src/cli/**` route is a rendered-command module (`.tsx`/`.jsx`); rendered commands are not supported yet. Rename it to `.ts`, or prefix a path segment with `_` to keep it private. | ## Development package build (`AB7103`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index c12ab3be6..fe01f58c1 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -60,6 +60,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use. A `scripts` entry that references the file claims it. Rendered (`.tsx`) and nested modules are hard errors until later #102 stages (`AB4807`/`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/cli/**/*.ts` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Rendered (`.tsx`) command routes are hard errors until #102 stage 3 (`AB4816`). | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | Conventions match `.ts` and `.tsx` files exactly. @@ -106,6 +107,50 @@ top-level failure path (stack to stderr, exit code 1). Self-executing modules (no `main` export) bundle directly, byte for byte — existing Scripts keep their behavior. +### The routed CLI shell (#102 stage 2) + +A generated-mode `src/cli/**` surface compiles into one framework-generated +executable instead of a hand-written `src/cli.ts` dispatcher. A plain command +route is one module: + +```ts +// src/cli/inspect.ts — the whole command a consumer writes +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +export const config = { + description: 'Inspect a bounded source tree without changing it.', + positionals: ['root'], +} satisfies CliRouteConfig; +export const inputSchema = z.object({ + maxFiles: z.number().int().min(1).max(256).optional(), + root: z.string().min(1), +}).strict(); +export const resultSchema = z.object({ /* ... */ }).strict(); + +export default async function inspect({ input, signal }: CliRouteProps) { + // ... do the work ... + return result; +} +``` + +The compiler statically projects `inputSchema` onto argv (the bounded grammar +and every policy rule are documented in +[Diagnostics](diagnostics.md#route-graph-ab4800ab4816)), generates nested +help (`--help` at every level, `--version` at the root), and emits +`dist/bin/.js` with the shebang and executable bit through the +same Rslib synthesis as every other bin. At run time the shell resolves the +command path, parses and coerces argv, validates through the module's own +zod schemas, executes the default function inside the typed Agent request +context (`invocation.kind: 'cli'`), writes one canonical JSON line to +stdout, and maps exit codes deterministically (0 success or the result's +`exitCode` under `config.exitCode: 'result'`; 1 execution failure; 2 usage +or input failure; 130/143 on SIGINT/SIGTERM, which reach the route's +`AbortSignal`). `--json` is accepted on every command; plain commands +already emit the canonical JSON document. Routed CLI projects need +`@agent-bundle/runtime` as a dependency — the generated executable installs +the request context through it. + ### The stdio MCP lifecycle shell An MCP server entry that **default-exports a server factory** is served under diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 40337bce3..4335bb422 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -45,6 +45,10 @@ "types": "./dist/api.d.ts", "import": "./dist/api.js" }, + "./cli-entry": { + "types": "./dist/cli-entry.d.ts", + "import": "./dist/cli-entry.js" + }, "./config": { "types": "./dist/config/index.d.ts", "import": "./dist/config.js" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 800452803..87f3d5246 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ entry: { api: './src/api.ts', cli: './src/cli.ts', + 'cli-entry': './src/cli-entry.ts', config: './src/config/index.ts', eval: './src/eval/index.ts', index: './src/index.ts', diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index b37c89588..560680d23 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { stableJson } from '../core/digest.ts'; -import type { CompiledAgentRoute } from '../routes/types.ts'; +import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; /** * Generated-entry templates: the framework-provided entry files consumers @@ -73,6 +73,89 @@ export const generatedExecutableEntrySource = (options: { ].join('\n'); +export const cliEntryRuntimeSpecifier = 'agent-bundle/cli-entry'; + +/** + * The on-disk location of the `agent-bundle/cli-entry` runtime module, + * aliased into generated CLI executables exactly like the mcp-entry + * lifecycle so emitted bins stay self-contained. + */ +export const cliEntryRuntimePath = (): string => { + for (const candidate of [ + new URL('./cli-entry.js', import.meta.url), + new URL('../cli-entry.ts', import.meta.url), + ]) { + const path = fileURLToPath(candidate); + if (existsSync(path)) return path; + } + throw new Error('Unable to locate the agent-bundle/cli-entry runtime module for generated CLI executables.'); +}; + +export interface GeneratedCliBinEntryOptions { + readonly commands: readonly CompiledCliCommand[]; + readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; + readonly routes: readonly CompiledAgentRoute[]; +} + +/** + * The generated routed-CLI executable (#102 stage 2): the compiled command + * graph rides the bundle as data, the cli-entry shell owns argv parsing, + * help, exit codes, and signals, and every command executes inside the typed + * Agent request context. Input validation failures are usage failures + * (`CliInputError`, exit 2); the route module's zod schemas stay the + * runtime validation boundary. + */ +export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions): string => { + const commandRoutes = options.routes.filter((route) => + options.commands.some((command) => command.routeId === route.id)); + return [ + `import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...routeImports(commandRoutes), + '', + 'const routes = Object.freeze({', + ...commandRoutes.map((route, index) => + ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), + '});', + '', + `const commands = Object.freeze(${stableJson(options.commands)});`, + '', + 'const execute = async (command, input, context) => {', + ' const route = routes[command.routeId];', + " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');", + ' let parsed;', + ' try {', + ' parsed = route.module.inputSchema.parse(input);', + ' } catch (error) {', + ' throw new CliInputError(error instanceof Error ? error.message : String(error));', + ' }', + ' const cwd = process.cwd();', + ' const result = await runAgentRequest({', + ' capabilities: {', + ' command: unavailable(),', + ' filesystem: unavailable(),', + ' network: unavailable(),', + " projectRoot: available({ root: cwd }, 'derived'),", + ' },', + " host: unavailable('unsupported-surface'),", + " invocation: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", + ' signal: context.signal,', + " workspace: available({ root: cwd }, 'derived'),", + ' }, async () => route.module.default({ input: parsed, signal: context.signal }));', + ' return route.module.resultSchema.parse(result);', + '};', + '', + 'await runGeneratedCliProcess({', + ' commands,', + ...(options.plugin.description === undefined ? [] : [` description: ${JSON.stringify(options.plugin.description)},`]), + ' execute,', + ` name: ${JSON.stringify(options.plugin.name)},`, + ` version: ${JSON.stringify(options.plugin.version)},`, + '});', + '', + ].join('\n'); +}; + export interface GeneratedRouteMcpEntryOptions { readonly plugin: { readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 2cec424b1..c4a8ca014 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -6,7 +6,12 @@ import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts' import { assertInside } from '../core/paths.ts'; import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; -import { generatedExecutableEntrySource } from './entry-shell.ts'; +import { + cliEntryRuntimePath, + cliEntryRuntimeSpecifier, + generatedCliBinEntrySource, + generatedExecutableEntrySource, +} from './entry-shell.ts'; import { buildWithRslib, type RslibEntry } from './rslib.ts'; /** @@ -96,6 +101,33 @@ export const planPackageEntries = async ( if (packageBuild === undefined) return Object.freeze([]); const entries: PlannedPackageEntry[] = []; for (const bin of packageBuild.bins) { + if (bin.generatedCli !== undefined) { + // A routed-CLI bin compiles the framework-generated command program; + // the cli-entry runtime shell is aliased in so the emitted executable + // stays self-contained, exactly like generated stdio MCP entries. + entries.push({ + aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, + banner: binShebang, + executable: true, + name: `bin-${bin.name}`, + outputRelativePath: `bin/${bin.name}.js`, + source: bin.source, + sourceInputs: Object.freeze([...new Set([ + bin.provenance.sourcePath, + ...bin.generatedCli.routes.map((route) => route.source), + ])]), + virtualSource: generatedCliBinEntrySource({ + commands: bin.generatedCli.commands, + plugin: { + ...(model.metadata.description === undefined ? {} : { description: model.metadata.description }), + name: model.metadata.name, + version: model.metadata.version, + }, + routes: bin.generatedCli.routes, + }), + }); + continue; + } // A bin entry exporting `main` (or a default function) receives the // generated process envelope; a self-executing module bundles directly. const exports = await scanEntryExports(bin.source); @@ -160,9 +192,13 @@ export const buildPackageOutputs = async (options: { await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); try { + const cliRuntimeShell = entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined) + ? cliEntryRuntimePath() + : undefined; const evidence = await buildWithRslib({ cwd: projectRoot, entries, + ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [cliRuntimeShell] }), logLevel: 'error', outputRoot: stageRoot, ...(options.tools === undefined ? {} : { tools: options.tools }), diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts new file mode 100644 index 000000000..0d0cfb1e0 --- /dev/null +++ b/packages/agent-bundle/src/cli-entry.ts @@ -0,0 +1,432 @@ +import type { CompiledCliCommand, CompiledCliOption } from './routes/types.ts'; + +/** + * The framework-owned routed-CLI shell (#102 stage 2): command-tree + * resolution, argv parsing against the statically compiled option surface, + * generated help, deterministic exit codes, and signal handling. + * `agent-bundle build` embeds the compiled command graph into every + * generated CLI executable and aliases this module in, exactly like the + * stdio MCP entry lifecycle. + * + * Exit codes: 0 success (or the validated result's `exitCode` under the + * `result` policy), 1 execution or result-contract failure, 2 usage or input + * validation failure, 130/143 after SIGINT/SIGTERM. Machine output (one + * canonical JSON line) goes to stdout; help goes to stdout; diagnostics go + * to stderr. Every value is injectable so the shell is testable with + * plain-object harnesses. + */ + +/** Raised for argv-shape failures: unknown commands or options, missing or malformed values. */ +export class CliUsageError extends Error { + constructor(message: string) { + super(message); + this.name = 'CliUsageError'; + } +} + +/** + * Raised by generated executables when the route module's own `inputSchema` + * rejects parsed input — invalid input is a usage failure (exit 2), never an + * execution failure. + */ +export class CliInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'CliInputError'; + } +} + +export interface GeneratedCliExecuteContext { + /** True when `--json` was passed; plain commands already emit canonical JSON. */ + readonly json: boolean; + readonly signal: AbortSignal; +} + +export interface RunGeneratedCliOptions { + readonly argv: readonly string[]; + readonly commands: readonly CompiledCliCommand[]; + readonly description?: string; + /** Runs one resolved command with parsed input; returns the validated result. */ + readonly execute: ( + command: CompiledCliCommand, + input: Readonly>, + context: GeneratedCliExecuteContext, + ) => Promise; + readonly name: string; + readonly signal?: AbortSignal; + readonly version: string; + readonly writeErr?: (text: string) => void; + readonly writeOut?: (text: string) => void; +} + +interface CommandTreeNode { + readonly children: Map; + command?: CompiledCliCommand; + readonly path: readonly string[]; +} + +const buildCommandTree = (commands: readonly CompiledCliCommand[]): CommandTreeNode => { + const root: CommandTreeNode = { children: new Map(), path: [] }; + for (const command of commands) { + let node = root; + for (const segment of command.path) { + let child = node.children.get(segment); + if (child === undefined) { + child = { children: new Map(), path: [...node.path, segment] }; + node.children.set(segment, child); + } + node = child; + } + node.command = command; + const parent = command.path.length === 1 + ? root + : command.path.slice(0, -1).reduce((cursor, segment) => cursor.children.get(segment)!, root); + for (const alias of command.aliases) { + const target = parent.children.get(command.path[command.path.length - 1]!)!; + parent.children.set(alias, target); + } + } + return root; +}; + +const optionPlaceholder = (option: CompiledCliOption): string => { + if (option.kind === 'boolean') return ''; + if (option.choices !== undefined) return ` <${option.choices.join('|')}>`; + return ` <${option.kind}>`; +}; + +const positionalPlaceholder = (option: CompiledCliOption): string => { + const name = option.repeated ? `${option.option}...` : option.option; + return option.required ? `<${name}>` : `[${name}]`; +}; + +const helpColumns = (rows: readonly (readonly [string, string])[]): string => { + const width = rows.reduce((max, [left]) => Math.max(max, left.length), 0); + return rows.map(([left, right]) => ` ${left.padEnd(width)}${right === '' ? '' : ` ${right}`}`).join('\n'); +}; + +const sortedPositionals = (command: CompiledCliCommand): readonly CompiledCliOption[] => + command.options + .filter((option) => option.positional !== undefined) + .sort((left, right) => left.positional! - right.positional!); + +const namedOptions = (command: CompiledCliCommand): readonly CompiledCliOption[] => + command.options.filter((option) => option.positional === undefined); + +const globalOptionRows: readonly (readonly [string, string])[] = [ + ['-h, --help', 'Show help.'], + [' --json', 'Emit the canonical JSON result.'], + [' --version', 'Print the version.'], +]; + +const commandUsage = (name: string, command: CompiledCliCommand): string => { + const positionals = sortedPositionals(command).map(positionalPlaceholder); + return `Usage: ${name} ${command.path.join(' ')} [options]${positionals.length === 0 ? '' : ` ${positionals.join(' ')}`}`; +}; + +const commandHelp = (name: string, command: CompiledCliCommand): string => { + const lines: string[] = [commandUsage(name, command)]; + if (command.description !== undefined) lines.push('', command.description); + if (command.aliases.length > 0) lines.push('', `Aliases: ${command.aliases.join(', ')}`); + const positionals = sortedPositionals(command); + if (positionals.length > 0) { + lines.push('', 'Arguments:', helpColumns(positionals.map((option) => [ + positionalPlaceholder(option), + [ + option.description ?? '', + ...(option.choices === undefined ? [] : [`(${option.choices.join('|')})`]), + ...(option.defaultValue === undefined ? [] : [`[default: ${JSON.stringify(option.defaultValue)}]`]), + ].filter((part) => part !== '').join(' '), + ]))); + } + const options = namedOptions(command); + const optionRows: (readonly [string, string])[] = options.map((option) => [ + ` --${option.option}${optionPlaceholder(option)}${option.repeated ? ' ...' : ''}`, + [ + option.description ?? '', + ...(option.required ? ['(required)'] : []), + ...(option.defaultValue === undefined ? [] : [`[default: ${JSON.stringify(option.defaultValue)}]`]), + ].filter((part) => part !== '').join(' '), + ]); + lines.push('', 'Options:', helpColumns([...optionRows, ...globalOptionRows])); + return `${lines.join('\n')}\n`; +}; + +const treeHelp = ( + name: string, + version: string, + description: string | undefined, + node: CommandTreeNode, +): string => { + const lines: string[] = []; + if (node.path.length === 0) { + lines.push(`${name} ${version}`); + if (description !== undefined) lines.push('', description); + lines.push('', `Usage: ${name} [options]`); + } else { + lines.push(`Usage: ${name} ${node.path.join(' ')} [options]`); + } + const rows: (readonly [string, string])[] = []; + const seen = new Set(); + for (const [segment, child] of [...node.children.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (seen.has(child)) continue; + seen.add(child); + const label = child.command === undefined ? `${segment} ` : segment; + rows.push([label, child.command?.description ?? '']); + } + lines.push('', 'Commands:', helpColumns(rows)); + lines.push('', 'Options:', helpColumns(globalOptionRows)); + return `${lines.join('\n')}\n`; +}; + +interface ParsedArgv { + readonly input: Readonly>; + readonly json: boolean; +} + +const coerceValue = (option: CompiledCliOption, value: string): unknown => { + switch (option.kind) { + case 'boolean': + throw new CliUsageError(`--${option.option} is a flag and takes no value.`); + case 'number': { + const parsed = Number(value); + if (value.trim() === '' || !Number.isFinite(parsed)) { + throw new CliUsageError(`--${option.option} requires a number; got ${JSON.stringify(value)}.`); + } + return parsed; + } + case 'enum': { + if (!(option.choices ?? []).includes(value)) { + throw new CliUsageError(`--${option.option} must be one of: ${(option.choices ?? []).join(', ')}.`); + } + return value; + } + case 'string': + return value; + default: { + const unreachable: never = option.kind; + throw new TypeError(`Unhandled option kind ${String(unreachable)}.`); + } + } +}; + +const coercePositional = (option: CompiledCliOption, value: string): unknown => { + switch (option.kind) { + case 'number': { + const parsed = Number(value); + if (value.trim() === '' || !Number.isFinite(parsed)) { + throw new CliUsageError(`<${option.option}> requires a number; got ${JSON.stringify(value)}.`); + } + return parsed; + } + case 'enum': { + if (!(option.choices ?? []).includes(value)) { + throw new CliUsageError(`<${option.option}> must be one of: ${(option.choices ?? []).join(', ')}.`); + } + return value; + } + case 'string': + return value; + case 'boolean': + throw new CliUsageError(`<${option.option}> cannot be a flag.`); + default: { + const unreachable: never = option.kind; + throw new TypeError(`Unhandled option kind ${String(unreachable)}.`); + } + } +}; + +/** 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 values = new Map(); + const bare: string[] = []; + let json = false; + let index = 0; + const readOption = (raw: string): void => { + const separator = raw.indexOf('='); + const name = separator === -1 ? raw.slice(2) : raw.slice(2, separator); + const inline = separator === -1 ? undefined : raw.slice(separator + 1); + if (name === 'json' && inline === undefined) { + json = true; + return; + } + const option = options.get(name); + if (option === undefined) throw new CliUsageError(`Unknown option: --${name}.`); + if (option.kind === 'boolean') { + if (inline !== undefined) throw new CliUsageError(`--${name} is a flag and takes no value.`); + if (values.has(option.key)) throw new CliUsageError(`Duplicate option: --${name}.`); + values.set(option.key, true); + return; + } + let value = inline; + if (value === undefined) { + const next = argv[index + 1]; + if (next === undefined || next.startsWith('--')) throw new CliUsageError(`--${name} requires a value.`); + value = next; + index += 1; + } + const coerced = coerceValue(option, value); + if (option.repeated) { + const existing = values.get(option.key); + values.set(option.key, Array.isArray(existing) ? [...existing, coerced] : [coerced]); + return; + } + if (values.has(option.key)) throw new CliUsageError(`Duplicate option: --${name}.`); + values.set(option.key, coerced); + }; + for (; index < argv.length; index += 1) { + const raw = argv[index]!; + if (raw === '--') { + bare.push(...argv.slice(index + 1)); + break; + } + if (raw.startsWith('--')) { + readOption(raw); + continue; + } + if (raw.startsWith('-') && raw.length > 1) throw new CliUsageError(`Unknown option: ${raw}.`); + bare.push(raw); + } + + const positionals = sortedPositionals(command); + let cursor = 0; + for (const option of positionals) { + if (option.repeated) { + const rest = bare.slice(cursor).map((value) => coercePositional(option, value)); + cursor = bare.length; + if (rest.length === 0) { + if (option.required) throw new CliUsageError(`Missing required argument: <${option.option}...>.`); + continue; + } + values.set(option.key, rest); + continue; + } + if (cursor >= bare.length) { + if (option.required) throw new CliUsageError(`Missing required argument: <${option.option}>.`); + continue; + } + values.set(option.key, coercePositional(option, bare[cursor]!)); + cursor += 1; + } + if (cursor < bare.length) { + throw new CliUsageError(`Unexpected argument: ${JSON.stringify(bare[cursor]!)}.`); + } + for (const option of namedOptions(command)) { + if (option.required && !values.has(option.key)) { + throw new CliUsageError(`Missing required option: --${option.option}.`); + } + } + return { input: Object.fromEntries(values), json }; +}; + +const resultExitCode = (command: CompiledCliCommand, result: unknown): number => { + if (command.exitCode === 'zero') return 0; + const exitCode = typeof result === 'object' && result !== null + ? (result as Record)['exitCode'] + : undefined; + if (typeof exitCode !== 'number' || !Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255) { + throw new Error(`The exitCode result policy requires an integer exitCode property between 0 and 255; got ${JSON.stringify(exitCode)}.`); + } + return exitCode; +}; + +/** + * Runs one routed-CLI invocation to completion and returns the process exit + * code. Help and machine output go through `writeOut`; diagnostics through + * `writeErr`; nothing here touches `process`. + */ +export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Promise => { + const writeOut = options.writeOut ?? ((text: string) => void process.stdout.write(text)); + const writeErr = options.writeErr ?? ((text: string) => void process.stderr.write(text)); + const signal = options.signal ?? new AbortController().signal; + const tree = buildCommandTree(options.commands); + + let node = tree; + let index = 0; + try { + if (options.argv[0] === '--version') { + writeOut(`${options.name} ${options.version}\n`); + return 0; + } + while (index < options.argv.length) { + const token = options.argv[index]!; + if (token === '--help' || token === '-h') { + writeOut(node.command === undefined + ? treeHelp(options.name, options.version, options.description, node) + : commandHelp(options.name, node.command)); + return 0; + } + if (node.command !== undefined || token.startsWith('-')) break; + const child = node.children.get(token); + if (child === undefined) { + throw new CliUsageError(node.path.length === 0 + ? `Unknown command: ${token}.` + : `Unknown command: ${node.path.join(' ')} ${token}.`); + } + node = child; + index += 1; + } + if (node.command === undefined) { + if (node.path.length === 0 && index >= options.argv.length) { + writeOut(treeHelp(options.name, options.version, options.description, node)); + return 0; + } + const token = options.argv[index]; + if (token !== undefined && token.startsWith('-')) { + throw new CliUsageError(`Unknown option: ${token}.`); + } + throw new CliUsageError(`Missing command: ${options.name}${node.path.length === 0 ? '' : ` ${node.path.join(' ')}`} .`); + } + const command = node.command; + const rest = options.argv.slice(index); + const terminator = rest.indexOf('--'); + const visible = terminator === -1 ? rest : rest.slice(0, terminator); + if (visible.includes('--help') || visible.includes('-h')) { + writeOut(commandHelp(options.name, command)); + return 0; + } + const parsed = parseCommandArgv(command, rest); + signal.throwIfAborted(); + const result = await options.execute(command, parsed.input, { json: parsed.json, signal }); + signal.throwIfAborted(); + writeOut(`${JSON.stringify(result)}\n`); + return resultExitCode(command, result); + } catch (error) { + if (signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) { + writeErr('Aborted.\n'); + return 1; + } + const usage = error instanceof CliUsageError || error instanceof CliInputError; + writeErr(`${error instanceof Error ? error.message : String(error)}\n`); + if (usage) { + const helpPath = node.path.length === 0 ? '' : ` ${node.path.join(' ')}`; + writeErr(`Run '${options.name}${helpPath} --help' for usage.\n`); + } + return usage ? 2 : 1; + } +}; + +/** + * The generated executable envelope: wires process argv, stdout/stderr, + * SIGINT/SIGTERM (which reach the framework `AbortSignal` and exit 130/143), + * and the process exit code around {@link runGeneratedCliEntry}. + */ +export const runGeneratedCliProcess = async ( + options: Omit, +): Promise => { + const controller = new AbortController(); + let signalExitCode: number | undefined; + const onSignal = (exitCode: number): void => { + signalExitCode = exitCode; + controller.abort(new DOMException('The CLI process received a termination signal', 'AbortError')); + }; + process.once('SIGINT', () => onSignal(130)); + process.once('SIGTERM', () => onSignal(143)); + const code = await runGeneratedCliEntry({ + ...options, + argv: process.argv.slice(2), + signal: controller.signal, + }); + process.exitCode = signalExitCode ?? code; +}; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index aaf65bdfa..3d3a8e920 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -44,6 +44,7 @@ import type { NormalizedSkill, SourceProvenance, } from '../core/types.ts'; +import type { CompiledCliSurface } from '../routes/types.ts'; import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts'; @@ -130,14 +131,43 @@ const safePackageOutputName = (name: string): boolean => */ export const packageBuildOutputDir = 'dist'; +/** + * The framework-generated routed-CLI bin (#102 stage 2): a generated-mode + * `src/cli/**` surface with at least one compiled command becomes one + * executable named after the plugin, exactly where the `src/cli.ts` + * convention would have placed it. Rendered routes that compiled no command + * are hard source-validation errors (AB4816), so omitting them here is + * deterministic hygiene, never a silent choice. + */ +const generatedCliBinEntry = ( + config: Readonly, + routeCli: CompiledCliSurface | undefined, +): NormalizedBinEntry | undefined => { + if (routeCli?.mode !== 'generated' || !safePackageOutputName(config.plugin.name)) return undefined; + const commands = routeCli.commands ?? []; + if (commands.length === 0) return undefined; + const commandRouteIds = new Set(commands.map((command) => command.routeId)); + const routes = routeCli.routes.filter((route) => commandRouteIds.has(route.id)); + const source = routes[0]!.source; + return { + generatedCli: { commands, routes }, + id: `bin:${config.plugin.name}`, + name: config.plugin.name, + provenance: { kind: 'conventional', sourcePath: source }, + source, + }; +}; + const normalizeBinEntries = ( config: Readonly, root: string, configPath: string, + routeCli: CompiledCliSurface | undefined, ): readonly NormalizedBinEntry[] => { if (config.bin === false) return []; + const generated = generatedCliBinEntry(config, routeCli); if (config.bin !== undefined) { - return Object.entries(config.bin) + const explicit = Object.entries(config.bin) .sort(([left], [right]) => left.localeCompare(right)) .map(([name, input]) => { const declaration = input as string | AgentBundleBinEntry; @@ -149,7 +179,13 @@ const normalizeBinEntries = ( source: resolve(root, entry), }; }); + // Config always wins one name: an explicit bin claiming the plugin name + // shadows the generated CLI, and source validation reports the collision. + return generated === undefined || explicit.some((entry) => entry.name === generated.name) + ? explicit + : [...explicit, generated].sort((left, right) => left.name.localeCompare(right.name)); } + if (generated !== undefined) return [generated]; const conventional = conventionalCliEntrySource(root); if (conventional === undefined || !safePackageOutputName(config.plugin.name)) return []; return [{ @@ -192,15 +228,17 @@ const normalizeLibEntry = ( /** * The framework-owned npm package build: explicit `bin`/`lib` config wins, - * the `src/cli.ts` and `src/index.ts` conventions fill the gaps, and `false` - * opts a project out of a convention entirely. + * the `src/cli.ts` and `src/index.ts` conventions fill the gaps (a + * generated-mode `src/cli/**` command surface supersedes the `src/cli.ts` + * bin convention), and `false` opts a project out of a convention entirely. */ export const normalizePackageBuild = ( config: Readonly, root: string, configPath: string, + routeCli?: CompiledCliSurface, ): NormalizedPackageBuild | undefined => { - const bins = normalizeBinEntries(config, root, configPath); + const bins = normalizeBinEntries(config, root, configPath, routeCli); const lib = normalizeLibEntry(config, root, configPath); if (bins.length === 0 && lib === undefined) return undefined; return { @@ -837,7 +875,12 @@ export const normalizeProject = async ( const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); const scripts = normalizeScripts(loaded, discovered, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); - const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath); + const packageBuild = normalizePackageBuild( + loaded.config, + loaded.context.projectRoot, + loaded.configPath, + discovered.routeGraph?.cli, + ); const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index f73b86588..c9b7261f7 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -15,6 +15,7 @@ import { satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; import { isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; +import { isRenderedCliRoute } from '../routes/cli-commands.ts'; import type { AgentBundleBinEntry, AgentBundleHookEntry, @@ -1525,6 +1526,47 @@ const validateConventionalScripts = ( return diagnostics; }; +/** + * The stage-2 routed-CLI gate (#102): a generated-mode `src/cli/**` surface + * compiles into one framework-generated bin, so a rendered (`.tsx`) command + * route the plain pipeline cannot execute yet, and an explicit `bin` entry + * shadowing the generated executable's name, are hard errors naming their + * explicit resolution. Discovery is not a packaging choice — a route never + * disappears silently. + */ +const validateConventionalCliRoutes = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const cli = discovered.routeGraph?.cli; + if (cli?.mode !== 'generated') return diagnostics; + for (const route of cli.routes) { + if (!isRenderedCliRoute(route)) continue; + diagnostics.push({ + code: 'AB4816', + message: `Conventional CLI route ${route.provenance.relativePath} is a rendered-command module; rendered commands are not supported yet.`, + recovery: 'Rename the module to .ts to ship a plain command, or prefix a path segment with "_" to keep it private.', + severity: 'error', + sourcePath: route.source, + }); + } + const bin = loaded.config.bin; + if ((cli.commands ?? []).length > 0 && bin !== false && bin !== undefined && isRecord(bin)) { + const pluginName = loaded.config.plugin.name; + if (Object.hasOwn(bin, pluginName)) { + diagnostics.push({ + code: 'AB4813', + message: `The explicit bin entry ${JSON.stringify(pluginName)} and the generated src/cli/ command executable share one bin name; the compiler never chooses silently.`, + recovery: `Rename the bin.${pluginName} entry, or remove the src/cli/ routes to keep the explicit bin.`, + severity: 'error', + sourcePath: loaded.configPath, + }); + } + } + return diagnostics; +}; + export const validateSource = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1602,6 +1644,9 @@ export const validateSource = ( // own collisions: rendered, nested, and config-conflicting script routes // stay hard errors until later #102 stages ship them. diagnostics.push(...validateConventionalScripts(loaded, discovered)); + // The stage-2 gate for routed CLI commands: rendered command routes and + // explicit-bin shadowing stay hard errors, never silent omissions. + diagnostics.push(...validateConventionalCliRoutes(loaded, discovered)); return diagnostics; }; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index f0f9c9fbf..6e4a72953 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -1,6 +1,6 @@ import type { EnvironmentConfig } from '@rsbuild/core'; -import type { CompiledAgentRoute } from '../routes/types.ts'; +import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; import type { CapabilityState } from './capabilities.ts'; export interface AgentBundlePluginConfig { @@ -350,6 +350,11 @@ export interface NormalizedScript { /** One normalized npm-facing CLI binary in the framework-owned package build. */ export interface NormalizedBinEntry { + /** The compiled routed-CLI surface a framework-generated bin executes (#102 stage 2). */ + readonly generatedCli?: { + readonly commands: readonly CompiledCliCommand[]; + readonly routes: readonly CompiledAgentRoute[]; + }; readonly id: string; readonly name: string; readonly provenance: SourceProvenance; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 294565297..a9af62252 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -16,6 +16,8 @@ export type { AgentEventRuntimeMode, AppRouteConfig, CanonicalAgentEvent, + CliRouteConfig, + CliRouteProps, PromptConfig, ResourceConfig, RouteSchema, diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts new file mode 100644 index 000000000..163c33ca4 --- /dev/null +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -0,0 +1,514 @@ +// Aliased: the workspace toolchain is typescript@7 (native compiler, no +// single-file parse API), and a plain `typescript` dependency here would +// shadow it for rslib's declaration generation. The alias ships the 5.x +// compiler API for parsing only. +import ts from 'typescript-5'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import type { CompiledCliOption } from './types.ts'; + +/** + * The bounded zod-to-argv grammar (#102 stage 2). A routed CLI command's + * `inputSchema` is projected onto argv statically — the module is parsed, + * never executed — so the initializer must be built from these forms only: + * + * - the top level is `z.object({ ... })` or `z.strictObject({ ... })`, + * optionally followed by `.strict()`; + * - each property is a zod chain rooted at `z.string()`, `z.number()`, + * `z.boolean()`, `z.enum([...string literals])`, or `z.array()` + * where the element chain roots at `z.string()`, `z.number()`, or + * `z.enum([...])`; + * - chains may append `.optional()`, `.default()`, and + * `.describe('')` — these shape the argv contract — plus a + * bounded set of validation-only refinements the projection accepts + * without interpreting (strings: `min`, `max`, `length`, `regex`, + * `startsWith`, `endsWith`, `includes`; numbers: `int`, `min`, `max`, + * `gt`, `gte`, `lt`, `lte`, `positive`, `nonnegative`, `negative`, + * `nonpositive`, `finite`, `safe`, `multipleOf`, `step`; arrays: `min`, + * `max`, `length`, `nonempty`), because the module's real zod schema still + * validates every input at run time; + * - `as`/`satisfies` casts, non-null assertions, and parentheses unwrap. + * + * Everything else — identifier references (including shared schema + * constants), unions, tuples, nested objects, records, literals, + * transforms, refinements, coercions, template substitutions — is outside + * the projection and raises AB4814 naming the offending construct. Argv + * policy: property keys project to kebab-case `--options` (`maxFiles` -> + * `--max-files`); `--help`, `--json`, `--ndjson`, and `--version` are + * reserved; booleans are flags and must carry `.optional()` or + * `.default(...)`; `config.positionals` names the keys consumed as bare + * arguments in order. + */ +export const cliArgvGrammar = + 'z.object of z.string/z.number/z.boolean/z.enum/z.array chains with optional/default/describe and bounded validation-only refinements'; + +/** Option names the generated CLI shell owns; schema keys must not project onto them. */ +export const reservedCliOptionNames: ReadonlySet = Object.freeze(new Set([ + 'help', + 'json', + 'ndjson', + 'version', +])); + +/** + * The statically extracted argv projection of one CLI route module's + * `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. + */ +export interface ExtractedCliArgv { + readonly diagnostics: readonly Diagnostic[]; + readonly found: boolean; + readonly options?: readonly CompiledCliOption[]; +} + +const grammarRecovery = `Restrict the inputSchema initializer to the bounded argv grammar (${cliArgvGrammar}), then inspect again.`; + +const argvError = (message: string, sourcePath: string): Diagnostic => ({ + code: 'AB4814', + message, + recovery: grammarRecovery, + severity: 'error', + sourcePath, +}); + +/** Casts, assertions, and parentheses carry no runtime value; unwrap them. */ +const unwrapExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) || + ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + return current; +}; + +const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { + const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return `${line + 1}:${character + 1}`; +}; + +interface ChainCall { + readonly args: readonly ts.Expression[]; + readonly method: string; + readonly node: ts.Node; +} + +interface ZodChain { + /** The `z.(...)` call name and arguments. */ + readonly base: ChainCall; + /** Chained method calls after the base, innermost first. */ + readonly calls: readonly ChainCall[]; +} + +/** Flattens `z.base(...).m1(...).m2(...)` into base + ordered calls; undefined when the shape is not a `z.` chain. */ +const flattenZodChain = (expression: ts.Expression): ZodChain | undefined => { + const calls: ChainCall[] = []; + let current = unwrapExpression(expression); + while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { + const target = unwrapExpression(current.expression.expression); + calls.unshift({ args: current.arguments, method: current.expression.name.text, node: current }); + if (ts.isIdentifier(target) && target.text === 'z') { + const base = calls.shift()!; + return { base, calls }; + } + current = target; + } + return undefined; +}; + +type StaticLiteral = + | { readonly kind: 'value'; readonly value: unknown } + | { readonly kind: 'dynamic'; readonly node: ts.Node }; + +/** Static literal grammar for `.default(...)` arguments: scalars and arrays of scalars. */ +const staticLiteral = (expression: ts.Expression): StaticLiteral => { + const node = unwrapExpression(expression); + if (node.kind === ts.SyntaxKind.TrueKeyword) return { kind: 'value', value: true }; + if (node.kind === ts.SyntaxKind.FalseKeyword) return { kind: 'value', value: false }; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return { kind: 'value', value: node.text }; + } + if (ts.isNumericLiteral(node)) return { kind: 'value', value: Number(node.text) }; + if (ts.isPrefixUnaryExpression(node)) { + const operand = unwrapExpression(node.operand); + if ( + ts.isNumericLiteral(operand) && + (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) + ) { + const magnitude = Number(operand.text); + return { kind: 'value', value: node.operator === ts.SyntaxKind.MinusToken ? -magnitude : magnitude }; + } + return { kind: 'dynamic', node }; + } + if (ts.isArrayLiteralExpression(node)) { + const values: unknown[] = []; + for (const element of node.elements) { + if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return { kind: 'dynamic', node: element }; + const extracted = staticLiteral(element); + if (extracted.kind === 'dynamic') return extracted; + values.push(extracted.value); + } + return { kind: 'value', value: values }; + } + return { kind: 'dynamic', node }; +}; + +type ScalarBaseKind = 'boolean' | 'enum' | 'number' | 'string'; + +const validationOnlyMethods: Readonly>> = Object.freeze({ + array: new Set(['length', 'max', 'min', 'nonempty']), + boolean: new Set(), + enum: new Set(), + number: new Set([ + 'finite', + 'gt', + 'gte', + 'int', + 'lt', + 'lte', + 'max', + 'min', + 'multipleOf', + 'negative', + 'nonnegative', + 'nonpositive', + 'positive', + 'safe', + 'step', + ]), + string: new Set(['endsWith', 'includes', 'length', 'max', 'min', 'regex', 'startsWith']), +}); + +interface ScalarBase { + readonly choices?: readonly string[]; + readonly kind: ScalarBaseKind; +} + +type ScalarBaseResult = + | { readonly base: ScalarBase; readonly ok: true } + | { readonly message: string; readonly ok: false }; + +/** Interprets one `z.(...)` call as a scalar argv projection base. */ +const scalarBaseOf = ( + chain: ZodChain, + sourceFile: ts.SourceFile, + relativePath: string, + key: string, +): ScalarBaseResult => { + const { args, method, node } = chain.base; + const reject = (detail: string): ScalarBaseResult => ({ + message: `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, + ok: false, + }); + switch (method) { + case 'string': + case 'number': + case 'boolean': { + if (args.length > 0) return reject(`z.${method} with arguments`); + return { base: { kind: method }, ok: true }; + } + case 'enum': { + const argument = args.length === 1 ? unwrapExpression(args[0]!) : undefined; + if (argument === undefined || !ts.isArrayLiteralExpression(argument)) { + return reject('z.enum without one array-literal argument'); + } + const choices: string[] = []; + for (const element of argument.elements) { + const literal = unwrapExpression(element as ts.Expression); + if (!ts.isStringLiteral(literal) && !ts.isNoSubstitutionTemplateLiteral(literal)) { + return reject('a non-string-literal z.enum member'); + } + choices.push(literal.text); + } + if (choices.length === 0) return reject('an empty z.enum'); + return { base: { choices, kind: 'enum' }, ok: true }; + } + default: + return reject(`the zod base z.${method}`); + } +}; + +/** True when every chained call is validation-only for the given base kind. */ +const validationOnlyChain = ( + calls: readonly ChainCall[], + kind: ScalarBaseKind | 'array', +): ChainCall | undefined => calls.find((call) => !validationOnlyMethods[kind].has(call.method)); + +interface PropertyProjection { + readonly diagnostics: readonly Diagnostic[]; + readonly option?: CompiledCliOption; +} + +const projectProperty = ( + key: string, + initializer: ts.Expression, + sourceFile: ts.SourceFile, + relativePath: string, + sourcePath: string, +): PropertyProjection => { + const reject = (detail: string, node: ts.Node): PropertyProjection => ({ + diagnostics: [argvError( + `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, + sourcePath, + )], + }); + const chain = flattenZodChain(initializer); + if (chain === undefined) { + const node = unwrapExpression(initializer); + const description = ts.isIdentifier(node) + ? `a reference to the identifier ${JSON.stringify(node.text)}` + : 'an expression outside the z.(...) chain form'; + return reject(description, node); + } + + let elementBase: ScalarBase | undefined; + let repeated = false; + if (chain.base.method === 'array') { + repeated = true; + const argument = chain.base.args.length === 1 ? chain.base.args[0]! : undefined; + const element = argument === undefined ? undefined : flattenZodChain(argument); + if (element === undefined) { + return reject('z.array without one z.(...) chain argument', chain.base.node); + } + const scalar = scalarBaseOf(element, sourceFile, relativePath, key); + if (!scalar.ok) return { diagnostics: [argvError(scalar.message, sourcePath)] }; + if (scalar.base.kind === 'boolean') { + return reject('z.array of z.boolean cannot be projected onto argv;', chain.base.node); + } + const invalidElementCall = validationOnlyChain(element.calls, scalar.base.kind); + if (invalidElementCall !== undefined) { + return reject(`the array-element method .${invalidElementCall.method}()`, invalidElementCall.node); + } + elementBase = scalar.base; + } else { + const scalar = scalarBaseOf(chain, sourceFile, relativePath, key); + if (!scalar.ok) return { diagnostics: [argvError(scalar.message, sourcePath)] }; + elementBase = scalar.base; + } + + let defaultValue: unknown; + let hasDefault = false; + let description: string | undefined; + let optional = false; + const validationKind = repeated ? 'array' : elementBase.kind; + for (const call of chain.calls) { + if (call.method === 'optional') { + if (call.args.length > 0) return reject('.optional() with arguments', call.node); + optional = true; + continue; + } + if (call.method === 'default') { + const argument = call.args.length === 1 ? staticLiteral(call.args[0]!) : undefined; + if (argument === undefined || argument.kind === 'dynamic') { + return reject('.default() without one static literal argument', call.node); + } + defaultValue = argument.value; + hasDefault = true; + continue; + } + if (call.method === 'describe') { + const argument = call.args.length === 1 ? unwrapExpression(call.args[0]!) : undefined; + if (argument === undefined || (!ts.isStringLiteral(argument) && !ts.isNoSubstitutionTemplateLiteral(argument))) { + return reject('.describe() without one string-literal argument', call.node); + } + description = argument.text; + continue; + } + if (validationOnlyMethods[validationKind].has(call.method)) continue; + return reject(`the method .${call.method}()`, call.node); + } + + const required = !optional && !hasDefault; + if (elementBase.kind === 'boolean' && required) { + return { + diagnostics: [argvError( + `CLI route ${relativePath} property ${JSON.stringify(key)}: a required boolean cannot be expressed as a flag; add .optional() or .default(false).`, + sourcePath, + )], + }; + } + + const option = key + .replace(/([a-z0-9])([A-Z])/gu, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1-$2') + .toLowerCase(); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(option)) { + return { + diagnostics: [argvError( + `CLI route ${relativePath} property ${JSON.stringify(key)} does not project onto a kebab-case option name.`, + sourcePath, + )], + }; + } + if (reservedCliOptionNames.has(option)) { + return { + diagnostics: [argvError( + `CLI route ${relativePath} property ${JSON.stringify(key)} projects onto the reserved option --${option}.`, + sourcePath, + )], + }; + } + + return { + diagnostics: [], + option: { + ...(elementBase.choices === undefined ? {} : { choices: elementBase.choices }), + ...(hasDefault ? { defaultValue } : {}), + ...(description === undefined ? {} : { description }), + key, + kind: elementBase.kind, + option, + repeated, + required, + }, + }; +}; + +interface InputSchemaExportSite { + /** The accepted-form initializer; absent for every rejected declaration shape. */ + readonly initializer?: ts.Expression; + readonly rejection?: string; +} + +const bindsInputSchemaName = (name: ts.BindingName): boolean => { + if (ts.isIdentifier(name)) return name.text === 'inputSchema'; + return name.elements.some((element) => + !ts.isOmittedExpression(element) && bindsInputSchemaName(element.name)); +}; + +const hasExportModifier = (statement: ts.Statement): boolean => + ts.canHaveModifiers(statement) && + (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + +/** Finds the first top-level statement that exports an `inputSchema` binding. */ +const findInputSchemaExport = (sourceFile: ts.SourceFile): InputSchemaExportSite | undefined => { + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + const declaration = statement.declarationList.declarations + .find((candidate) => bindsInputSchemaName(candidate.name)); + if (declaration === undefined) continue; + if (!ts.isIdentifier(declaration.name)) return { rejection: 'a destructuring declaration' }; + if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) { + return { rejection: 'a mutable `let`/`var` declaration' }; + } + if (declaration.initializer === undefined) return { rejection: 'a declaration without an initializer' }; + return { initializer: declaration.initializer }; + } + if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause)) { + const named = statement.exportClause.elements + .find((element) => element.name.text === 'inputSchema'); + if (named !== undefined) return { rejection: 'an indirect `export { inputSchema }` clause' }; + } + } + return undefined; +}; + +/** + * 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; the module's real zod schema still + * validates parsed input at run time, so validation-only refinements pass + * through uninterpreted. + */ +export const extractCliArgv = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): ExtractedCliArgv => { + const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const site = findInputSchemaExport(sourceFile); + if (site === undefined) return deepFreeze({ diagnostics: [], found: false }); + if (site.initializer === undefined) { + return deepFreeze({ + diagnostics: [argvError( + `CLI route ${relativePath} exports inputSchema through ${site.rejection!}; only a single top-level \`export const inputSchema = z.object({ ... })\` declaration is projected onto argv.`, + sourcePath, + )], + found: true, + }); + } + + const chain = flattenZodChain(site.initializer); + const objectBase = chain !== undefined && (chain.base.method === 'object' || chain.base.method === 'strictObject') + ? chain + : undefined; + if (objectBase === undefined) { + return deepFreeze({ + diagnostics: [argvError( + `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top level must be z.object({ ... }) or z.strictObject({ ... }).`, + sourcePath, + )], + found: true, + }); + } + const invalidTopLevelCall = objectBase.calls.find((call) => call.method !== 'strict'); + if (invalidTopLevelCall !== undefined) { + return deepFreeze({ + diagnostics: [argvError( + `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top-level method .${invalidTopLevelCall.method}() at ${positionOf(sourceFile, invalidTopLevelCall.node)} is not supported.`, + sourcePath, + )], + found: true, + }); + } + const shape = objectBase.base.args.length === 1 ? unwrapExpression(objectBase.base.args[0]!) : undefined; + if (shape === undefined || !ts.isObjectLiteralExpression(shape)) { + return deepFreeze({ + diagnostics: [argvError( + `CLI route ${relativePath} has an inputSchema outside the argv grammar: z.${objectBase.base.method} requires one object-literal argument.`, + sourcePath, + )], + found: true, + }); + } + + const diagnostics: Diagnostic[] = []; + const options: CompiledCliOption[] = []; + const seenOptions = new Map(); + for (const property of shape.properties) { + if (!ts.isPropertyAssignment(property)) { + diagnostics.push(argvError( + `CLI route ${relativePath} has an inputSchema property outside the argv grammar at ${positionOf(sourceFile, property)}; use plain \`key: z...\` property assignments.`, + sourcePath, + )); + continue; + } + const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined; + if (name === undefined) { + diagnostics.push(argvError( + `CLI route ${relativePath} has a computed inputSchema property name at ${positionOf(sourceFile, property.name)}; property names must be identifiers or string literals.`, + sourcePath, + )); + continue; + } + const projected = projectProperty(name, property.initializer, sourceFile, relativePath, sourcePath); + diagnostics.push(...projected.diagnostics); + if (projected.option === undefined) continue; + const claimed = seenOptions.get(projected.option.option); + if (claimed !== undefined) { + diagnostics.push(argvError( + `CLI route ${relativePath} properties ${JSON.stringify(claimed)} and ${JSON.stringify(name)} both project onto --${projected.option.option}.`, + sourcePath, + )); + continue; + } + seenOptions.set(projected.option.option, name); + options.push(projected.option); + } + + if (diagnostics.length > 0) return deepFreeze({ diagnostics, found: true }); + return deepFreeze({ + diagnostics: [], + found: true, + options: [...options].sort((left, right) => left.option.localeCompare(right.option)), + }); +}; diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts new file mode 100644 index 000000000..a9eceb43d --- /dev/null +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -0,0 +1,320 @@ +import { extname } from 'node:path'; + +import { extractCliArgv } from './cli-argv.ts'; +import { scanRouteModuleExports } from './contract.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import type { CompiledAgentRoute, CompiledCliCommand, CompiledCliOption } from './types.ts'; + +/** + * The #102 stage-2 command-graph compiler: projects the generated-mode + * `src/cli/**` route surface into one collision-checked command list. Path + * segments below the CLI root are the command nesting (`cli:library/audit` + * -> `library audit`); the statically extracted route config supplies + * description, aliases, positionals, and the exit-code policy; the bounded + * argv grammar supplies the option surface. Rendered (`.tsx`) routes compile + * no command until #102 stage 3 — source validation gates them (AB4816). + */ + +const renderedCliExtensions = new Set(['.jsx', '.tsx']); + +/** True for a rendered (`.tsx`/`.jsx`) CLI route module (#102 stage 3 surface). */ +export const isRenderedCliRoute = (route: CompiledAgentRoute): boolean => + renderedCliExtensions.has(extname(route.source).toLowerCase()); + +/** The path-derived command segments of one CLI route (`cli:library/audit` -> `['library', 'audit']`). */ +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, + recovery: 'Keep exactly one command per name at each nesting level, then inspect again.', + severity: 'error', + sourcePath, +}); + +const contractError = (message: string, sourcePath: string): Diagnostic => ({ + code: 'AB4815', + message, + recovery: 'Export const inputSchema and resultSchema, plus one async default function receiving { input, signal }.', + severity: 'error', + sourcePath, +}); + +const positionalsError = (message: string, sourcePath: string): Diagnostic => ({ + code: 'AB4814', + message, + recovery: 'Name existing scalar schema keys in argument order; only the last positional may be an array.', + severity: 'error', + sourcePath, +}); + +interface RouteCliConfig { + readonly aliases: readonly string[]; + readonly description?: string; + readonly diagnostics: readonly Diagnostic[]; + readonly exitCode: 'result' | 'zero'; + readonly positionals?: readonly string[]; +} + +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; + const diagnostics: Diagnostic[] = []; + const description = route.config['description']; + if (description !== undefined && typeof description !== 'string') { + diagnostics.push(contractError( + `CLI route ${relativePath} config.description must be a string.`, + route.source, + )); + } + const declaredAliases = route.config['aliases']; + let aliases = declaredAliases === undefined ? [] : stringArray(declaredAliases); + if (aliases === undefined) { + diagnostics.push(contractError( + `CLI route ${relativePath} config.aliases must be an array of strings.`, + route.source, + )); + aliases = []; + } + const declaredExitCode = route.config['exitCode']; + let exitCode: 'result' | 'zero' = 'zero'; + if (declaredExitCode === 'result') { + exitCode = 'result'; + } else if (declaredExitCode !== undefined) { + diagnostics.push(contractError( + `CLI route ${relativePath} config.exitCode must be "result" when declared; the default policy exits 0 on success.`, + route.source, + )); + } + const declaredPositionals = route.config['positionals']; + const positionals = declaredPositionals === undefined ? undefined : stringArray(declaredPositionals); + if (declaredPositionals !== undefined && positionals === undefined) { + diagnostics.push(positionalsError( + `CLI route ${relativePath} config.positionals must be an array of schema key strings.`, + route.source, + )); + } + return { + aliases, + ...(typeof description === 'string' ? { description } : {}), + diagnostics, + exitCode, + ...(positionals === undefined ? {} : { positionals }), + }; +}; + +/** Applies `config.positionals` onto the extracted option surface, in declared order. */ +const applyPositionals = ( + options: readonly CompiledCliOption[], + positionals: readonly string[], + relativePath: string, + sourcePath: string, +): { 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, + )); + 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, + )); + 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, + )); + 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, + )); + continue; + } + indexOfKey.set(key, index); + } + let sawOptionalPositional = false; + for (const key of positionals) { + const option = byKey.get(key); + 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, + )); + } + } + if (diagnostics.length > 0) return { diagnostics }; + return { + diagnostics: [], + options: options.map((option) => { + const index = indexOfKey.get(option.key); + return index === undefined ? option : { ...option, positional: index }; + }), + }; +}; + +export interface CompiledCliCommandSurface { + readonly commands: readonly CompiledCliCommand[]; + readonly diagnostics: readonly Diagnostic[]; +} + +/** + * 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). + */ +export const compileCliCommands = async ( + routes: readonly CompiledAgentRoute[], + readModuleText: (route: CompiledAgentRoute) => Promise, +): Promise => { + const diagnostics: Diagnostic[] = []; + const commands: CompiledCliCommand[] = []; + + for (const route of [...routes].sort((left, right) => left.id.localeCompare(right.id))) { + if (isRenderedCliRoute(route)) continue; + const relativePath = route.provenance.relativePath; + const moduleText = await readModuleText(route); + if (moduleText === undefined) continue; + + const config = routeCliConfig(route); + diagnostics.push(...config.diagnostics); + + const exports = scanRouteModuleExports(moduleText, relativePath); + const argv = extractCliArgv(moduleText, relativePath, route.source); + const missing = [ + ...(argv.found ? [] : ['inputSchema']), + ...(exports.named.has('resultSchema') ? [] : ['resultSchema']), + ]; + if (missing.length > 0 || !exports.asyncDefault) { + const details = [ + ...(missing.length === 0 ? [] : [`missing named ${missing.join(' and ')}`]), + ...(exports.asyncDefault ? [] : ['default export is not an async function']), + ]; + diagnostics.push(contractError( + `CLI route ${relativePath} does not satisfy the routed command contract: ${details.join('; ')}.`, + route.source, + )); + continue; + } + diagnostics.push(...argv.diagnostics); + if (config.diagnostics.length > 0 || argv.options === undefined) continue; + + let options = argv.options; + if (config.positionals !== undefined) { + const positioned = applyPositionals(options, config.positionals, relativePath, route.source); + diagnostics.push(...positioned.diagnostics); + if (positioned.options === undefined) continue; + options = positioned.options; + } + + commands.push({ + aliases: config.aliases, + ...(config.description === undefined ? {} : { description: config.description }), + exitCode: config.exitCode, + options, + path: cliCommandPath(route), + routeId: route.id, + }); + } + + // Collision checks run over the compiled commands plus the rendered routes' + // claimed paths, so a `.tsx` sibling still collides deterministically. + const claimedPaths = new Map(); + for (const route of routes) { + claimedPaths.set(cliCommandPath(route).join('/'), route.provenance.relativePath); + } + const groupPaths = new Map(); + for (const route of routes) { + const path = cliCommandPath(route); + for (let depth = 1; depth < path.length; depth += 1) { + const prefix = path.slice(0, depth).join('/'); + if (!groupPaths.has(prefix)) groupPaths.set(prefix, route.provenance.relativePath); + } + } + const sourceByPath = new Map(routes.map((route) => [cliCommandPath(route).join('/'), route.source])); + for (const [path, relativePath] of claimedPaths) { + const groupClaim = groupPaths.get(path); + if (groupClaim !== undefined) { + diagnostics.push(collisionError( + `CLI command ${JSON.stringify(path.replaceAll('/', ' '))} is both the command module ${relativePath} and a command group (${groupClaim} nests below it); the compiler never chooses silently.`, + sourceByPath.get(path)!, + )); + } + } + + // Alias collisions resolve per nesting level: an alias must not equal a + // sibling command name, a sibling group name, or another sibling alias. + const levelNames = new Map>(); + const claimLevelName = (parent: string, name: string, claim: string): string | undefined => { + const names = levelNames.get(parent) ?? new Map(); + levelNames.set(parent, names); + const existing = names.get(name); + if (existing !== undefined) return existing; + names.set(name, claim); + return undefined; + }; + for (const [path, relativePath] of claimedPaths) { + const segments = path.split('/'); + claimLevelName(segments.slice(0, -1).join('/'), segments[segments.length - 1]!, `the command ${relativePath}`); + } + for (const [path, relativePath] of groupPaths) { + const segments = path.split('/'); + claimLevelName(segments.slice(0, -1).join('/'), segments[segments.length - 1]!, `the ${relativePath} command group`); + } + for (const command of commands) { + const parent = command.path.slice(0, -1).join('/'); + const route = routes.find((candidate) => candidate.id === command.routeId)!; + 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, + )); + continue; + } + const existing = claimLevelName(parent, alias, `the ${route.provenance.relativePath} alias`); + if (existing !== undefined) { + diagnostics.push(collisionError( + `CLI alias ${JSON.stringify(alias)} on ${route.provenance.relativePath} collides with ${existing} at the same nesting level.`, + route.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, + )); + } + } + + return deepFreeze({ + commands: [...commands].sort((left, right) => left.path.join('/').localeCompare(right.path.join('/'))), + diagnostics, + }); +}; diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts index 3d19e5ba2..311aebdea 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -28,16 +28,20 @@ const diagnostic = ( recovery: string, ): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); -interface RouteModuleShape { +/** The statically scanned export surface of one route module. */ +export interface RouteModuleExports { + /** True when the default export is an async function or arrow function. */ readonly asyncDefault: boolean; readonly named: ReadonlySet; + /** True when the module exports `execute` or `render` (the retired split contract). */ readonly splitExport: boolean; } -const inspectRouteModule = ( +/** Scans one route module's top-level export surface without evaluating it. */ +export const scanRouteModuleExports = ( moduleText: string, relativePath: string, -): RouteModuleShape => { +): RouteModuleExports => { const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const named = new Set(); let asyncDefault = false; @@ -75,7 +79,7 @@ const inspectRouteModule = ( } } - return { asyncDefault, named, splitExport }; + return Object.freeze({ asyncDefault, named, splitExport }); }; /** Validates G8's one executable MCP route contract without evaluating the module. */ @@ -84,7 +88,7 @@ export const validateRouteModuleContract = ( relativePath: string, sourcePath: string, ): readonly Diagnostic[] => { - const { asyncDefault, named, splitExport } = inspectRouteModule(moduleText, relativePath); + const { asyncDefault, named, splitExport } = scanRouteModuleExports(moduleText, relativePath); const missing = ['inputSchema', 'resultSchema'].filter((name) => !named.has(name)); const diagnostics: Diagnostic[] = []; if (missing.length > 0 || !asyncDefault) { @@ -116,7 +120,7 @@ export const validateEventRouteModuleContract = ( relativePath: string, sourcePath: string, ): readonly Diagnostic[] => { - const { asyncDefault, splitExport } = inspectRouteModule(moduleText, relativePath); + const { asyncDefault, splitExport } = scanRouteModuleExports(moduleText, relativePath); const diagnostics: Diagnostic[] = []; if (!asyncDefault) { diagnostics.push(diagnostic( diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 4fcbe6a2c..69cac93d0 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -5,6 +5,7 @@ import { extname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; +import { compileCliCommands } from './cli-commands.ts'; import { extractRouteConfig } from './config-extract.ts'; import { validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -562,7 +563,20 @@ export const compileRouteGraph = async ( conventionalCli, )); } - cli = { mode, routes: mode === 'conventional' ? [] : cliRoutes }; + if (mode === 'generated') { + const compiled = await compileCliCommands(cliRoutes, async (route) => { + try { + return await readFile(route.source, 'utf8'); + } catch { + // Racing deletion is handled by the next source snapshot. + return undefined; + } + }); + diagnostics.push(...compiled.diagnostics); + cli = { commands: compiled.commands, mode, routes: cliRoutes }; + } else { + cli = { mode, routes: mode === 'conventional' ? [] : cliRoutes }; + } } const identity = { diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 85e270285..fcbdb238b 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -1,4 +1,8 @@ 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 { CompiledCliCommandSurface } from './cli-commands.ts'; export { extractRouteConfig, routeConfigGrammar } from './config-extract.ts'; export type { ExtractedRouteConfig } from './config-extract.ts'; export { inspectRouteGraph } from './inspect.ts'; @@ -8,7 +12,9 @@ export type { CapabilityEvidence, CapabilityState, CompiledAgentRoute, + CompiledCliCommand, CompiledCliMode, + CompiledCliOption, CompiledCliSurface, CompiledProvider, CompiledRouteGraph, @@ -18,7 +24,8 @@ export type { RouteProvenance, } from './types.ts'; export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts'; -export { validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts'; +export { scanRouteModuleExports, validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts'; +export type { RouteModuleExports } from './contract.ts'; export { canonicalAgentEvents } from './public.ts'; export type { AgentEventCanonicalIdentity, @@ -31,6 +38,8 @@ export type { AgentEventRuntimeMode, AppRouteConfig, CanonicalAgentEvent, + CliRouteConfig, + CliRouteProps, PromptConfig, ResourceConfig, RouteSchema, diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 4f0383c75..8fec25e68 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -93,3 +93,31 @@ export interface AppRouteConfig { readonly targets?: readonly string[]; readonly template?: string; } + +/** + * Static metadata of one `src/cli/**` command route (#102 stage 2). Every + * field must stay inside the static route-config grammar; the command path + * itself comes from the file path, never from config. + */ +export interface CliRouteConfig { + /** Alternative command names at the same nesting level. */ + readonly aliases?: readonly string[]; + readonly description?: string; + /** + * Exit-code policy: omit for 0-on-success, or `'result'` to read the + * validated result's integer `exitCode` property (0-255). + */ + readonly exitCode?: 'result'; + /** + * The `inputSchema` keys consumed as bare arguments, in order. All but the + * last must be scalar; a trailing `z.array(...)` key is variadic. Keys not + * named here become `--options`. + */ + readonly positionals?: readonly string[]; +} + +/** Props received by every routed CLI command's async default function. */ +export interface CliRouteProps { + readonly input: RouteSchemaOutput; + readonly signal: AbortSignal; +} diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 2d1d353c8..1f40bc01a 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -85,8 +85,55 @@ export interface CompiledServerSurface { */ export type CompiledCliMode = 'generated' | 'conventional' | 'conflict'; +/** + * One argv projection of a CLI route's `inputSchema` property, derived + * statically from the bounded zod grammar (#102 stage 2). `key` is the + * schema property; `option` is its kebab-case `--option` spelling; a + * positional entry consumes bare arguments in `positional` order instead. + */ +export interface CompiledCliOption { + /** Accepted values of a `z.enum([...])` base. */ + readonly choices?: readonly string[]; + /** The static `.default()` value, surfaced in generated help. */ + readonly defaultValue?: unknown; + /** The static `.describe('')` string, surfaced in generated help. */ + readonly description?: string; + readonly key: string; + readonly kind: 'boolean' | 'enum' | 'number' | 'string'; + readonly option: string; + /** Zero-based positional order when `config.positionals` names the key. */ + 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(...)`. */ + readonly required: boolean; +} + +/** + * One executable command compiled from a `src/cli/**` route: nesting is the + * path-derived identity (`cli:library/audit` -> `library audit`), metadata + * comes from the statically extracted route config, and the argv surface + * comes from the bounded `inputSchema` grammar. + */ +export interface CompiledCliCommand { + readonly aliases: readonly string[]; + readonly description?: string; + /** Exit-code policy: `zero` on success, or `result` reading the validated result's `exitCode`. */ + readonly exitCode: 'result' | 'zero'; + readonly options: readonly CompiledCliOption[]; + /** Command path segments below the CLI root (`['library', 'audit']`). */ + readonly path: readonly string[]; + readonly routeId: string; +} + /** The CLI command surface assembled from `src/cli/**` route modules. */ export interface CompiledCliSurface { + /** + * The collision-checked command graph compiled from the plain (`.ts`) + * routes; present only in `generated` mode. Rendered (`.tsx`) routes stay + * in {@link routes} but compile no command until #102 stage 3. + */ + readonly commands?: readonly CompiledCliCommand[]; readonly mode: CompiledCliMode; /** Discovered command routes; empty when `conventional` mode omits them. */ readonly routes: readonly CompiledAgentRoute[]; diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts new file mode 100644 index 000000000..5e87e0d1e --- /dev/null +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -0,0 +1,133 @@ +import { execFile as executeFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; + +const execFile = promisify(executeFile); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +/** + * The routed-CLI packaging proof (#102 stage 2): `src/cli/**` routes feed the + * existing package-build pipeline as one generated Rslib executable, and the + * emitted bin serves help, JSON output, exit codes, and usage failures per + * the documented contract. + */ +it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 120_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cli-bin-')); + roots.push(root); + // The audiobook example's installed tree supplies @agent-bundle/runtime and zod. + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + zod: '4.4.3', + }, + name: 'cli-bin-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + " plugin: { description: 'Routed CLI fixture.', name: 'cli-bin-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '});', + '', + ].join('\n')), + writeProjectFile(root, 'src/cli/doctor.ts', [ + "import { agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { aliases: ['health'], description: 'Inspect the runtime.' };", + 'export const inputSchema = z.object({ verbose: z.boolean().optional() }).strict();', + "export const resultSchema = z.object({ invocation: z.string(), status: z.literal('ready'), surface: z.string() }).strict();", + 'export default async function doctor({ input, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' const context = await agent();', + ' return {', + ' invocation: context.invocation.kind,', + " status: 'ready',", + " surface: input.verbose === true ? `${context.invocation.surface} (verbose)` : context.invocation.surface,", + ' };', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/cli/library/audit.ts', [ + "import { z } from 'zod';", + "export const config = { description: 'Audit sources.', exitCode: 'result', positionals: ['sources'] };", + 'export const inputSchema = z.object({', + ' maxFindings: z.number().int().min(0).default(1),', + ' sources: z.array(z.string().min(1)).min(1).max(8),', + ' strict: z.boolean().optional(),', + '}).strict();', + 'export const resultSchema = z.object({ exitCode: z.number(), sources: z.array(z.string()) }).strict();', + 'export default async function audit({ input }) {', + ' return {', + ' exitCode: input.strict === true && input.sources.length > input.maxFindings ? 2 : 0,', + ' sources: input.sources,', + ' };', + '}', + '', + ].join('\n')), + ]); + + const result = await build({ output: 'artifact', packageOutputs: true, root }); + expect(result.model.packageBuild?.bins).toMatchObject([ + { name: 'cli-bin-fixture', provenance: { kind: 'conventional' } }, + ]); + const binPath = join(root, 'dist', 'bin', 'cli-bin-fixture.js'); + const binSource = await readFile(binPath, 'utf8'); + expect(binSource.startsWith('#!/usr/bin/env node\n')).toBe(true); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); + expect((await stat(binPath)).mode & 0o111).not.toBe(0); + // The emitted executable's provenance names every command route module. + const binEvidence = result.packageBuild!.files.find((file) => file.path === 'bin/cli-bin-fixture.js'); + expect(binEvidence?.sourceInputs).toEqual(expect.arrayContaining([ + 'src/cli/doctor.ts', + 'src/cli/library/audit.ts', + ])); + + // Help and version come from the compiled command graph. + const help = await execFile(binPath, ['--help']); + expect(help.stdout).toContain('cli-bin-fixture 1.0.0'); + expect(help.stdout).toContain('Routed CLI fixture.'); + expect(help.stdout).toContain('doctor'); + expect(help.stdout).toContain('library '); + const commandHelp = await execFile(binPath, ['library', 'audit', '--help']); + expect(commandHelp.stdout).toContain('Usage: cli-bin-fixture library audit [options] '); + expect(commandHelp.stdout).toContain('--max-findings '); + await expect(execFile(binPath, ['--version'])).resolves.toMatchObject({ stdout: 'cli-bin-fixture 1.0.0\n' }); + + // Commands run inside the typed Agent request context and print one JSON line. + const doctor = await execFile(binPath, ['doctor']); + expect(JSON.parse(doctor.stdout)).toEqual({ invocation: 'cli', status: 'ready', surface: 'doctor' }); + const aliased = await execFile(binPath, ['health', '--verbose', '--json']); + expect(JSON.parse(aliased.stdout)).toEqual({ invocation: 'cli', status: 'ready', surface: 'doctor (verbose)' }); + + // Nested commands parse positionals/options and honor the result exit-code policy. + const audit = await execFile(binPath, ['library', 'audit', 'a', 'b']); + expect(JSON.parse(audit.stdout)).toEqual({ exitCode: 0, sources: ['a', 'b'] }); + await expect(execFile(binPath, ['library', 'audit', '--strict', 'a', 'b'])) + .rejects.toMatchObject({ code: 2, stdout: '{"exitCode":2,"sources":["a","b"]}\n' }); + + // Usage and input-validation failures exit 2 with diagnostics on stderr only. + await expect(execFile(binPath, ['unknown'])).rejects.toMatchObject({ code: 2, stdout: '' }); + const tooMany = execFile(binPath, ['library', 'audit', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']); + await expect(tooMany).rejects.toMatchObject({ code: 2, stdout: '' }); + await expect(tooMany).rejects.toMatchObject({ stderr: expect.stringContaining('sources') }); +}); diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts new file mode 100644 index 000000000..b63ec308d --- /dev/null +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -0,0 +1,555 @@ +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 { inspect } from '../src/api.ts'; +import { CliInputError, runGeneratedCliEntry } from '../src/cli-entry.ts'; +import { normalizePackageBuild } from '../src/config/normalize.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; +import { extractCliArgv } from '../src/routes/cli-argv.ts'; +import { compileRouteGraph } from '../src/routes/graph.ts'; +import type { CompiledCliCommand, CompiledCliSurface } 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-routes-'))); + 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: 'cli-fixture', version: '1.0.0' }, + ...extra, +}); + +const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.code); + +const extract = (schema: string) => extractCliArgv( + `export const inputSchema = ${schema};\n`, + 'src/cli/example.ts', + '/project/src/cli/example.ts', +); + +describe('static argv projection (bounded zod grammar)', () => { + it('projects the supported scalar, enum, array, wrapper, and refinement forms', () => { + const extracted = extract([ + 'z.object({', + " format: z.enum(['json', 'table']).default('table'),", + " maxFiles: z.number().int().min(1).max(256).optional().describe('Bound the scan.'),", + ' root: z.string().min(1),', + ' sources: z.array(z.string().min(1)).min(1),', + ' strict: z.boolean().default(false),', + '}).strict()', + ].join('\n')); + + expect(extracted.diagnostics).toEqual([]); + // Options sort deterministically by projected option name. + expect(extracted.options).toEqual([ + { choices: ['json', 'table'], defaultValue: 'table', key: 'format', kind: 'enum', option: 'format', repeated: false, required: false }, + { description: 'Bound the scan.', key: 'maxFiles', kind: 'number', option: 'max-files', repeated: false, required: false }, + { key: 'root', kind: 'string', option: 'root', repeated: false, required: true }, + { key: 'sources', kind: 'string', option: 'sources', repeated: true, required: true }, + { defaultValue: false, key: 'strict', kind: 'boolean', option: 'strict', repeated: false, required: false }, + ]); + }); + + it('accepts z.strictObject and substitution-free template describe strings', () => { + const extracted = extract("z.strictObject({ name: z.string().describe(`The name.`) })"); + expect(extracted.diagnostics).toEqual([]); + expect(extracted.options).toEqual([ + { description: 'The name.', key: 'name', kind: 'string', option: 'name', repeated: false, required: true }, + ]); + }); + + it('reports found: false when the module exports no inputSchema', () => { + const extracted = extractCliArgv('export const other = 1;\n', 'src/cli/example.ts', '/p/example.ts'); + expect(extracted.found).toBe(false); + expect(extracted.diagnostics).toEqual([]); + }); + + it.each([ + ['a shared schema identifier', 'z.object({ root: pathSchema })', 'pathSchema'], + ['a union', 'z.object({ mode: z.union([z.string(), z.number()]) })', 'z.union'], + ['a nested object', 'z.object({ nested: z.object({ a: z.string() }) })', 'z.object'], + ['a transform', 'z.object({ root: z.string().transform((value) => value) })', '.transform()'], + ['a coercion', 'z.object({ count: z.coerce.number() })', 'outside the z.(...) chain form'], + ['a dynamic default', 'z.object({ root: z.string().default(process.cwd()) })', '.default()'], + ['a spread', 'z.object({ ...shared, root: z.string() })', 'property outside the argv grammar'], + ['an enum with substitutions', 'z.object({ mode: z.enum([`a${1}`]) })', 'non-string-literal z.enum member'], + ['a non-object top level', 'z.string()', 'top level must be z.object'], + ['a passthrough top level', 'z.object({ a: z.string() }).passthrough()', '.passthrough()'], + ])('rejects %s with AB4814 naming the construct', (_label, schema, fragment) => { + const extracted = extract(schema); + expect(codesOf(extracted.diagnostics)).toEqual(['AB4814']); + expect(extracted.diagnostics[0]!.message).toContain(fragment); + expect(extracted.options).toBeUndefined(); + }); + + it('rejects required booleans, reserved options, and kebab-case collisions', () => { + const requiredBoolean = extract('z.object({ strict: z.boolean() })'); + expect(codesOf(requiredBoolean.diagnostics)).toEqual(['AB4814']); + expect(requiredBoolean.diagnostics[0]!.message).toContain('required boolean'); + + const reserved = extract('z.object({ json: z.string() })'); + expect(codesOf(reserved.diagnostics)).toEqual(['AB4814']); + expect(reserved.diagnostics[0]!.message).toContain('reserved option --json'); + + const collision = extract("z.object({ 'max-files': z.string(), maxFiles: z.number() })"); + expect(codesOf(collision.diagnostics)).toEqual(['AB4814']); + expect(collision.diagnostics[0]!.message).toContain('--max-files'); + }); + + it('rejects indirect and mutable inputSchema declarations', () => { + const indirect = extractCliArgv( + 'const inputSchema = z.object({});\nexport { inputSchema };\n', + 'src/cli/example.ts', + '/p/example.ts', + ); + expect(codesOf(indirect.diagnostics)).toEqual(['AB4814']); + expect(indirect.diagnostics[0]!.message).toContain('indirect'); + + const mutable = extractCliArgv('export let inputSchema = z.object({});\n', 'src/cli/example.ts', '/p/example.ts'); + expect(codesOf(mutable.diagnostics)).toEqual(['AB4814']); + expect(mutable.diagnostics[0]!.message).toContain('mutable'); + }); +}); + +const plainCommandModule = (options: { + readonly config?: string; + readonly schema?: string; +} = {}): string => [ + ...(options.config === undefined ? [] : [`export const config = ${options.config};`]), + `export const inputSchema = ${options.schema ?? 'z.object({}).strict()'};`, + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', +].join('\n'); + +describe('compiled command graph', () => { + it('compiles nesting, aliases, positionals, and the exit-code policy into graph.cli.commands', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/doctor.ts': plainCommandModule({ + config: "{ aliases: ['health'], description: 'Inspect the runtime.' }", + schema: 'z.object({ verbose: z.boolean().optional() })', + }), + 'src/cli/library/audit.ts': plainCommandModule({ + config: "{ description: 'Audit sources.', exitCode: 'result', positionals: ['sources'] }", + schema: 'z.object({ report: z.string(), sources: z.array(z.string()).min(1) }).strict()', + }), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.mode).toBe('generated'); + expect(graph.cli?.commands).toEqual([ + { + aliases: ['health'], + description: 'Inspect the runtime.', + exitCode: 'zero', + options: [{ key: 'verbose', kind: 'boolean', option: 'verbose', repeated: false, required: false }], + path: ['doctor'], + routeId: 'cli:doctor', + }, + { + aliases: [], + description: 'Audit sources.', + exitCode: 'result', + options: [ + { key: 'report', kind: 'string', option: 'report', repeated: false, required: true }, + { key: 'sources', kind: 'string', option: 'sources', positional: 0, repeated: true, required: true }, + ], + path: ['library', 'audit'], + routeId: 'cli:library/audit', + }, + ]); + expect(Object.isFrozen(graph.cli!.commands)).toBe(true); + }); + + it('errors with AB4813 when a command path is both a module and a group', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/library.ts': plainCommandModule(), + 'src/cli/library/audit.ts': plainCommandModule(), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics)).toEqual(['AB4813']); + expect(graph.diagnostics[0]!.message).toContain('command group'); + }); + + it('errors with AB4813 on alias collisions across siblings, groups, and duplicates', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/doctor.ts': plainCommandModule({ config: "{ aliases: ['inspect'] }" }), + 'src/cli/inspect.ts': plainCommandModule(), + 'src/cli/library/audit.ts': plainCommandModule(), + 'src/cli/status.ts': plainCommandModule({ config: "{ aliases: ['library'] }" }), + 'src/cli/verify.ts': plainCommandModule({ config: "{ aliases: ['check', 'check'] }" }), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + const messages = graph.diagnostics.map((diagnostic) => diagnostic.message).join('\n'); + expect(codesOf(graph.diagnostics)).toEqual(['AB4813', 'AB4813', 'AB4813']); + expect(messages).toContain('alias "inspect"'); + expect(messages).toContain('alias "library"'); + expect(messages).toContain('alias "check" twice'); + }); + + it('errors with AB4815 on contract violations and malformed config fields', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/bad-alias.ts': plainCommandModule({ config: "{ aliases: 'health' }" }), + 'src/cli/bad-exit.ts': plainCommandModule({ config: "{ exitCode: 'signal' }" }), + 'src/cli/no-result.ts': [ + 'export const inputSchema = z.object({});', + 'export default async () => undefined;', + '', + ].join('\n'), + 'src/cli/sync-default.ts': [ + 'export const inputSchema = z.object({});', + 'export const resultSchema = {};', + 'export default () => undefined;', + '', + ].join('\n'), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics)).toEqual(['AB4815', 'AB4815', 'AB4815', 'AB4815']); + const messages = graph.diagnostics.map((diagnostic) => diagnostic.message).join('\n'); + expect(messages).toContain('config.aliases'); + expect(messages).toContain('config.exitCode'); + expect(messages).toContain('missing named resultSchema'); + expect(messages).toContain('not an async function'); + }); + + it('errors with AB4814 on positional policy violations', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/copy.ts': plainCommandModule({ + config: "{ positionals: ['sources', 'destination'] }", + schema: 'z.object({ destination: z.string(), sources: z.array(z.string()) }).strict()', + }), + 'src/cli/pick.ts': plainCommandModule({ + config: "{ positionals: ['missing'] }", + }), + 'src/cli/scan.ts': plainCommandModule({ + config: "{ positionals: ['root', 'depth'] }", + schema: 'z.object({ depth: z.number(), root: z.string().optional() }).strict()', + }), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics)).toEqual(['AB4814', 'AB4814', 'AB4814']); + const messages = graph.diagnostics.map((diagnostic) => diagnostic.message).join('\n'); + expect(messages).toContain('before the end'); + expect(messages).toContain('not a projected inputSchema key'); + expect(messages).toContain('after an optional one'); + }); + + it('compiles no command for rendered routes and gates them with AB4816 in source validation', async () => { + const root = await createRoot(); + await writeTree(root, { + 'agent-bundle.config.ts': [ + "export default { plugin: { name: 'cli-fixture', version: '1.0.0' }, targets: ['portable'] };", + '', + ].join('\n'), + 'package.json': '{"type":"module"}\n', + 'src/cli/doctor.tsx': plainCommandModule(), + 'src/cli/inspect.ts': plainCommandModule(), + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.commands?.map((command) => command.routeId)).toEqual(['cli:inspect']); + + const result = await inspect({ root }); + expect(result.state).toBe('invalid'); + expect(codesOf(result.diagnostics)).toContain('AB4816'); + }); + + it('errors with AB4813 when an explicit bin entry claims the generated executable name', async () => { + const root = await createRoot(); + await writeTree(root, { + 'agent-bundle.config.ts': [ + 'export default {', + " bin: { 'cli-fixture': './src/tool.ts' },", + " plugin: { name: 'cli-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'package.json': '{"type":"module"}\n', + 'src/cli/inspect.ts': plainCommandModule(), + 'src/tool.ts': 'export const main = async () => 0;\n', + }); + const result = await inspect({ root }); + expect(result.state).toBe('invalid'); + expect(codesOf(result.diagnostics)).toContain('AB4813'); + }); +}); + +describe('generated bin normalization', () => { + const command: CompiledCliCommand = { + aliases: [], + exitCode: 'zero', + options: [], + path: ['inspect'], + routeId: 'cli:inspect', + }; + const surface = (overrides: Partial = {}): CompiledCliSurface => ({ + commands: [command], + mode: 'generated', + routes: [{ + config: {}, + id: 'cli:inspect', + kind: 'cli', + provenance: { kind: 'conventional', relativePath: 'src/cli/inspect.ts' }, + source: '/project/src/cli/inspect.ts', + }], + ...overrides, + }); + + it('feeds the generated command surface into one package bin named after the plugin', () => { + const packageBuild = normalizePackageBuild(fixtureConfig(), '/project', '/project/agent-bundle.config.ts', surface()); + expect(packageBuild?.bins).toEqual([{ + generatedCli: { commands: [command], routes: surface().routes }, + id: 'bin:cli-fixture', + name: 'cli-fixture', + provenance: { kind: 'conventional', sourcePath: '/project/src/cli/inspect.ts' }, + source: '/project/src/cli/inspect.ts', + }]); + }); + + it('keeps explicit bins authoritative: a claimed name shadows the generated CLI, others coexist', () => { + const shadowed = normalizePackageBuild( + fixtureConfig({ bin: { 'cli-fixture': './src/tool.ts' } }), + '/project', + '/project/agent-bundle.config.ts', + surface(), + ); + expect(shadowed?.bins.map((bin) => [bin.name, bin.generatedCli === undefined])).toEqual([['cli-fixture', true]]); + + const coexisting = normalizePackageBuild( + fixtureConfig({ bin: { 'other-tool': './src/tool.ts' } }), + '/project', + '/project/agent-bundle.config.ts', + surface(), + ); + expect(coexisting?.bins.map((bin) => [bin.name, bin.generatedCli === undefined])).toEqual([ + ['cli-fixture', false], + ['other-tool', true], + ]); + }); + + it('honors bin: false and compiles nothing for command-free or non-generated surfaces', () => { + expect(normalizePackageBuild( + fixtureConfig({ bin: false }), + '/project', + '/project/agent-bundle.config.ts', + surface(), + )).toBeUndefined(); + expect(normalizePackageBuild( + fixtureConfig(), + '/project', + '/project/agent-bundle.config.ts', + surface({ commands: [] }), + )).toBeUndefined(); + expect(normalizePackageBuild( + fixtureConfig(), + '/project', + '/project/agent-bundle.config.ts', + surface({ commands: undefined, mode: 'conflict' }), + )).toBeUndefined(); + }); +}); + +describe('generated CLI shell', () => { + const commands: readonly CompiledCliCommand[] = [ + { + aliases: ['health'], + description: 'Inspect the runtime.', + exitCode: 'zero', + options: [ + { defaultValue: 8, description: 'Bound the scan.', key: 'maxFiles', kind: 'number', option: 'max-files', repeated: false, required: false }, + { key: 'root', kind: 'string', option: 'root', positional: 0, repeated: false, required: true }, + { key: 'verbose', kind: 'boolean', option: 'verbose', repeated: false, required: false }, + ], + path: ['doctor'], + routeId: 'cli:doctor', + }, + { + aliases: [], + description: 'Audit sources.', + exitCode: 'result', + options: [ + { choices: ['json', 'table'], key: 'format', kind: 'enum', option: 'format', repeated: false, required: false }, + { key: 'report', kind: 'string', option: 'report', repeated: false, required: true }, + { key: 'sources', kind: 'string', option: 'sources', positional: 0, repeated: true, required: true }, + ], + path: ['library', 'audit'], + routeId: 'cli:library/audit', + }, + ]; + + interface RunResult { + readonly calls: { command: CompiledCliCommand; input: Readonly>; json: boolean }[]; + readonly code: number; + readonly stderr: string; + readonly stdout: string; + } + + const run = async ( + argv: readonly string[], + options: { + readonly result?: unknown; + readonly signal?: AbortSignal; + readonly throws?: Error; + } = {}, + ): Promise => { + const calls: RunResult['calls'] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runGeneratedCliEntry({ + argv, + commands, + description: 'Curate audiobooks.', + execute: async (command, input, context) => { + calls.push({ command, input, json: context.json }); + if (options.throws !== undefined) throw options.throws; + return options.result ?? { ok: true }; + }, + name: 'curator', + ...(options.signal === undefined ? {} : { signal: options.signal }), + version: '1.2.3', + writeErr: (text) => void stderr.push(text), + writeOut: (text) => void stdout.push(text), + }); + return { calls, code, stderr: stderr.join(''), stdout: stdout.join('') }; + }; + + it('prints root help on bare invocation and --help, and the version on --version', async () => { + const bare = await run([]); + expect(bare.code).toBe(0); + expect(bare.stdout).toContain('curator 1.2.3'); + expect(bare.stdout).toContain('Curate audiobooks.'); + expect(bare.stdout).toContain('doctor'); + expect(bare.stdout).toContain('library '); + expect(bare.stdout).toContain('Inspect the runtime.'); + expect((await run(['--help'])).stdout).toBe(bare.stdout); + + const version = await run(['--version']); + expect(version).toMatchObject({ code: 0, stdout: 'curator 1.2.3\n' }); + }); + + it('prints command help with usage, aliases, arguments, defaults, and choices', async () => { + const help = await run(['doctor', '--help']); + expect(help.code).toBe(0); + expect(help.stdout).toContain('Usage: curator doctor [options] '); + expect(help.stdout).toContain('Aliases: health'); + expect(help.stdout).toContain('--max-files '); + expect(help.stdout).toContain('[default: 8]'); + expect(help.stdout).toContain('--verbose'); + + const audit = await run(['library', 'audit', '-h']); + expect(audit.stdout).toContain('Usage: curator library audit [options] '); + expect(audit.stdout).toContain('--format '); + expect(audit.stdout).toContain('(required)'); + + const group = await run(['library', '--help']); + expect(group.code).toBe(0); + expect(group.stdout).toContain('Usage: curator library [options]'); + expect(group.stdout).toContain('audit'); + }); + + it('parses options, positionals, coercions, flags, aliases, and --json, then prints one JSON line', async () => { + const result = await run( + ['doctor', '/library', '--max-files', '3', '--verbose', '--json'], + { result: { status: 'ready' } }, + ); + expect(result.code).toBe(0); + expect(result.stdout).toBe('{"status":"ready"}\n'); + expect(result.calls).toEqual([{ + command: commands[0], + input: { maxFiles: 3, root: '/library', verbose: true }, + json: true, + }]); + + const aliased = await run(['health', '/media'], { result: { status: 'ready' } }); + expect(aliased.calls[0]!.input).toEqual({ root: '/media' }); + + const variadic = await run( + ['library', 'audit', '--report', 'out.json', '--format', 'json', 'a', '--', '--b'], + { result: { exitCode: 0 } }, + ); + expect(variadic.code).toBe(0); + expect(variadic.calls[0]!.input).toEqual({ format: 'json', report: 'out.json', sources: ['a', '--b'] }); + }); + + it('maps usage failures to exit 2 with a help hint on stderr', async () => { + const cases: readonly (readonly [readonly string[], string])[] = [ + [['unknown'], 'Unknown command: unknown.'], + [['library', 'unknown'], 'Unknown command: library unknown.'], + [['library'], 'Missing command: curator library .'], + [['doctor', '/library', '--bogus'], 'Unknown option: --bogus.'], + [['doctor', '/library', '--max-files', 'many'], '--max-files requires a number'], + [['doctor', '/library', '--max-files'], '--max-files requires a value.'], + [['doctor', '/library', '--verbose=true'], '--verbose is a flag'], + [['doctor', '/library', '--verbose', '--verbose'], 'Duplicate option: --verbose.'], + [['doctor'], 'Missing required argument: .'], + [['doctor', '/library', 'extra'], 'Unexpected argument: "extra".'], + [['library', 'audit', '--report', 'r', '--format', 'yaml', 'a'], '--format must be one of: json, table.'], + [['library', 'audit', 'a'], 'Missing required option: --report.'], + [['library', 'audit', '--report', 'r'], 'Missing required argument: .'], + ]; + for (const [argv, message] of cases) { + const result = await run(argv); + expect(result.code).toBe(2); + expect(result.stderr).toContain(message); + expect(result.stderr).toContain("--help' for usage."); + expect(result.calls).toEqual([]); + } + }); + + it('maps execution failures to exit 1 without a usage hint and input failures to exit 2', async () => { + const failed = await run(['doctor', '/library'], { throws: new Error('backend unavailable') }); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain('backend unavailable'); + expect(failed.stderr).not.toContain('for usage'); + + const invalid = await run(['doctor', '/library'], { throws: new CliInputError('root must be absolute') }); + expect(invalid.code).toBe(2); + expect(invalid.stderr).toContain('root must be absolute'); + expect(invalid.stderr).toContain('for usage'); + }); + + it('adopts the validated result exitCode under the result policy and fails closed otherwise', async () => { + const three = await run(['library', 'audit', '--report', 'r', 'a'], { result: { exitCode: 3 } }); + expect(three.code).toBe(3); + expect(three.stdout).toBe('{"exitCode":3}\n'); + + const missing = await run(['library', 'audit', '--report', 'r', 'a'], { result: { ok: true } }); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain('exitCode result policy'); + }); + + it('reports an aborted invocation on stderr with exit 1', async () => { + const controller = new AbortController(); + controller.abort(new DOMException('stop', 'AbortError')); + const aborted = await run(['doctor', '/library'], { signal: controller.signal }); + expect(aborted.code).toBe(1); + expect(aborted.stderr).toBe('Aborted.\n'); + expect(aborted.calls).toEqual([]); + }); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index bd917ca7f..cf1726d22 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -41,7 +41,15 @@ const fixtureConfig = (extra: Readonly> = {}): AgentBund const conventionalTree: Readonly> = { 'src/cli/doctor.tsx': moduleSource, - 'src/cli/library/audit.ts': moduleSource, + // Plain CLI routes compile through the stage-2 argv grammar, so the + // fixture carries a real (statically parseable) zod object schema. + 'src/cli/library/audit.ts': [ + "export const config = { description: 'Audit the library.' };", + 'export const inputSchema = z.object({ strict: z.boolean().optional() }).strict();', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'), 'src/events/workspace/open.tsx': moduleSource, 'src/mcp/curator/apps/dashboard.tsx': `export const config = { resourceUri: 'ui://curator/dashboard.html' }; ${moduleSource}`, 'src/mcp/curator/prompts/curate.tsx': moduleSource, diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 03207cdde..105809059 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -17,6 +17,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/artifact-validator.test.ts', 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', 'packages/agent-bundle/tests/build.test.ts', + 'packages/agent-bundle/tests/cli-routes-build.test.ts', 'packages/agent-bundle/tests/cli.test.ts', 'packages/agent-bundle/tests/dev-artifact-service.test.ts', 'packages/agent-bundle/tests/dev-package-build.test.ts',