From dd866b5e338a040be383f030c0937c52c3ade09d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 19:11:50 +0000 Subject: [PATCH 1/3] feat(cli): compile src/cli/** routes into a routed CLI executable (#102 stage 2) Conventional command routes now compile into one collision-checked command graph on the route-graph IR: path nesting is identity, the static config export supplies description/aliases/positionals/exit-code policy, and a bounded documented zod grammar projects each route's inputSchema onto argv with named AB4814 diagnostics for everything outside it. The graph feeds the existing package-build pipeline as one generated Rslib executable (cli-entry runtime shell aliased in, exactly like mcp-entry); commands run inside the typed Agent request context and keep the one-JSON-line stdout contract. New diagnostics AB4813 (command/alias/bin collisions), AB4814 (argv policy), AB4815 (route contract), AB4816 (rendered commands gated until stage 3). --- .changeset/routed-cli-stage2.md | 19 + docs/diagnostics.md | 41 +- docs/entry-conventions.md | 45 ++ packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 1 + .../agent-bundle/src/build/entry-shell.ts | 85 ++- .../agent-bundle/src/build/package-build.ts | 38 +- packages/agent-bundle/src/cli-entry.ts | 432 ++++++++++++++ packages/agent-bundle/src/config/normalize.ts | 53 +- packages/agent-bundle/src/config/validate.ts | 45 ++ packages/agent-bundle/src/core/types.ts | 7 +- packages/agent-bundle/src/index.ts | 2 +- packages/agent-bundle/src/routes/cli-argv.ts | 514 ++++++++++++++++ .../agent-bundle/src/routes/cli-commands.ts | 320 ++++++++++ packages/agent-bundle/src/routes/contract.ts | 26 +- packages/agent-bundle/src/routes/graph.ts | 16 +- packages/agent-bundle/src/routes/index.ts | 11 +- packages/agent-bundle/src/routes/public.ts | 28 + packages/agent-bundle/src/routes/types.ts | 47 ++ .../tests/cli-routes-build.test.ts | 133 +++++ .../agent-bundle/tests/cli-routes.test.ts | 555 ++++++++++++++++++ .../agent-bundle/tests/route-graph.test.ts | 10 +- rstest.integration-tests.ts | 1 + 23 files changed, 2415 insertions(+), 18 deletions(-) create mode 100644 .changeset/routed-cli-stage2.md create mode 100644 packages/agent-bundle/src/cli-entry.ts create mode 100644 packages/agent-bundle/src/routes/cli-argv.ts create mode 100644 packages/agent-bundle/src/routes/cli-commands.ts create mode 100644 packages/agent-bundle/tests/cli-routes-build.test.ts create mode 100644 packages/agent-bundle/tests/cli-routes.test.ts 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 55f76e5f8..4e21531d6 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 9ad6111a2..c8282a045 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, @@ -1449,6 +1450,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, @@ -1525,6 +1567,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 ad44a900e..a757782fb 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'; export interface AgentBundlePluginConfig { description?: string; @@ -349,6 +349,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 69f0b9fc3..f39fb3d69 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -4,7 +4,7 @@ import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; export { defineConfig, pathTokens, pluginRootEnvAnchor } from './core/types.ts'; -export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts'; +export type { AppRouteConfig, CliRouteConfig, CliRouteProps, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts'; export { compareEvals, runEvals, startDevServer } from './api.ts'; export { createCodexEvalHarness, 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 2c818f02c..2ef0481ce 100644 --- a/packages/agent-bundle/src/routes/contract.ts +++ b/packages/agent-bundle/src/routes/contract.ts @@ -28,12 +28,20 @@ const diagnostic = ( recovery: string, ): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); -/** Validates G8's one executable route contract without evaluating the module. */ -export const validateRouteModuleContract = ( +/** 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; +} + +/** Scans one route module's top-level export surface without evaluating it. */ +export const scanRouteModuleExports = ( moduleText: string, relativePath: string, - sourcePath: string, -): readonly Diagnostic[] => { +): RouteModuleExports => { const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const named = new Set(); let asyncDefault = false; @@ -71,6 +79,16 @@ export const validateRouteModuleContract = ( } } + return Object.freeze({ asyncDefault, named, splitExport }); +}; + +/** Validates G8's one executable route contract without evaluating the module. */ +export const validateRouteModuleContract = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): readonly Diagnostic[] => { + const { asyncDefault, named, splitExport } = scanRouteModuleExports(moduleText, relativePath); const missing = ['inputSchema', 'resultSchema'].filter((name) => !named.has(name)); const diagnostics: Diagnostic[] = []; if (missing.length > 0 || !asyncDefault) { diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 67126061e..5db29a135 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 { validateRouteModuleContract } from './contract.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -530,7 +531,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 0c397ecf3..62419f916 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,5 +24,6 @@ export type { RouteProvenance, } from './types.ts'; export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts'; -export { validateRouteModuleContract } from './contract.ts'; -export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './public.ts'; +export { scanRouteModuleExports, validateRouteModuleContract } from './contract.ts'; +export type { RouteModuleExports } from './contract.ts'; +export type { AppRouteConfig, CliRouteConfig, CliRouteProps, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './public.ts'; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 024991da1..ee6356286 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -38,3 +38,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 0b2b2cffb..9a069060e 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -82,8 +82,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 b11b97f64..4e3b857d1 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/file/saved.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 4a7592001..c7ba91695 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', From 19b5461087c2695194d68f563704fae35256abcf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 20:07:20 +0000 Subject: [PATCH 2/3] feat(cli): render .tsx commands and scripts through the dispatcher (#102 stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendered CLI routes and conventional rendered scripts now execute through the runtime dispatcher's public stream() against a sibling react-server worker, with four output modes: in-place TTY progress, one final piped Markdown document, --json canonical value, and --ndjson sequence-numbered render events (CLI/script dialect, never MCP stdout). Plain .ts keeps ordinary Node semantics. Lifts the AB4807/AB4816 stage gates (retired, not reused), consciously flips the docs-contract pin to the narrowed runRscCli compatibility claim with the routed-CLI replacement pins, and updates framework-mode's power-tier reference. Proof: audiobook-curator's manual CLI operation registry migrates to fifteen src/cli/ routes — fourteen plain commands byte-parity, library-audit rendered. --- .changeset/rendered-cli-docs-pin.md | 9 + .changeset/rendered-cli-stage3.md | 20 + docs/diagnostics.md | 45 ++- docs/entry-conventions.md | 22 +- docs/framework-mode.md | 9 +- .../audiobook-curator/agent-bundle.config.ts | 8 +- examples/audiobook-curator/src/cli-command.ts | 71 +--- examples/audiobook-curator/src/cli.ts | 72 ---- .../src/cli/acoustic-identify.ts | 28 ++ .../src/cli/acoustic-verify.ts | 29 ++ .../src/cli/apply-chapters.ts | 23 ++ .../src/cli/apply-metadata.ts | 29 ++ .../src/cli/audible-cache.ts | 32 ++ .../src/cli/audible-search.ts | 40 ++ .../src/cli/audible-select.ts | 23 ++ examples/audiobook-curator/src/cli/audit.ts | 24 ++ examples/audiobook-curator/src/cli/convert.ts | 36 ++ examples/audiobook-curator/src/cli/inspect.ts | 24 ++ .../audiobook-curator/src/cli/inventory.ts | 24 ++ .../src/cli/library-audit.tsx | 60 +++ examples/audiobook-curator/src/cli/prepare.ts | 32 ++ examples/audiobook-curator/src/cli/select.ts | 21 + .../src/cli/whisper-verify.ts | 31 ++ .../src/operations/audible.ts | 67 +--- .../src/operations/cli-arguments.ts | 72 ---- .../src/operations/discovery.ts | 68 ---- .../src/operations/evidence.ts | 76 ---- .../src/operations/media-mutation.ts | 45 --- .../src/operations/output.ts | 76 ---- .../tests/application.test.ts | 19 +- examples/audiobook-curator/tests/cli.test.ts | 200 ++++++---- .../tests/route-unit/routes.test.ts | 33 ++ packages/agent-bundle/src/build/build.ts | 10 +- packages/agent-bundle/src/build/entries.ts | 89 ++++- .../agent-bundle/src/build/entry-shell.ts | 224 ++++++++++- .../agent-bundle/src/build/package-build.ts | 29 +- packages/agent-bundle/src/cli-entry.ts | 358 +++++++++++++++++- packages/agent-bundle/src/config/normalize.ts | 31 +- .../agent-bundle/src/config/script-routes.ts | 21 +- packages/agent-bundle/src/config/validate.ts | 31 +- packages/agent-bundle/src/core/types.ts | 2 + packages/agent-bundle/src/routes/cli-argv.ts | 9 +- .../agent-bundle/src/routes/cli-commands.ts | 20 +- packages/agent-bundle/src/routes/types.ts | 8 +- packages/agent-bundle/src/test/render.ts | 20 +- packages/agent-bundle/tests/api.test.ts | 4 +- .../tests/cli-routes-build.test.ts | 64 ++++ .../agent-bundle/tests/cli-routes.test.ts | 253 ++++++++++++- .../agent-bundle/tests/normalization.test.ts | 45 ++- .../agent-bundle/tests/route-graph.test.ts | 15 +- packages/rsc-runtime/README.md | 10 +- .../rsc-runtime/tests/docs-contract.test.ts | 22 +- 52 files changed, 1852 insertions(+), 781 deletions(-) create mode 100644 .changeset/rendered-cli-docs-pin.md create mode 100644 .changeset/rendered-cli-stage3.md delete mode 100644 examples/audiobook-curator/src/cli.ts create mode 100644 examples/audiobook-curator/src/cli/acoustic-identify.ts create mode 100644 examples/audiobook-curator/src/cli/acoustic-verify.ts create mode 100644 examples/audiobook-curator/src/cli/apply-chapters.ts create mode 100644 examples/audiobook-curator/src/cli/apply-metadata.ts create mode 100644 examples/audiobook-curator/src/cli/audible-cache.ts create mode 100644 examples/audiobook-curator/src/cli/audible-search.ts create mode 100644 examples/audiobook-curator/src/cli/audible-select.ts create mode 100644 examples/audiobook-curator/src/cli/audit.ts create mode 100644 examples/audiobook-curator/src/cli/convert.ts create mode 100644 examples/audiobook-curator/src/cli/inspect.ts create mode 100644 examples/audiobook-curator/src/cli/inventory.ts create mode 100644 examples/audiobook-curator/src/cli/library-audit.tsx create mode 100644 examples/audiobook-curator/src/cli/prepare.ts create mode 100644 examples/audiobook-curator/src/cli/select.ts create mode 100644 examples/audiobook-curator/src/cli/whisper-verify.ts delete mode 100644 examples/audiobook-curator/src/operations/cli-arguments.ts diff --git a/.changeset/rendered-cli-docs-pin.md b/.changeset/rendered-cli-docs-pin.md new file mode 100644 index 000000000..c65589bd5 --- /dev/null +++ b/.changeset/rendered-cli-docs-pin.md @@ -0,0 +1,9 @@ +--- +"@agent-bundle/runtime": patch +--- + +Narrow the documented CLI claim to the `runRscCli` compatibility path: it +still serializes the validated result as one JSON line and never invokes +`render`, while routed `src/cli/**` `.tsx` commands render through the Agent +renderer's dispatcher (#102 stage 3). Documentation and pin-test wording +only; no runtime behavior changes. diff --git a/.changeset/rendered-cli-stage3.md b/.changeset/rendered-cli-stage3.md new file mode 100644 index 000000000..a59440ed3 --- /dev/null +++ b/.changeset/rendered-cli-stage3.md @@ -0,0 +1,20 @@ +--- +"agent-bundle": minor +--- + +Render `.tsx` CLI commands and scripts through the Agent renderer (#102 +stage 3). A `src/cli/.tsx` route's async default Server Component +now renders through the runtime dispatcher's public stream against a sibling +react-server worker with four output modes: interactive TTY progress updated +in place, exactly one final Markdown document when piped (no partial +fallbacks), `--json` for the canonical validated final value, and `--ndjson` +for the sequence-numbered render-event stream (a CLI/script dialect, never +written to an MCP stdout). Diagnostics stay on stderr; machine output owns +stdout; exit codes stay deterministic (status- or result-policy-derived, +130/143 on signals reaching the route's `AbortSignal`). Conventional +`src/scripts/.tsx` routes ship the same way with `{ argv, signal }` +component props — lifting the stage-1 `AB4807` gate — while plain `.ts` +scripts and commands keep ordinary Node semantics and never enter the +renderer. The stage-2 `AB4816` gate is retired; the route-unit test harness +now passes rendered CLI/script routes the same props the generated +executables do. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a69ca3227..34c18faca 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -149,28 +149,49 @@ last-good file, while a successful route-free preparation removes it. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under `src/scripts/` compiles to `scripts/.mjs` in every selected target -artifact with `provenance.kind: 'conventional'`. Script routes that pipeline -cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions. +artifact with `provenance.kind: 'conventional'`. A rendered module +(`src/scripts/.tsx`/`.jsx`, #102 stage 3) compiles to the same +`scripts/.mjs` plus a sibling `scripts/-flight.mjs` react-server +worker: its async default component receives `{ argv, signal }` and renders +through the Agent renderer with the full CLI output contract (`--json`, +`--ndjson`, interactive TTY progress, piped Markdown); the framework dialect +reserves exactly `--json` and `--ndjson`, every other argument passes +through as `argv`, and the exit code derives from the final document status +(0 on `success`, 1 otherwise). Explicit `scripts` config entries keep +ordinary Node semantics regardless of extension — config always wins, and +only the conventional route contract opts into rendering. Script routes +neither pipeline can ship are hard errors (`AB4808`/`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 +graph (#102 stages 2-3): 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 +the `src/cli.ts` convention for that project. Every 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. +receiving `{ input, signal }`, and runs inside the typed Agent request +context. A plain (`.ts`) command executes directly and writes one canonical +JSON line to stdout. A rendered (`.tsx`) command's async default Server +Component renders through the runtime dispatcher against a sibling +`dist/bin/-flight.mjs` react-server worker with four output +modes: interactive TTY updates progress in place before the final document; +piped output emits exactly one final Markdown document (no partial +fallbacks); `--json` emits the canonical validated final value; `--ndjson` +emits the sequence-numbered render-event stream (an Agent Bundle CLI/script +dialect — never MCP JSON-RPC, never written to an MCP server's stdout). +Diagnostics go to stderr; machine output owns stdout. Exit codes: 0 on +success (or the validated result's integer `exitCode` under +`config.exitCode: 'result'`), 1 on execution/render failure, 2 on usage or +input-validation failure, 130/143 after SIGINT/SIGTERM. `--help`, `--json`, +`--ndjson`, 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.url()` (a string option validated as a URL at run time), `z.enum([...string literals])`, or `z.array()`; chains may add `.optional()`, `.default()`, and `.describe('')`, plus validation-only refinements the @@ -196,7 +217,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. | | `AB4805` | error | A route module exports `config` through a rejected declaration shape (`let`/`var`, destructuring, `export { config }`, a function or class, a missing initializer), or the extracted value is not an object. | | `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position. | -| `AB4807` | error | A conventional `src/scripts/` route is a rendered-script module (`.tsx`/`.jsx`); rendered scripts are not supported yet. Rename it to `.ts`, prefix a path segment with `_` to keep it private, or declare it under `scripts` in config to opt into plain bundling. | +| `AB4807` | retired | The stage-1 rendered-script gate. Rendered script routes ship through the Agent renderer pipeline since #102 stage 3; the code is never reused. | | `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. | | `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. | | `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. | @@ -205,7 +226,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `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. | +| `AB4816` | retired | The stage-2 rendered-command gate. Rendered command routes render through the dispatcher since #102 stage 3; the code is never reused. | ## Development package build (`AB7103`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index fe01f58c1..5411eb04c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -59,8 +59,9 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `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 `_` | +| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | Conventions match `.ts` and `.tsx` files exactly. @@ -107,7 +108,7 @@ 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) +### The routed CLI shell (#102 stages 2-3) A generated-mode `src/cli/**` surface compiles into one framework-generated executable instead of a hand-written `src/cli.ts` dispatcher. A plain command @@ -151,6 +152,21 @@ 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. +A `.tsx` command route swaps the default function for an async default +Server Component with the same `{ input, signal }` props and renders through +the runtime dispatcher's public `stream()` against a sibling +`dist/bin/-flight.mjs` react-server worker (one warm worker per +invocation; raw Flight bytes never reach the terminal). The four output +modes: an interactive TTY updates progress in place and prints the final +document as Markdown; piped output emits exactly one final Markdown document +with no partial fallbacks; `--json` emits the canonical validated final +value; `--ndjson` emits the sequence-numbered render-event stream — an +Agent Bundle CLI/script output dialect, not MCP JSON-RPC, and never written +as non-MCP bytes to an MCP server's stdout. Diagnostics stay on stderr; +machine output owns stdout. Rendered scripts +(`src/scripts/.tsx`) share the same shell and output contract with +`{ argv, signal }` component props and status-derived exit codes. + ### The stdio MCP lifecycle shell An MCP server entry that **default-exports a server factory** is served under diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 391f53ac7..2b7da0459 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -60,6 +60,9 @@ Everything else is power-tier reference: custom/remote server modes and collision recovery are in [Entry conventions](entry-conventions.md); accepted static metadata, generated `.agent-bundle/routes.d.ts`, and diagnostics are in [Diagnostics](diagnostics.md). Handwritten `src/mcp/.ts`, -`defineOperation`, and `createRscMcpServer` remain supported escape hatches. -The handwritten CLI compatibility path still serializes validated results and -never renders JSX; routed CLI rendering belongs to #102 stage 3. +`defineOperation`, and `createRscMcpServer` remain supported escape hatches; +the handwritten `runRscCli` compatibility path still serializes validated +results and never renders JSX. Routed `src/cli/**` commands and +`src/scripts/**` scripts follow one sentence: `.tsx` renders through the +Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` +is plain. diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 4fcedf9bb..4a9aa86b3 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -18,11 +18,9 @@ export default defineConfig({ version: '1.0.0', }, runtime: { node: '22.19.0' }, - // `src/cli.ts` is the package bin by convention; declaring it as a script - // also ships it inside every host artifact. - scripts: { - 'audiobook-curator': './src/cli.ts', - }, + // No `scripts` or `bin` fields needed: the routed `src/cli/` commands + // compile into the package executable (dist/bin/audiobook-curator.js) by + // convention (#102 stages 2-3). // No `skills` field needed: `skills/curate-audiobooks/SKILL.md` is // discovered by convention. targets: ['claude', 'codex'], diff --git a/examples/audiobook-curator/src/cli-command.ts b/examples/audiobook-curator/src/cli-command.ts index 25fd12b1f..e8801b650 100644 --- a/examples/audiobook-curator/src/cli-command.ts +++ b/examples/audiobook-curator/src/cli-command.ts @@ -1,3 +1,12 @@ +/** + * The operation-definition helper behind `src/operations/*.ts`: one shared + * core (`id`, `inputSchema`, `handler`, `resultSchema`) that the generated + * MCP routes and the routed `src/cli/` commands both consume. The manual + * CLI projection (`cli.parse`/`usage`/`exitCode`) and its `runCliCommands` + * dispatcher were retired by the #102 stage-3 migration — the framework + * compiles `src/cli/**` routes into the executable instead. + */ + export interface CliCommandContext { readonly signal: AbortSignal; } @@ -7,30 +16,12 @@ interface Schema { parse(value: unknown): Output; } -type SchemaOutput = Value extends Schema ? Output : never; - -interface CliProjection { - readonly exitCode?: (result: Result) => 0 | 1 | 2; - readonly name: string; - readonly parse: (args: readonly string[]) => Input; - readonly summary: string; - readonly usage: string; -} - -/** - * Exported because the operation factories in `src/operations/` export - * objects of `defineCliCommand(...)` results: declaration emit for the - * package build must be able to name this type from those modules - * (TS4023 otherwise fails `agent-bundle build`'s d.ts generation, even - * though `tsc --noEmit` passes). - */ +/** Exported so consumer declaration emit can name the registry types (#174). */ export interface CliCommandDefinition< InputSchema extends Schema, ResultSchema extends Schema, - ParsedInput, HandlerInput, > { - readonly cli: CliProjection>; readonly handler: (input: HandlerInput, context: CliCommandContext) => unknown; readonly id: string; readonly inputSchema: InputSchema; @@ -40,45 +31,7 @@ export interface CliCommandDefinition< export const defineCliCommand = < InputSchema extends Schema, ResultSchema extends Schema, - ParsedInput, HandlerInput, >( - definition: CliCommandDefinition, -): CliCommandDefinition => Object.freeze(definition); - -interface RuntimeCliCommand { - readonly cli: CliProjection; - readonly handler: (input: unknown, context: CliCommandContext) => unknown; - readonly inputSchema: { parse(value: unknown): unknown }; - readonly resultSchema: { parse(value: unknown): unknown }; -} - -const runtimeCommands = (commands: readonly unknown[]): readonly RuntimeCliCommand[] => - commands as readonly RuntimeCliCommand[]; - -export const runCliCommands = async ( - definitions: readonly unknown[], - argv: readonly string[], - options: { readonly signal?: AbortSignal; readonly write?: (value: string) => void } = {}, -): Promise<0 | 1 | 2> => { - const commands = runtimeCommands(definitions); - const write = options.write ?? ((value: string) => process.stdout.write(value)); - if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { - write(`${commands.map((command) => `${command.cli.usage}\n ${command.cli.summary}`).join('\n')}\n`); - return 0; - } - const command = commands.find((candidate) => candidate.cli.name === argv[0]); - if (command === undefined) throw new Error(`Unknown command: ${argv[0]}`); - if (argv[1] === '--help' || argv[1] === '-h') { - write(`${command.cli.usage}\n${command.cli.summary}\n`); - return 0; - } - const signal = options.signal ?? new AbortController().signal; - signal.throwIfAborted(); - const input = command.inputSchema.parse(command.cli.parse(argv.slice(1))); - const handled = await command.handler(input, { signal }); - signal.throwIfAborted(); - const result = command.resultSchema.parse(handled); - write(`${JSON.stringify(result)}\n`); - return command.cli.exitCode?.(result) ?? 0; -}; + definition: CliCommandDefinition, +): CliCommandDefinition => Object.freeze(definition); diff --git a/examples/audiobook-curator/src/cli.ts b/examples/audiobook-curator/src/cli.ts deleted file mode 100644 index 3e3d4b07f..000000000 --- a/examples/audiobook-curator/src/cli.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - audibleOperations, - defaultAudibleOperations, - type AudibleOperations, -} from './operations/audible.js'; -import { - defaultDiscoveryOperations, - discoveryOperations, - type DiscoveryOperations, -} from './operations/discovery.js'; -import { - defaultEvidenceOperations, - evidenceOperations, - type EvidenceOperations, -} from './operations/evidence.js'; -import { - defaultMediaMutationOperations, - mediaMutationOperations, - type MediaMutationOperations, -} from './operations/media-mutation.js'; -import { - defaultOutputOperations, - outputOperations, - type OutputOperations, -} from './operations/output.js'; -import { runCliCommands } from './cli-command.js'; - -export type CuratorOperations = AudibleOperations - & DiscoveryOperations - & EvidenceOperations - & MediaMutationOperations - & OutputOperations; - -export interface CliOptions { - readonly operations?: CuratorOperations; - readonly signal?: AbortSignal; - readonly write?: (value: string) => void; -} - -export const runCli = ( - argv: readonly string[], - options: CliOptions = {}, -): Promise<0 | 1 | 2> => { - const operations = { - ...defaultAudibleOperations, - ...defaultDiscoveryOperations, - ...defaultEvidenceOperations, - ...defaultMediaMutationOperations, - ...defaultOutputOperations, - ...(options.operations ?? {}), - }; - const commands = Object.values({ - ...evidenceOperations(operations), - ...mediaMutationOperations(operations), - ...audibleOperations(operations), - ...discoveryOperations(operations), - ...outputOperations(operations), - }); - return runCliCommands(commands, argv, { - ...(options.signal === undefined ? {} : { signal: options.signal }), - ...(options.write === undefined ? {} : { write: options.write }), - }); -}; - -export const main = async (argv: readonly string[]): Promise => { - try { - process.exitCode = await runCli(argv); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : 'Audiobook curator failed.'}\n`); - process.exitCode = 1; - } -}; diff --git a/examples/audiobook-curator/src/cli/acoustic-identify.ts b/examples/audiobook-curator/src/cli/acoustic-identify.ts new file mode 100644 index 000000000..45c397c4b --- /dev/null +++ b/examples/audiobook-curator/src/cli/acoustic-identify.ts @@ -0,0 +1,28 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultEvidenceOperations, evidenceOperations } from '../operations/evidence.js'; + +const operation = evidenceOperations(defaultEvidenceOperations).acousticIdentify; + +export const config = { + description: 'Try score-ranked, deduplicated Audible candidates and retain per-candidate evidence.', + exitCode: 'result', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + all: z.boolean().optional(), + attempts: z.number().int().min(1).max(10).optional(), + candidates: z.string().min(1).max(4096), + chunkSeconds: z.number().int().min(1).max(86_400).optional(), + file: z.string().min(1).max(4096), + receipt: z.string().min(1).max(4096), + top: z.number().int().min(1).max(10).optional(), + verbose: z.boolean().optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function acousticIdentify({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/acoustic-verify.ts b/examples/audiobook-curator/src/cli/acoustic-verify.ts new file mode 100644 index 000000000..a8f5116f7 --- /dev/null +++ b/examples/audiobook-curator/src/cli/acoustic-verify.ts @@ -0,0 +1,29 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultEvidenceOperations, evidenceOperations } from '../operations/evidence.js'; + +const operation = evidenceOperations(defaultEvidenceOperations).acousticVerify; + +export const config = { + description: 'Compare one bounded Audible sample with local audio through optional Audiolocate.', + exitCode: 'result', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + asin: z.string().min(1).max(64), + attempts: z.number().int().min(1).max(10).optional(), + audiolocatePython: z.string().min(1).max(4096).optional(), + chunkSeconds: z.number().int().min(1).max(86_400).optional(), + file: z.string().min(1).max(4096), + receipt: z.string().min(1).max(4096), + region: z.enum(['au', 'ca', 'de', 'es', 'fr', 'in', 'it', 'jp', 'uk', 'us']).optional(), + sampleUrl: z.url().optional(), + verbose: z.boolean().optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function acousticVerify({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/apply-chapters.ts b/examples/audiobook-curator/src/cli/apply-chapters.ts new file mode 100644 index 000000000..45594ae98 --- /dev/null +++ b/examples/audiobook-curator/src/cli/apply-chapters.ts @@ -0,0 +1,23 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultMediaMutationOperations, mediaMutationOperations } from '../operations/media-mutation.js'; + +const operation = mediaMutationOperations(defaultMediaMutationOperations).applyChapters; + +export const config = { + description: 'Plan or apply verified generic or Audible chapter rows without changing encoded audio.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + apply: z.boolean().optional(), + chapters: z.string().min(1).max(4096), + file: z.string().min(1).max(4096), + receipt: z.string().min(1).max(4096), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function applyChapters({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/apply-metadata.ts b/examples/audiobook-curator/src/cli/apply-metadata.ts new file mode 100644 index 000000000..fa71ff34d --- /dev/null +++ b/examples/audiobook-curator/src/cli/apply-metadata.ts @@ -0,0 +1,29 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultMediaMutationOperations, mediaMutationOperations } from '../operations/media-mutation.js'; + +const operation = mediaMutationOperations(defaultMediaMutationOperations).applyMetadata; + +export const config = { + description: 'Plan or apply verified Audible metadata and artwork without changing encoded audio.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + apply: z.boolean().optional(), + artwork: z.string().min(1).max(4096).optional(), + author: z.string().max(512).optional(), + file: z.string().min(1).max(4096), + language: z.string().min(1).max(64).optional(), + narrator: z.string().max(512).optional(), + product: z.string().min(1).max(4096), + receipt: z.string().min(1).max(4096), + title: z.string().max(1024).optional(), + year: z.string().max(64).optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function applyMetadata({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/audible-cache.ts b/examples/audiobook-curator/src/cli/audible-cache.ts new file mode 100644 index 000000000..8ab422c30 --- /dev/null +++ b/examples/audiobook-curator/src/cli/audible-cache.ts @@ -0,0 +1,32 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { audibleOperations, defaultAudibleOperations } from '../operations/audible.js'; + +const operation = audibleOperations(defaultAudibleOperations).audibleCache; + +export const config = { + description: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', +} satisfies CliRouteConfig; + +// Schema keys are the CLI surface (`--cache-dir`); the handler maps the key +// onto the operation input's `cacheDirectory`. +export const inputSchema = z.object({ + asin: z.string().min(1).max(64), + attempts: z.number().int().min(1).max(10).optional(), + cacheDir: z.string().min(1).max(4096), + receipt: z.string().min(1).max(4096), + region: z.enum(['au', 'ca', 'de', 'es', 'fr', 'in', 'it', 'jp', 'uk', 'us']).optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function audibleCache({ input, signal }: CliRouteProps) { + return operation.handler({ + asin: input.asin, + ...(input.attempts === undefined ? {} : { attempts: input.attempts }), + cacheDirectory: input.cacheDir, + receipt: input.receipt, + ...(input.region === undefined ? {} : { region: input.region }), + }, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/audible-search.ts b/examples/audiobook-curator/src/cli/audible-search.ts new file mode 100644 index 000000000..f420d50ab --- /dev/null +++ b/examples/audiobook-curator/src/cli/audible-search.ts @@ -0,0 +1,40 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { audibleOperations, audibleRegionList, defaultAudibleOperations } from '../operations/audible.js'; + +const operation = audibleOperations(defaultAudibleOperations).audibleSearch; + +export const config = { + description: 'Search and rank Audible identity candidates across reviewed regions.', + exitCode: 'result', +} satisfies CliRouteConfig; + +// Schema keys are the CLI surface (`--duration`, `--regions LIST`); the +// handler maps them onto the operation input's `durationSeconds` and parsed +// region array, preserving the pre-migration option names byte for byte. +export const inputSchema = z.object({ + attempts: z.number().int().min(1).max(10).optional(), + author: z.string().min(1).max(512).optional(), + duration: z.number().positive().optional(), + limit: z.number().int().min(1).max(50).optional(), + narrator: z.string().min(1).max(512).optional(), + regions: z.string().min(1).max(64).optional(), + report: z.string().min(1).max(4096), + title: z.string().min(1).max(1024), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function audibleSearch({ input, signal }: CliRouteProps) { + return operation.handler({ + ...(input.attempts === undefined ? {} : { attempts: input.attempts }), + ...(input.author === undefined ? {} : { author: input.author }), + ...(input.duration === undefined ? {} : { durationSeconds: input.duration }), + ...(input.limit === undefined ? {} : { limit: input.limit }), + ...(input.narrator === undefined ? {} : { narrator: input.narrator }), + ...(input.regions === undefined ? {} : { regions: audibleRegionList(input.regions) }), + report: input.report, + title: input.title, + }, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/audible-select.ts b/examples/audiobook-curator/src/cli/audible-select.ts new file mode 100644 index 000000000..def576ae7 --- /dev/null +++ b/examples/audiobook-curator/src/cli/audible-select.ts @@ -0,0 +1,23 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { audibleOperations, defaultAudibleOperations } from '../operations/audible.js'; + +const operation = audibleOperations(defaultAudibleOperations).audibleSelect; + +export const config = { + description: 'Record one explicit human-reviewed Audible edition choice.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + candidate: z.number().int().min(1).max(500), + candidates: z.string().min(1).max(4096), + note: z.string().max(4096).optional(), + receipt: z.string().min(1).max(4096), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function audibleSelect({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/audit.ts b/examples/audiobook-curator/src/cli/audit.ts new file mode 100644 index 000000000..b68599bff --- /dev/null +++ b/examples/audiobook-curator/src/cli/audit.ts @@ -0,0 +1,24 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultOutputOperations, outputOperations } from '../operations/output.js'; + +const operation = outputOperations(defaultOutputOperations).audit; + +export const config = { + description: 'Validate metadata, chapters, source mapping, hashes, and optional complete decode.', + exitCode: 'result', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + conversionReceipt: z.string().min(1).max(4096).optional(), + file: z.string().min(1).max(4096), + fullDecode: z.boolean().optional(), + receipt: z.string().min(1).max(4096), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function audit({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/convert.ts b/examples/audiobook-curator/src/cli/convert.ts new file mode 100644 index 000000000..c13e18895 --- /dev/null +++ b/examples/audiobook-curator/src/cli/convert.ts @@ -0,0 +1,36 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultOutputOperations, outputOperations } from '../operations/output.js'; + +const operation = outputOperations(defaultOutputOperations).convert; + +export const config = { + description: 'Plan or apply a verified conversion to one chaptered M4B.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + apply: z.boolean().optional(), + artwork: z.string().min(1).max(4096).optional(), + audioBitrate: z.string().min(2).max(32).optional(), + audioCodec: z.enum(['aac', 'alac']).optional(), + author: z.string().min(1).max(512), + engine: z.enum(['audiobook-forge', 'ffmpeg']).optional(), + forgeAacEncoder: z.string().min(1).max(128).optional(), + forgeCli: z.string().min(1).max(4096).optional(), + jobs: z.number().int().min(0).max(256).optional(), + language: z.string().min(1).max(64).optional(), + narrator: z.string().min(1).max(512).optional(), + output: z.string().min(1).max(4096), + overwrite: z.boolean().optional(), + receipt: z.string().min(1).max(4096), + selection: z.string().min(1).max(4096), + title: z.string().min(1).max(1024), + year: z.string().min(1).max(64).optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function convert({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/inspect.ts b/examples/audiobook-curator/src/cli/inspect.ts new file mode 100644 index 000000000..6f0070f97 --- /dev/null +++ b/examples/audiobook-curator/src/cli/inspect.ts @@ -0,0 +1,24 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).inspect; + +export const config = { + description: 'Inspect a bounded audiobook source tree without changing it.', + positionals: ['root'], +} satisfies CliRouteConfig; + +// The argv projection is compiled statically, so the schema is inline +// literal zod; the bounds match the operation registry's own input schema. +export const inputSchema = z.object({ + maxFiles: z.number().int().min(1).max(256).optional(), + root: z.string().min(1).max(4096), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function inspect({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/inventory.ts b/examples/audiobook-curator/src/cli/inventory.ts new file mode 100644 index 000000000..4bed49fe5 --- /dev/null +++ b/examples/audiobook-curator/src/cli/inventory.ts @@ -0,0 +1,24 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).inventory; + +export const config = { + description: 'Probe source audio without changing it.', + exitCode: 'result', + positionals: ['source'], +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + report: z.string().min(1).max(4096), + source: z.string().min(1).max(4096), + strict: z.boolean().optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function inventory({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/library-audit.tsx b/examples/audiobook-curator/src/cli/library-audit.tsx new file mode 100644 index 000000000..0d3f00e06 --- /dev/null +++ b/examples/audiobook-curator/src/cli/library-audit.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { Agent, agent, type JsonValue } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +import type { LibraryAuditReceipt } from '../library.js'; +import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; + +/** + * The rendered command of this CLI (#102 stage 3): the audit is the + * long-running surface, so an interactive terminal gets in-place progress + * and a piped run gets one Markdown summary document. `--json` keeps the + * canonical receipt (the same value the plain command printed), and the + * receipt's own `exitCode` stays authoritative through the result policy. + */ +export const config = { + description: 'Audit metadata, artwork, chapters, duplicate candidates, and multipart groups.', + exitCode: 'result', + positionals: ['sources'], +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + concurrency: z.number().int().min(1).max(8).optional(), + report: z.string().min(1).max(4096), + sources: z.array(z.string().min(1).max(4096)).min(1).max(64), + strict: z.boolean().optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function LibraryAudit({ input, signal }: CliRouteProps) { + const context = await agent(); + const total = input.sources.length; + await context.progress.report({ completed: 0, message: 'Auditing sources', total }); + const receipt = await operation.handler(input, { signal }) as LibraryAuditReceipt; + await context.progress.report({ completed: total, message: 'Audit complete', total }); + const { summary } = receipt; + const issues = + summary.missingAlbum + summary.missingArtwork + summary.missingAuthor + + summary.missingChapters + summary.missingTitle + summary.probeFailures; + return ( + + + {[ + `## Library audit`, + '', + `Audited **${String(summary.files)}** files (${String(summary.bytes)} bytes) across **${String(total)}** sources.`, + '', + `- metadata issues: **${String(issues)}**`, + `- duplicate candidates: **${String(receipt.duplicateCandidates.length)}**`, + `- multipart candidates: **${String(receipt.multipartCandidates.length)}**`, + '', + receipt.reviewNote, + ].join('\n')} + + + ); +} diff --git a/examples/audiobook-curator/src/cli/prepare.ts b/examples/audiobook-curator/src/cli/prepare.ts new file mode 100644 index 000000000..0902b0b27 --- /dev/null +++ b/examples/audiobook-curator/src/cli/prepare.ts @@ -0,0 +1,32 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultOutputOperations, outputOperations } from '../operations/output.js'; + +const operation = outputOperations(defaultOutputOperations).prepare; + +export const config = { + description: 'Plan an M4B output or apply the plan when explicitly requested.', + positionals: ['source'], +} satisfies CliRouteConfig; + +// Schema keys are the CLI surface (`--output`, `--name`); the handler maps +// them onto the operation input's `outputRoot`/`outputName`, preserving the +// pre-migration option names byte for byte. +export const inputSchema = z.object({ + apply: z.boolean().optional(), + name: z.string().min(5).max(204).optional(), + output: z.string().min(1).max(4096), + source: z.string().min(1).max(4096), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function prepare({ input, signal }: CliRouteProps) { + return operation.handler({ + ...(input.apply === undefined ? {} : { apply: input.apply }), + ...(input.name === undefined ? {} : { outputName: input.name }), + outputRoot: input.output, + source: input.source, + }, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/select.ts b/examples/audiobook-curator/src/cli/select.ts new file mode 100644 index 000000000..73f2dbd0f --- /dev/null +++ b/examples/audiobook-curator/src/cli/select.ts @@ -0,0 +1,21 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultDiscoveryOperations, discoveryOperations } from '../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).select; + +export const config = { + description: 'Choose the strongest source among normalized collisions.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + inventory: z.string().min(1).max(4096), + report: z.string().min(1).max(4096), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function select({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/cli/whisper-verify.ts b/examples/audiobook-curator/src/cli/whisper-verify.ts new file mode 100644 index 000000000..4efff57c2 --- /dev/null +++ b/examples/audiobook-curator/src/cli/whisper-verify.ts @@ -0,0 +1,31 @@ +import type { CliRouteConfig, CliRouteProps } from 'agent-bundle'; +import { z } from 'zod'; + +import { defaultEvidenceOperations, evidenceOperations } from '../operations/evidence.js'; + +const operation = evidenceOperations(defaultEvidenceOperations).whisperVerify; + +export const config = { + description: 'Transcribe distributed audiobook windows for human language and identity review.', + exitCode: 'result', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({ + author: z.string().max(512).optional(), + file: z.string().min(1).max(4096), + language: z.string().min(1).max(64).optional(), + maxWindows: z.number().int().min(5).max(11).optional(), + minimumChars: z.number().int().min(1).max(16_384).optional(), + model: z.string().min(1).max(4096), + receipt: z.string().min(1).max(4096), + threads: z.number().int().min(1).max(256).optional(), + title: z.string().max(1024).optional(), + whisperCli: z.string().min(1).max(4096).optional(), + windowSeconds: z.number().int().min(1).max(3600).optional(), +}).strict(); + +export const resultSchema = operation.resultSchema; + +export default async function whisperVerify({ input, signal }: CliRouteProps) { + return operation.handler(input, { signal }); +} diff --git a/examples/audiobook-curator/src/operations/audible.ts b/examples/audiobook-curator/src/operations/audible.ts index a58bc070e..a9e287b14 100644 --- a/examples/audiobook-curator/src/operations/audible.ts +++ b/examples/audiobook-curator/src/operations/audible.ts @@ -18,15 +18,6 @@ import { type AudibleSelectionReceipt, } from '../audible.ts'; import { readJson, writeReceipt } from '../foundation.ts'; -import { - assertOptions, - numberOption, - optionChoice, - optionValue, - optionalField, - positionalArguments, - requiredOption, -} from './cli-arguments.ts'; import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; export interface AudibleOperations { @@ -70,7 +61,8 @@ export const defaultAudibleOperations: Required = { }, }; -const audibleRegionList = (value: string): readonly AudibleRegion[] => value.split(',').map((region) => { +/** Parses the CLI's comma-separated `--regions` list; shared with the routed `audible-search` command. */ +export const audibleRegionList = (value: string): readonly AudibleRegion[] => value.split(',').map((region) => { const candidate = region.trim().toLowerCase(); if (!audibleRegions.includes(candidate as AudibleRegion)) throw new Error(`Unsupported Audible region: ${candidate}.`); return candidate as AudibleRegion; @@ -78,28 +70,6 @@ const audibleRegionList = (value: string): readonly AudibleRegion[] => value.spl export const audibleOperations = (operations: Required) => ({ audibleSearch: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'audible-search', - parse: (args) => { - const valued = new Set(['--attempts', '--author', '--duration', '--limit', '--narrator', '--regions', '--report', '--title']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audible-search accepts only named options.'); - const regions = optionValue(args, '--regions'); - return { - ...optionalField('attempts', numberOption(args, '--attempts')), - ...optionalField('author', optionValue(args, '--author')), - ...optionalField('durationSeconds', numberOption(args, '--duration')), - ...optionalField('limit', numberOption(args, '--limit')), - ...optionalField('narrator', optionValue(args, '--narrator')), - ...(regions === undefined ? {} : { regions: audibleRegionList(regions) }), - report: requiredOption(args, '--report', 'audible-search'), - title: requiredOption(args, '--title', 'audible-search'), - }; - }, - summary: 'Search and rank Audible identity candidates across reviewed regions.', - usage: 'audible-search --title TITLE --report FILE [--author AUTHOR] [--narrator NARRATOR] [--duration SECONDS] [--regions LIST]', - }, handler: operations.audibleSearch, id: 'audible-search', inputSchema: z.object({ @@ -111,45 +81,12 @@ export const audibleOperations = (operations: Required) => ({ resultSchema: audibleSearchResultSchema, }), audibleSelect: defineCliCommand({ - cli: { - name: 'audible-select', - parse: (args) => { - const valued = new Set(['--candidate', '--candidates', '--note', '--receipt']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audible-select accepts only named options.'); - return { - candidate: Number(requiredOption(args, '--candidate', 'audible-select')), - candidates: requiredOption(args, '--candidates', 'audible-select'), - ...optionalField('note', optionValue(args, '--note')), - receipt: requiredOption(args, '--receipt', 'audible-select'), - }; - }, - summary: 'Record one explicit human-reviewed Audible edition choice.', - usage: 'audible-select --candidates FILE --candidate N --receipt FILE [--note NOTE]', - }, handler: operations.audibleSelect, id: 'audible-select', inputSchema: z.object({ candidate: z.number().int().min(1).max(500), candidates: pathSchema, note: z.string().max(4096).optional(), receipt: pathSchema.optional() }).strict(), resultSchema: audibleSelectResultSchema, }), audibleCache: defineCliCommand({ - cli: { - name: 'audible-cache', - parse: (args) => { - const valued = new Set(['--asin', '--attempts', '--cache-dir', '--receipt', '--region']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audible-cache accepts only named options.'); - return { - asin: requiredOption(args, '--asin', 'audible-cache'), - ...optionalField('attempts', numberOption(args, '--attempts')), - cacheDirectory: requiredOption(args, '--cache-dir', 'audible-cache'), - receipt: requiredOption(args, '--receipt', 'audible-cache'), - ...optionalField('region', optionChoice(args, '--region', audibleRegions)), - }; - }, - summary: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', - usage: 'audible-cache --asin ASIN --region REGION --cache-dir DIR --receipt FILE', - }, handler: operations.audibleCache, id: 'audible-cache', inputSchema: z.object({ diff --git a/examples/audiobook-curator/src/operations/cli-arguments.ts b/examples/audiobook-curator/src/operations/cli-arguments.ts deleted file mode 100644 index bdfa3fac5..000000000 --- a/examples/audiobook-curator/src/operations/cli-arguments.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Shared argv toolkit for every operation's `cli.parse` projection. The - * framework's CLI contract is a bare `(argv) => input` function, so option - * lookup, flag validation, and positional handling live here once instead of - * being repeated in each command. - */ - -export const optionValue = (args: readonly string[], option: string): string | undefined => { - const index = args.indexOf(option); - if (index === -1) return undefined; - const value = args[index + 1]; - if (value === undefined || value.startsWith('--')) throw new Error(`${option} requires a value.`); - return value; -}; - -export const assertOptions = (args: readonly string[], flags: ReadonlySet, valued: ReadonlySet): void => { - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]!; - if (!argument.startsWith('--')) continue; - if (flags.has(argument)) continue; - if (valued.has(argument)) { - index += 1; - if (args[index] === undefined || args[index]!.startsWith('--')) throw new Error(`${argument} requires a value.`); - continue; - } - throw new Error(`Unknown option: ${argument}`); - } -}; - -export const positionalArguments = (args: readonly string[], valued: ReadonlySet): readonly string[] => { - const positional: string[] = []; - for (let index = 0; index < args.length; index += 1) { - if (valued.has(args[index]!)) { - index += 1; - } else if (!args[index]!.startsWith('--')) { - positional.push(args[index]!); - } - } - return positional; -}; - -export const onePath = (args: readonly string[], valued: ReadonlySet, command: string): string => { - const positional = positionalArguments(args, valued); - if (positional.length !== 1) throw new Error(`${command} requires exactly one path.`); - return positional[0]!; -}; - -export const requiredOption = (args: readonly string[], option: string, command: string): string => { - const value = optionValue(args, option); - if (value === undefined) throw new Error(`${command} requires ${option}.`); - return value; -}; - -export const optionChoice = ( - args: readonly string[], - option: string, - choices: readonly T[], -): T | undefined => { - const value = optionValue(args, option); - if (value === undefined) return undefined; - if (!choices.includes(value as T)) throw new Error(`${option} must be one of: ${choices.join(', ')}.`); - return value as T; -}; - -export const numberOption = (args: readonly string[], option: string): number | undefined => { - const value = optionValue(args, option); - return value === undefined ? undefined : Number(value); -}; - -/** Spread helper that omits the key entirely when the option is absent, so parsed inputs never carry explicit `undefined` entries. */ -export const optionalField = (key: K, value: V | undefined): Readonly>> => - (value === undefined ? {} : { [key]: value }) as Partial>; diff --git a/examples/audiobook-curator/src/operations/discovery.ts b/examples/audiobook-curator/src/operations/discovery.ts index 0b267480d..891cefc6f 100644 --- a/examples/audiobook-curator/src/operations/discovery.ts +++ b/examples/audiobook-curator/src/operations/discovery.ts @@ -16,14 +16,6 @@ import { type LibraryAuditReceipt, type SelectionReceipt, } from '../library.ts'; -import { - assertOptions, - numberOption, - onePath, - optionalField, - positionalArguments, - requiredOption, -} from './cli-arguments.ts'; import { parityReceiptSchema, pathSchema, probeShape } from './schemas.ts'; export interface DiscoveryOperations { @@ -87,64 +79,18 @@ export const defaultDiscoveryOperations: Required = { export const discoveryOperations = (operations: Required) => ({ inspect: defineCliCommand({ - cli: { - name: 'inspect', - parse: (args) => { - const valued = new Set(['--max-files']); - assertOptions(args, new Set(), valued); - return { - ...optionalField('maxFiles', numberOption(args, '--max-files')), - root: onePath(args, valued, 'inspect'), - }; - }, - summary: 'Inspect a bounded audiobook source tree without changing it.', - usage: 'inspect [--max-files N] ', - }, handler: operations.inspect, id: 'inspect', inputSchema: inspectInputSchema, resultSchema: inspectResultSchema, }), inventory: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'inventory', - parse: (args) => { - const valued = new Set(['--report']); - assertOptions(args, new Set(['--strict']), valued); - return { - report: requiredOption(args, '--report', 'inventory'), - source: onePath(args, valued, 'inventory'), - ...(args.includes('--strict') ? { strict: true } : {}), - }; - }, - summary: 'Probe source audio without changing it.', - usage: 'inventory --report FILE [--strict]', - }, handler: operations.inventory, id: 'inventory', inputSchema: z.object({ report: pathSchema.optional(), source: pathSchema, strict: z.boolean().optional() }).strict(), resultSchema: inventoryResultSchema, }), libraryAudit: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'library-audit', - parse: (args) => { - const valued = new Set(['--concurrency', '--report']); - assertOptions(args, new Set(['--strict']), valued); - const sources = positionalArguments(args, valued); - if (sources.length === 0) throw new Error('library-audit requires at least one source path.'); - return { - ...optionalField('concurrency', numberOption(args, '--concurrency')), - report: requiredOption(args, '--report', 'library-audit'), - sources, - ...(args.includes('--strict') ? { strict: true } : {}), - }; - }, - summary: 'Audit metadata, artwork, chapters, duplicate candidates, and multipart groups.', - usage: 'library-audit --report FILE [--concurrency N] [--strict]', - }, handler: operations.libraryAudit, id: 'library-audit', inputSchema: z.object({ @@ -156,20 +102,6 @@ export const discoveryOperations = (operations: Required) = resultSchema: libraryResultSchema, }), select: defineCliCommand({ - cli: { - name: 'select', - parse: (args) => { - const valued = new Set(['--inventory', '--report']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('select accepts only named options.'); - return { - inventory: requiredOption(args, '--inventory', 'select'), - report: requiredOption(args, '--report', 'select'), - }; - }, - summary: 'Choose the strongest source among normalized collisions.', - usage: 'select --inventory FILE --report FILE', - }, handler: operations.select, id: 'select', inputSchema: z.object({ inventory: pathSchema, report: pathSchema.optional() }).strict(), diff --git a/examples/audiobook-curator/src/operations/evidence.ts b/examples/audiobook-curator/src/operations/evidence.ts index f185e389f..be88cd45c 100644 --- a/examples/audiobook-curator/src/operations/evidence.ts +++ b/examples/audiobook-curator/src/operations/evidence.ts @@ -16,15 +16,6 @@ import { type WhisperReceipt, } from '../evidence.ts'; import { readJson } from '../foundation.ts'; -import { - assertOptions, - numberOption, - optionChoice, - optionValue, - optionalField, - positionalArguments, - requiredOption, -} from './cli-arguments.ts'; import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; export interface EvidenceOperations { @@ -56,28 +47,6 @@ const whisperResultSchema = parityReceiptSchema('whisper-identit export const evidenceOperations = (operations: Required) => ({ acousticVerify: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'acoustic-verify', - parse: (args) => { - const valued = new Set(['--asin', '--attempts', '--audiolocate-python', '--chunk-seconds', '--file', '--receipt', '--region', '--sample-url']); - assertOptions(args, new Set(['--verbose']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-verify accepts only named options.'); - return { - asin: requiredOption(args, '--asin', 'acoustic-verify'), - ...optionalField('attempts', numberOption(args, '--attempts')), - ...optionalField('audiolocatePython', optionValue(args, '--audiolocate-python')), - ...optionalField('chunkSeconds', numberOption(args, '--chunk-seconds')), - file: requiredOption(args, '--file', 'acoustic-verify'), - receipt: requiredOption(args, '--receipt', 'acoustic-verify'), - ...optionalField('region', optionChoice(args, '--region', audibleRegions)), - ...optionalField('sampleUrl', optionValue(args, '--sample-url')), - ...(args.includes('--verbose') ? { verbose: true } : {}), - }; - }, - summary: 'Compare one bounded Audible sample with local audio through optional Audiolocate.', - usage: 'acoustic-verify --file FILE --asin ASIN --region REGION --receipt FILE [--audiolocate-python PATH]', - }, handler: operations.acousticVerify, id: 'acoustic-verify', inputSchema: z.object({ @@ -88,27 +57,6 @@ export const evidenceOperations = (operations: Required) => resultSchema: acousticResultSchema, }), acousticIdentify: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'acoustic-identify', - parse: (args) => { - const valued = new Set(['--attempts', '--candidates', '--chunk-seconds', '--file', '--receipt', '--top']); - assertOptions(args, new Set(['--all', '--verbose']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('acoustic-identify accepts only named options.'); - return { - ...(args.includes('--all') ? { all: true } : {}), - ...optionalField('attempts', numberOption(args, '--attempts')), - candidates: requiredOption(args, '--candidates', 'acoustic-identify'), - ...optionalField('chunkSeconds', numberOption(args, '--chunk-seconds')), - file: requiredOption(args, '--file', 'acoustic-identify'), - receipt: requiredOption(args, '--receipt', 'acoustic-identify'), - ...optionalField('top', numberOption(args, '--top')), - ...(args.includes('--verbose') ? { verbose: true } : {}), - }; - }, - summary: 'Try score-ranked, deduplicated Audible candidates and retain per-candidate evidence.', - usage: 'acoustic-identify --file FILE --candidates FILE --receipt FILE [--top N] [--all]', - }, handler: operations.acousticIdentify, id: 'acoustic-identify', inputSchema: z.object({ @@ -119,30 +67,6 @@ export const evidenceOperations = (operations: Required) => resultSchema: acousticIdentifyResultSchema, }), whisperVerify: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'whisper-verify', - parse: (args) => { - const valued = new Set(['--author', '--file', '--language', '--max-windows', '--minimum-chars', '--model', '--receipt', '--threads', '--title', '--whisper-cli', '--window-seconds']); - assertOptions(args, new Set(), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('whisper-verify accepts only named options.'); - return { - ...optionalField('author', optionValue(args, '--author')), - file: requiredOption(args, '--file', 'whisper-verify'), - ...optionalField('language', optionValue(args, '--language')), - ...optionalField('maxWindows', numberOption(args, '--max-windows')), - ...optionalField('minimumChars', numberOption(args, '--minimum-chars')), - model: requiredOption(args, '--model', 'whisper-verify'), - receipt: requiredOption(args, '--receipt', 'whisper-verify'), - ...optionalField('threads', numberOption(args, '--threads')), - ...optionalField('title', optionValue(args, '--title')), - ...optionalField('whisperCli', optionValue(args, '--whisper-cli')), - ...optionalField('windowSeconds', numberOption(args, '--window-seconds')), - }; - }, - summary: 'Transcribe distributed audiobook windows for human language and identity review.', - usage: 'whisper-verify --file FILE --model FILE --receipt FILE [--language CODE] [--max-windows N]', - }, handler: operations.whisperVerify, id: 'whisper-verify', inputSchema: z.object({ diff --git a/examples/audiobook-curator/src/operations/media-mutation.ts b/examples/audiobook-curator/src/operations/media-mutation.ts index de70604cf..3e3b7c364 100644 --- a/examples/audiobook-curator/src/operations/media-mutation.ts +++ b/examples/audiobook-curator/src/operations/media-mutation.ts @@ -13,13 +13,6 @@ import { type MetadataInput, type MetadataReceipt, } from '../media-mutation.ts'; -import { - assertOptions, - optionValue, - optionalField, - positionalArguments, - requiredOption, -} from './cli-arguments.ts'; import { parityReceiptSchema, pathSchema } from './schemas.ts'; export interface MediaMutationOperations { @@ -37,28 +30,6 @@ const chaptersResultSchema = parityReceiptSchema('apply-chapters export const mediaMutationOperations = (operations: Required) => ({ applyMetadata: defineCliCommand({ - cli: { - name: 'apply-metadata', - parse: (args) => { - const valued = new Set(['--artwork', '--author', '--file', '--language', '--narrator', '--product', '--receipt', '--title', '--year']); - assertOptions(args, new Set(['--apply']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('apply-metadata accepts only named options.'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - ...optionalField('artwork', optionValue(args, '--artwork')), - ...optionalField('author', optionValue(args, '--author')), - file: requiredOption(args, '--file', 'apply-metadata'), - ...optionalField('language', optionValue(args, '--language')), - ...optionalField('narrator', optionValue(args, '--narrator')), - product: requiredOption(args, '--product', 'apply-metadata'), - receipt: requiredOption(args, '--receipt', 'apply-metadata'), - ...optionalField('title', optionValue(args, '--title')), - ...optionalField('year', optionValue(args, '--year')), - }; - }, - summary: 'Plan or apply verified Audible metadata and artwork without changing encoded audio.', - usage: 'apply-metadata --file FILE --product FILE --receipt FILE [--artwork FILE] [--language CODE] [--apply]', - }, handler: operations.applyMetadata, id: 'apply-metadata', inputSchema: z.object({ @@ -69,22 +40,6 @@ export const mediaMutationOperations = (operations: Required { - const valued = new Set(['--chapters', '--file', '--receipt']); - assertOptions(args, new Set(['--apply']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('apply-chapters accepts only named options.'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - chapters: requiredOption(args, '--chapters', 'apply-chapters'), - file: requiredOption(args, '--file', 'apply-chapters'), - receipt: requiredOption(args, '--receipt', 'apply-chapters'), - }; - }, - summary: 'Plan or apply verified generic or Audible chapter rows without changing encoded audio.', - usage: 'apply-chapters --file FILE --chapters FILE --receipt FILE [--apply]', - }, handler: operations.applyChapters, id: 'apply-chapters', inputSchema: z.object({ apply: z.boolean().optional(), chapters: pathSchema, file: pathSchema, receipt: pathSchema.optional() }).strict(), diff --git a/examples/audiobook-curator/src/operations/output.ts b/examples/audiobook-curator/src/operations/output.ts index 39fe049df..9ba126bba 100644 --- a/examples/audiobook-curator/src/operations/output.ts +++ b/examples/audiobook-curator/src/operations/output.ts @@ -14,16 +14,6 @@ import { type IntegrityAuditInput, type IntegrityAuditReceipt, } from '../integrity-audit.ts'; -import { - assertOptions, - numberOption, - onePath, - optionChoice, - optionValue, - optionalField, - positionalArguments, - requiredOption, -} from './cli-arguments.ts'; import { parityReceiptSchema, pathSchema, probeSchema } from './schemas.ts'; export interface OutputOperations { @@ -56,38 +46,6 @@ const prepareResultSchema = z.object({ export const outputOperations = (operations: Required) => ({ convert: defineCliCommand({ - cli: { - name: 'convert', - parse: (args) => { - const valued = new Set([ - '--artwork', '--audio-bitrate', '--audio-codec', '--author', '--engine', '--forge-aac-encoder', - '--forge-cli', '--jobs', '--language', '--narrator', '--output', '--receipt', '--selection', '--title', '--year', - ]); - assertOptions(args, new Set(['--apply', '--overwrite']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('convert accepts only named options.'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - ...(args.includes('--overwrite') ? { overwrite: true } : {}), - ...optionalField('artwork', optionValue(args, '--artwork')), - ...optionalField('audioBitrate', optionValue(args, '--audio-bitrate')), - ...optionalField('audioCodec', optionChoice(args, '--audio-codec', ['aac', 'alac'] as const)), - author: requiredOption(args, '--author', 'convert'), - ...optionalField('engine', optionChoice(args, '--engine', ['audiobook-forge', 'ffmpeg'] as const)), - ...optionalField('forgeAacEncoder', optionValue(args, '--forge-aac-encoder')), - ...optionalField('forgeCli', optionValue(args, '--forge-cli')), - ...optionalField('jobs', numberOption(args, '--jobs')), - ...optionalField('language', optionValue(args, '--language')), - ...optionalField('narrator', optionValue(args, '--narrator')), - output: requiredOption(args, '--output', 'convert'), - receipt: requiredOption(args, '--receipt', 'convert'), - selection: requiredOption(args, '--selection', 'convert'), - title: requiredOption(args, '--title', 'convert'), - ...optionalField('year', optionValue(args, '--year')), - }; - }, - summary: 'Plan or apply a verified conversion to one chaptered M4B.', - usage: 'convert --selection FILE --output PATH --receipt FILE --title TITLE --author AUTHOR [--apply] [--overwrite]', - }, handler: operations.convert, id: 'convert', inputSchema: z.object({ @@ -101,46 +59,12 @@ export const outputOperations = (operations: Required) => ({ resultSchema: convertResultSchema, }), prepare: defineCliCommand({ - cli: { - name: 'prepare', - parse: (args) => { - const valued = new Set(['--name', '--output']); - assertOptions(args, new Set(['--apply']), valued); - const outputRoot = optionValue(args, '--output'); - if (outputRoot === undefined) throw new Error('prepare requires --output.'); - return { - ...(args.includes('--apply') ? { apply: true } : {}), - ...optionalField('outputName', optionValue(args, '--name')), - outputRoot, - source: onePath(args, valued, 'prepare'), - }; - }, - summary: 'Plan an M4B output or apply the plan when explicitly requested.', - usage: 'prepare [--apply] [--name FILE] --output DIR ', - }, handler: operations.prepare, id: 'prepare', inputSchema: prepareInputSchema, resultSchema: prepareResultSchema, }), audit: defineCliCommand({ - cli: { - exitCode: (receipt) => receipt.exitCode, - name: 'audit', - parse: (args) => { - const valued = new Set(['--conversion-receipt', '--file', '--receipt']); - assertOptions(args, new Set(['--full-decode']), valued); - if (positionalArguments(args, valued).length > 0) throw new Error('audit accepts only named options.'); - return { - ...optionalField('conversionReceipt', optionValue(args, '--conversion-receipt')), - file: requiredOption(args, '--file', 'audit'), - ...(args.includes('--full-decode') ? { fullDecode: true } : {}), - receipt: requiredOption(args, '--receipt', 'audit'), - }; - }, - summary: 'Validate metadata, chapters, source mapping, hashes, and optional complete decode.', - usage: 'audit --file FILE --receipt FILE [--conversion-receipt FILE] [--full-decode]', - }, handler: operations.audit, id: 'audit', inputSchema: z.object({ conversionReceipt: pathSchema.optional(), file: pathSchema, fullDecode: z.boolean().optional(), receipt: pathSchema.optional() }).strict(), diff --git a/examples/audiobook-curator/tests/application.test.ts b/examples/audiobook-curator/tests/application.test.ts index ae2e0a29c..b2368ba2e 100644 --- a/examples/audiobook-curator/tests/application.test.ts +++ b/examples/audiobook-curator/tests/application.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from '@rstest/core'; import maybeFactoryConfig from '../agent-bundle.config.ts'; import { compileRouteGraph } from 'agent-bundle/api'; -import { runCli } from '../src/cli.js'; if (typeof maybeFactoryConfig === 'function') throw new Error('expected a static config object'); const config = maybeFactoryConfig; @@ -30,7 +29,10 @@ describe('audiobook curator filesystem application', () => { it('derives the complete MCP server from route modules and no server config', async () => { expect(config.targets).toEqual(['claude', 'codex']); expect(config.mcp).toBeUndefined(); - expect(Object.keys(config.scripts ?? {})).toEqual(['audiobook-curator']); + // The manual CLI dispatcher and its explicit `scripts` shipping are + // retired (#102 stage 3); the routed src/cli/ commands feed the package + // executable instead. + expect(config.scripts).toBeUndefined(); expect(config.skills).toBeUndefined(); const graph = await compileRouteGraph(root, config); @@ -42,10 +44,13 @@ describe('audiobook curator filesystem application', () => { expect(graph.servers[0]!.routes.filter((route) => route.kind === 'prompt').map((route) => route.id)).toEqual(['prompt:curator/curate']); }); - it('keeps the handwritten CLI compatibility path non-rendering through stage 3', async () => { - const output: string[] = []; - await expect(runCli(['--help'], { write: (value) => output.push(value) })).resolves.toBe(0); - expect(output.join('')).toContain('inspect [--max-files N] '); - expect(output.join('')).toContain('prepare [--apply] [--name FILE] --output DIR '); + it('derives the complete routed CLI from src/cli/ route modules and no cli config', async () => { + const graph = await compileRouteGraph(root, config); + expect(graph.cli).toMatchObject({ mode: 'generated' }); + expect(graph.cli!.commands).toHaveLength(15); + // Exactly one command renders through the dispatcher; the other + // fourteen keep the plain one-JSON-line contract byte for byte. + expect(graph.cli!.commands!.filter((command) => command.rendered).map((command) => command.path.join(' '))) + .toEqual(['library-audit']); }); }); diff --git a/examples/audiobook-curator/tests/cli.test.ts b/examples/audiobook-curator/tests/cli.test.ts index da946cebe..b31082da9 100644 --- a/examples/audiobook-curator/tests/cli.test.ts +++ b/examples/audiobook-curator/tests/cli.test.ts @@ -1,83 +1,133 @@ -import { describe, expect, it } from '@rstest/core'; - -import { runCli, type CuratorOperations } from '../src/cli.js'; - -const operations = (): CuratorOperations => ({ - audit: async (input) => ({ - audioSha256: 'b'.repeat(64), bytes: 12, chapterIssues: [], chapters: [], exitCode: 0, file: input.file, - fullDecode: input.fullDecode === true ? 'verified' : 'not-requested', generatedAt: '2026-08-26T00:00:00.000Z', - mutation: false, operation: 'audit', probe: { bytes: 12, chapters: 0, codec: 'aac', durationSeconds: 12, extension: '.m4b', path: input.file, relativePath: 'book.m4b', sampleRate: 44_100, tags: {} }, - sha256: 'a'.repeat(64), sourceChapterMapping: { issues: [], status: 'not-requested' }, status: 'verified', - }), - inspect: async (input) => ({ files: [], operation: 'inspect', root: input.root, totalBytes: 0 }), - prepare: async (input) => ({ - applied: input.apply ?? false, - operation: 'prepare', - output: `${input.outputRoot}/book.m4b`, - probe: { codec: 'mp3', durationSeconds: 12, format: 'mp3', tags: {} }, - source: input.source, - }), +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import maybeFactoryConfig from '../agent-bundle.config.ts'; +import { compileRouteGraph } from 'agent-bundle/api'; +import inspectRoute, { inputSchema as inspectInput, resultSchema as inspectResult } from '../src/cli/inspect.ts'; + +if (typeof maybeFactoryConfig === 'function') throw new Error('expected a static config object'); +const config = maybeFactoryConfig; +const root = new URL('..', import.meta.url).pathname; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); }); -describe('audiobook-curator CLI', () => { - it('enables application only through the typed flag', async () => { - let applied = false; - const fixture = operations(); - await runCli(['prepare', '/source/book.mp3', '--output', '/curated', '--apply'], { - operations: { ...fixture, prepare: async (input, options) => { - applied = input.apply === true; - return fixture.prepare(input, options); - } }, - write: () => undefined, - }); - expect(applied).toBe(true); - }); +/** + * The routed-CLI migration pins (#102 stages 2-3): the compiled command + * graph carries exactly the pre-migration argv surface — the same command + * names, option spellings, positionals, and exit-code policies the manual + * `runCli` dispatcher served — and plain routes still emit the identical + * one-line JSON receipts. + */ +describe('audiobook-curator routed CLI', () => { + it('compiles the fifteen migrated commands with the pre-migration argv surface', async () => { + const graph = await compileRouteGraph(root, config); + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.mode).toBe('generated'); + const commands = graph.cli!.commands!; + const byName = new Map(commands.map((command) => [command.path.join(' '), command])); - it('rejects unknown commands and flags', async () => { - await expect(runCli(['unknown', '/library'], { operations: operations(), write: () => undefined })) - .rejects.toThrow('Unknown command'); - await expect(runCli(['audit', '/library', '--overwrite'], { operations: operations(), write: () => undefined })) - .rejects.toThrow('Unknown option'); - }); + expect([...byName.keys()].sort()).toEqual([ + 'acoustic-identify', + 'acoustic-verify', + 'apply-chapters', + 'apply-metadata', + 'audible-cache', + 'audible-search', + 'audible-select', + 'audit', + 'convert', + 'inspect', + 'inventory', + 'library-audit', + 'prepare', + 'select', + 'whisper-verify', + ]); + + // inspect [--max-files N] + const inspect = byName.get('inspect')!; + expect(inspect).toMatchObject({ exitCode: 'zero', rendered: false }); + expect(inspect.options).toEqual([ + { key: 'maxFiles', kind: 'number', option: 'max-files', repeated: false, required: false }, + { key: 'root', kind: 'string', option: 'root', positional: 0, repeated: false, required: true }, + ]); + + // inventory --report FILE [--strict] + const inventory = byName.get('inventory')!; + expect(inventory).toMatchObject({ exitCode: 'result', rendered: false }); + expect(inventory.options.map((option) => [option.option, option.required, option.positional ?? null])).toEqual([ + ['report', true, null], + ['source', true, 0], + ['strict', false, null], + ]); + + // library-audit --report FILE [--concurrency N] [--strict] — the rendered command. + const libraryAudit = byName.get('library-audit')!; + expect(libraryAudit).toMatchObject({ exitCode: 'result', rendered: true }); + expect(libraryAudit.options.map((option) => [option.option, option.repeated, option.positional ?? null])).toEqual([ + ['concurrency', false, null], + ['report', false, null], + ['sources', true, 0], + ['strict', false, null], + ]); + + // convert keeps its full named-option surface, including kebab-case + // projections of camelCase keys (--audio-bitrate, --forge-aac-encoder). + const convert = byName.get('convert')!; + expect(convert.options.map((option) => option.option).sort()).toEqual([ + 'apply', 'artwork', 'audio-bitrate', 'audio-codec', 'author', 'engine', + 'forge-aac-encoder', 'forge-cli', 'jobs', 'language', 'narrator', + 'output', 'overwrite', 'receipt', 'selection', 'title', 'year', + ]); + expect(convert.options.filter((option) => option.required).map((option) => option.option)).toEqual([ + 'author', 'output', 'receipt', 'selection', 'title', + ]); + + // prepare [--apply] [--name FILE] --output DIR — the handler + // maps --output/--name onto the operation's outputRoot/outputName. + const prepare = byName.get('prepare')!; + expect(prepare.options.map((option) => [option.option, option.required, option.positional ?? null])).toEqual([ + ['apply', false, null], + ['name', false, null], + ['output', true, null], + ['source', true, 0], + ]); + + // audible-search keeps --duration and the comma-separated --regions list. + const audibleSearch = byName.get('audible-search')!; + expect(audibleSearch.options.map((option) => option.option)).toEqual([ + 'attempts', 'author', 'duration', 'limit', 'narrator', 'regions', 'report', 'title', + ]); + expect(audibleSearch).toMatchObject({ exitCode: 'result' }); + + // audible-cache keeps --cache-dir. + expect(byName.get('audible-cache')!.options.some((option) => option.option === 'cache-dir')).toBe(true); - it('does not invoke a command when cancellation was already requested', async () => { - const controller = new AbortController(); - controller.abort(); - let invoked = false; - const output: string[] = []; - - await expect(runCli(['inspect', '/library'], { - operations: { - ...operations(), - inspect: async (input) => { - invoked = true; - return { files: [], operation: 'inspect', root: input.root, totalBytes: 0 }; - }, - }, - signal: controller.signal, - write: (value) => output.push(value), - })).rejects.toThrow('aborted'); - - expect(invoked).toBe(false); - expect(output).toEqual([]); + // The result exit-code policy rides exactly the commands that declared it. + expect(commands.filter((command) => command.exitCode === 'result').map((command) => command.path.join(' ')).sort()).toEqual([ + 'acoustic-identify', 'acoustic-verify', 'audible-search', 'audit', 'inventory', 'library-audit', 'whisper-verify', + ]); }); - it('does not emit a result when cancellation is requested during a command', async () => { - const controller = new AbortController(); - const output: string[] = []; - - await expect(runCli(['inspect', '/library'], { - operations: { - ...operations(), - inspect: async (input) => { - controller.abort(); - return { files: [], operation: 'inspect', root: input.root, totalBytes: 0 }; - }, - }, - signal: controller.signal, - write: (value) => output.push(value), - })).rejects.toThrow('aborted'); - - expect(output).toEqual([]); + it('keeps the plain inspect receipt byte-identical to the pre-migration CLI output', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-cli-inspect-')); + directories.push(directory); + const input = inspectInput.parse({ root: directory }); + const result = inspectResult.parse(await inspectRoute({ input, signal: new AbortController().signal })); + // The generated shell prints exactly JSON.stringify(result) + '\n', the + // same line `runCliCommands` wrote before the migration. + expect(JSON.stringify(result)).toBe(JSON.stringify({ + files: [], + operation: 'inspect', + root: directory, + totalBytes: 0, + })); }); }); diff --git a/examples/audiobook-curator/tests/route-unit/routes.test.ts b/examples/audiobook-curator/tests/route-unit/routes.test.ts index 67fd15c58..4812eef5b 100644 --- a/examples/audiobook-curator/tests/route-unit/routes.test.ts +++ b/examples/audiobook-curator/tests/route-unit/routes.test.ts @@ -1,3 +1,7 @@ +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { expect, it } from '@rstest/core'; import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; @@ -33,3 +37,32 @@ it('renders the curation prompt route into a final Agent Document', async () => }); expect(rendered.provenance).toMatchObject({ proofLevel: 'route-unit', routeId: 'prompt:curator/curate' }); }); + +it('renders the library-audit CLI route with in-flight progress and the canonical receipt (#102 stage 3)', async () => { + const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-audit-')); + try { + const sources = join(directory, 'library'); + const report = join(directory, 'report.json'); + await mkdir(sources, { recursive: true }); + const rendered = await renderRoute('cli:library-audit', { + input: { concurrency: 1, report, sources: [sources] }, + }); + + expectDocument(rendered).toHaveStatus('success').toContainMarkdown('Library audit'); + const receipt = rendered.document.value as { + readonly exitCode: number; + readonly operation: string; + readonly summary: { readonly files: number }; + }; + expect(receipt.operation).toBe('library-audit'); + expect(receipt.exitCode).toBe(0); + expect(receipt.summary.files).toBe(0); + // The component reported request-scoped progress around the audit. + expect(rendered.progress.map((update) => update.completed)).toEqual([0, 1]); + // The receipt landed in the requested report file, exactly like the + // pre-migration plain command. + expect(JSON.parse(await readFile(report, 'utf8'))).toMatchObject({ operation: 'library-audit' }); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 844958abb..3f15dbab8 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -190,7 +190,7 @@ const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[ ...target.entries.map((entry) => resolveArtifactDestination(target.root, entry.relativePath), ), - ...target.compiledEntries.map((entry) => entry.output), + ...target.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ...target.compiledHooks.map((entry) => entry.output), ...target.compiledMcpApps.map((entry) => entry.output), ...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), @@ -223,11 +223,15 @@ const outputCandidatesFor = (options: { path: resolveArtifactDestination(target.root, entry.relativePath), sourceInputs: entry.sourceInputs, }))), - ...options.compiledEntries.map((entry) => ({ + ...options.compiledEntries.flatMap((entry) => [{ kind: entry.outputKind, path: entry.output, sourceInputs: entry.sourceInputs, - })), + }, ...(entry.workerOutput === undefined ? [] : [{ + kind: 'bundle' as const, + path: entry.workerOutput, + sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, + }])]), ...options.compiledHooks.map((entry) => ({ kind: 'bundle' as const, path: entry.output, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 3b8116189..39705b442 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -8,7 +8,11 @@ import { stableJson } from '../core/digest.ts'; import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; import { + cliEntryRuntimePath, + cliEntryRuntimeSpecifier, generatedExecutableEntrySource, + generatedRenderedRouteWorkerSource, + generatedRenderedScriptEntrySource, generatedRouteArtifactEpoch, generatedRouteFlightWorkerSource, generatedRouteMcpEntrySource, @@ -16,6 +20,7 @@ import { mcpEntryRuntimePath, mcpEntryRuntimeSpecifier, } from './entry-shell.ts'; +import { emptyRouteConfig } from '../routes/types.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; import type { ArtifactOutputKind } from './provenance.ts'; import { buildWithRslib } from './rslib.ts'; @@ -26,10 +31,18 @@ export interface CompiledEntry { readonly outputKind: ArtifactOutputKind; readonly source: string; readonly sourceInputs: readonly string[]; + /** The sibling react-server Flight worker of a rendered script (#102 stage 3). */ + readonly workerOutput?: string; + readonly workerSourceInputs?: readonly string[]; } interface PlannedScriptEntry extends CompiledEntry { readonly mode: NormalizedScript['mode']; + /** The conventional rendered-script route this entry renders (#102 stage 3). */ + readonly rendered?: { + readonly routeId: string; + readonly workerFile: string; + }; } export interface CompiledHookEntry extends CompiledEntry { @@ -63,6 +76,7 @@ export const planCompiledEntries = ( throw new Error(`Duplicate compiled script destination ${JSON.stringify(`scripts/${filename}`)}.`); } names.add(filename); + const workerFile = `${script.name}-flight.mjs`; return { mode: script.mode, name: script.name, @@ -71,6 +85,12 @@ export const planCompiledEntries = ( filename, ), outputKind: script.mode === 'copy' ? 'copy' as const : 'bundle' as const, + ...(script.rendered === true + ? { + rendered: { routeId: script.id, workerFile }, + workerOutput: resolveArtifactDestination(resolve(options.outDir, 'scripts'), workerFile), + } + : {}), source: script.source, sourceInputs: Object.freeze([...new Set([script.provenance.sourcePath, script.source])]), }; @@ -83,23 +103,67 @@ export const compileEntries = async ( ): Promise => { const compiled = planCompiledEntries(entries, options); const bundled = compiled.filter((entry) => entry.mode === 'bundle'); + const cliRuntimeShell = bundled.some((entry) => entry.rendered !== undefined) + ? cliEntryRuntimePath() + : undefined; const evidence = await buildWithRslib({ cwd: options.cwd, - entries: await Promise.all(bundled.map(async ({ name, source, sourceInputs }) => { + entries: await Promise.all(bundled.flatMap((entry) => { + const { name, rendered, source, sourceInputs } = entry; + if (rendered !== undefined) { + // A rendered script route (#102 stage 3): the entry projects the + // dispatcher's render-event stream onto the CLI output contract and + // a sibling react-server worker executes the component. + return [ + Promise.resolve({ + aliases: { [cliEntryRuntimeSpecifier]: cliRuntimeShell! }, + name, + outputRelativePath: `scripts/${name}.mjs`, + rscManifest: true as const, + source, + sourceInputs, + virtualSource: generatedRenderedScriptEntrySource({ + name, + routeId: rendered.routeId, + workerFile: rendered.workerFile, + }), + }), + Promise.resolve({ + name: `${name}-flight`, + outputRelativePath: `scripts/${rendered.workerFile}`, + reactServer: true as const, + rscManifest: true as const, + source, + sourceInputs, + virtualSource: generatedRenderedRouteWorkerSource({ + routes: [{ + config: emptyRouteConfig, + id: rendered.routeId, + kind: 'script', + provenance: { kind: 'conventional', relativePath: `scripts/${name}` }, + source, + }], + }), + }), + ]; + } // A Script whose module exports `main` receives the framework process // envelope (argv, numeric exit codes); self-executing modules keep // today's direct-bundle behavior byte for byte. - const exports = await scanEntryExports(source); - return { - name, - outputRelativePath: `scripts/${name}.mjs`, - source, - sourceInputs, - ...(exports.hasMainExport - ? { virtualSource: generatedExecutableEntrySource({ entrySource: source, exportName: 'main' }) } - : {}), - }; + return [(async () => { + const exports = await scanEntryExports(source); + return { + name, + outputRelativePath: `scripts/${name}.mjs`, + source, + sourceInputs, + ...(exports.hasMainExport + ? { virtualSource: generatedExecutableEntrySource({ entrySource: source, exportName: 'main' }) } + : {}), + }; + })()]; })), + ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [cliRuntimeShell] }), outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }); @@ -122,6 +186,9 @@ export const compileEntries = async ( sourceInputs: entry.mode === 'bundle' ? evidenceByPath.get(`scripts/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled script evidence for ${JSON.stringify(entry.name)}.`); })() : entry.sourceInputs, + ...(entry.rendered === undefined ? {} : { + workerSourceInputs: evidenceByPath.get(`scripts/${entry.rendered.workerFile}`) ?? (() => { throw new Error(`Missing bundled script worker evidence for ${JSON.stringify(entry.name)}.`); })(), + }), }))); }; diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 560680d23..bb67150c3 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -95,22 +95,89 @@ export interface GeneratedCliBinEntryOptions { readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; readonly routes: readonly CompiledAgentRoute[]; + /** The sibling react-server worker bundle; required when any command is rendered. */ + readonly workerFile?: string; } /** - * 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. + * The worker-backed render-session factory shared by generated CLI + * executables and rendered scripts: one worker per rendered invocation, raw + * Flight bytes streamed chunk by chunk into the runtime dispatcher's public + * `stream()`, progress messages forwarded to the dispatcher's reporter, and + * worker stdout guarded onto stderr (machine output owns stdout). + */ +const renderedSessionSource = (workerFile: string): readonly string[] => [ + 'const openRenderedSession = ({ invocation, props, request, routeId, signal, validate }) => {', + ` const worker = new Worker(new URL(${JSON.stringify(`./${workerFile}`)}, import.meta.url), { stderr: true, stdout: true });`, + " worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));", + " worker.stderr?.on('data', (chunk) => process.stderr.write(chunk));", + ' const pending = new Map();', + ' let sequence = 0;', + ' const failPending = (error) => { for (const entry of [...pending.values()]) entry.fail(error); pending.clear(); };', + " worker.on('error', failPending);", + " worker.on('exit', (code) => { if (code !== 0) failPending(new Error(`Generated render worker exited with code ${String(code)}.`)); });", + " worker.on('message', (message) => {", + ' const entry = pending.get(message.id);', + ' if (entry === undefined) return;', + " if (message.type === 'progress') { void entry.progress?.report(message.update); return; }", + " if (message.type === 'chunk') { entry.enqueue(message.bytes); return; }", + ' pending.delete(message.id);', + " entry.signal.removeEventListener('abort', entry.abort);", + " if (message.type === 'error') { entry.fail(new Error(message.message)); return; }", + " if (message.type === 'end') entry.close();", + ' });', + ' const host = Object.freeze({', + ' execute: async (dispatch) => {', + ' const id = ++sequence;', + ' let streamController;', + ' const stream = new ReadableStream({ start(controller) { streamController = controller; } });', + ' const entry = {', + " abort: () => { worker.postMessage({ id, type: 'cancel' }); pending.delete(id); try { streamController.error(new DOMException('Agent render was aborted', 'AbortError')); } catch {} },", + ' close: () => { try { streamController.close(); } catch {} },', + ' enqueue: (bytes) => { try { streamController.enqueue(bytes); } catch {} },', + ' fail: (error) => { pending.delete(id); try { streamController.error(error); } catch {} },', + ' progress: dispatch.progress,', + ' signal: dispatch.signal,', + ' };', + ' pending.set(id, entry);', + " dispatch.signal.addEventListener('abort', entry.abort, { once: true });", + ' if (dispatch.signal.aborted) { entry.abort(); return stream; }', + " worker.postMessage({ id, props, request, routeId, type: 'render' });", + ' return stream;', + ' },', + ' });', + ' const dispatcher = createAgentRenderDispatcher(host);', + ' return Object.freeze({', + ' close: async () => { await worker.terminate(); },', + ' events: () => dispatcher.stream({ invocation, signal }),', + ' validate,', + ' });', + '};', +]; + +/** + * The generated routed-CLI executable (#102 stages 2-3): the compiled + * command graph rides the bundle as data, the cli-entry shell owns argv + * parsing, help, output modes, exit codes, and signals, plain commands + * execute inside the typed Agent request context, and rendered commands + * render through the runtime dispatcher against a sibling react-server + * worker. 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)); + const rendered = options.commands.some((command) => command.rendered); + if (rendered && options.workerFile === undefined) { + throw new Error('A generated CLI with rendered commands requires a worker file.'); + } return [ `import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, - "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + rendered + ? "import { available, createAgentRenderDispatcher, runAgentRequest, unavailable } from '@agent-bundle/runtime';" + : "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), ...routeImports(commandRoutes), '', 'const routes = Object.freeze({', @@ -120,15 +187,18 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '', `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;', + 'const parseInput = (route, input) => {', ' try {', - ' parsed = route.module.inputSchema.parse(input);', + ' return route.module.inputSchema.parse(input);', ' } catch (error) {', ' throw new CliInputError(error instanceof Error ? error.message : String(error));', ' }', + '};', + '', + '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.');", + ' const parsed = parseInput(route, input);', ' const cwd = process.cwd();', ' const result = await runAgentRequest({', ' capabilities: {', @@ -145,17 +215,147 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ' return route.module.resultSchema.parse(result);', '};', '', + ...(rendered + ? [ + ...renderedSessionSource(options.workerFile!), + '', + 'const render = (command, input, context) => {', + ' const route = routes[command.routeId];', + ' const parsed = parseInput(route, input);', + ' return openRenderedSession({', + " invocation: { kind: 'cli', props: { args: context.args, command: command.path.join(' ') } },", + ' props: { input: parsed },', + " request: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },", + ' routeId: command.routeId,', + ' signal: context.signal,', + ' validate: (value) => route.module.resultSchema.parse(value),', + ' });', + '};', + '', + ] + : []), 'await runGeneratedCliProcess({', ' commands,', ...(options.plugin.description === undefined ? [] : [` description: ${JSON.stringify(options.plugin.description)},`]), ' execute,', ` name: ${JSON.stringify(options.plugin.name)},`, + ...(rendered ? [' render,'] : []), ` version: ${JSON.stringify(options.plugin.version)},`, '});', '', ].join('\n'); }; +export interface GeneratedRenderedRouteWorkerOptions { + readonly routes: readonly CompiledAgentRoute[]; +} + +/** + * The react-server worker behind generated CLI executables and rendered + * scripts: renders one route's async default component through Flight, + * streaming raw bytes back chunk by chunk, with progress reports and the + * typed Agent request context installed around every render. + */ +export const generatedRenderedRouteWorkerSource = ( + options: GeneratedRenderedRouteWorkerOptions, +): string => [ + "import { parentPort } from 'node:worker_threads';", + "import { createElement } from 'react';", + "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", + "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...routeImports(options.routes), + '', + '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', + 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', + "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", + '// Machine output owns the parent stdout; anything a route logs goes to stderr.', + 'process.stdout.write = process.stderr.write.bind(process.stderr);', + 'const routes = Object.freeze({', + ...options.routes.map((route, index) => + ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), + '});', + 'const requests = new Map();', + '', + 'const render = async (message) => {', + ' const route = routes[message.routeId];', + " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated rendered route must default-export an async function component.');", + ' const controller = new AbortController();', + ' requests.set(message.id, controller);', + ' try {', + ' const cwd = process.cwd();', + ' await runAgentRequest({', + ' capabilities: {', + ' command: unavailable(),', + ' filesystem: unavailable(),', + ' network: unavailable(),', + " projectRoot: available({ root: cwd }, 'derived'),", + ' },', + " host: unavailable('unsupported-surface'),", + ' invocation: message.request,', + " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", + ' signal: controller.signal,', + " workspace: available({ root: cwd }, 'derived'),", + ' }, async () => {', + ' const flight = renderAgentFlight(createElement(route.module.default, { ...message.props, signal: controller.signal }), { signal: controller.signal });', + ' const reader = flight.getReader();', + ' while (true) {', + ' const next = await reader.read();', + ' if (next.done) break;', + ' const bytes = next.value;', + " parentPort.postMessage({ bytes, id: message.id, type: 'chunk' }, [bytes.buffer]);", + ' }', + ' });', + " parentPort.postMessage({ id: message.id, type: 'end' });", + ' } catch (error) {', + " parentPort.postMessage({ id: message.id, message: error instanceof Error ? error.message : String(error), type: 'error' });", + ' } finally {', + ' requests.delete(message.id);', + ' }', + '};', + '', + "parentPort.on('message', (message) => {", + " if (message.type === 'cancel') { requests.get(message.id)?.abort(); return; }", + " if (message.type === 'render') void render(message);", + '});', + '', +].join('\n'); + +export interface GeneratedRenderedScriptEntryOptions { + readonly name: string; + readonly routeId: string; + readonly workerFile: string; +} + +/** + * The generated rendered-script executable (`src/scripts/.tsx`, + * #102 stage 3): the script's async default component renders through the + * runtime dispatcher with the full CLI output contract (`--json`, + * `--ndjson`, interactive TTY progress, piped Markdown); every other + * argument passes through as the component's `argv` prop. + */ +export const generatedRenderedScriptEntrySource = ( + options: GeneratedRenderedScriptEntryOptions, +): string => [ + `import { runGeneratedRenderedScriptProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + "import { createAgentRenderDispatcher } from '@agent-bundle/runtime';", + "import { Worker } from 'node:worker_threads';", + '', + ...renderedSessionSource(options.workerFile), + '', + 'await runGeneratedRenderedScriptProcess({', + ' createSession: (argv, context) => openRenderedSession({', + ` invocation: { kind: 'script', props: { input: argv, name: ${JSON.stringify(options.name)} } },`, + ' props: { argv },', + ` request: { kind: 'script', operationId: ${JSON.stringify(options.routeId)}, surface: ${JSON.stringify(options.name)} },`, + ` routeId: ${JSON.stringify(options.routeId)},`, + ' signal: context.signal,', + ' validate: (value) => value,', + ' }),', + ` name: ${JSON.stringify(options.name)},`, + '});', + '', +].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 c4a8ca014..6e7047d45 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -11,6 +11,7 @@ import { cliEntryRuntimeSpecifier, generatedCliBinEntrySource, generatedExecutableEntrySource, + generatedRenderedRouteWorkerSource, } from './entry-shell.ts'; import { buildWithRslib, type RslibEntry } from './rslib.ts'; @@ -105,17 +106,22 @@ export const planPackageEntries = async ( // 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. + // Rendered commands add one sibling react-server Flight worker. + const rendered = bin.generatedCli.commands.some((command) => command.rendered); + const workerFile = `${bin.name}-flight.mjs`; + const sourceInputs = Object.freeze([...new Set([ + bin.provenance.sourcePath, + ...bin.generatedCli.routes.map((route) => route.source), + ])]); entries.push({ aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, banner: binShebang, executable: true, name: `bin-${bin.name}`, outputRelativePath: `bin/${bin.name}.js`, + ...(rendered ? { rscManifest: true as const } : {}), source: bin.source, - sourceInputs: Object.freeze([...new Set([ - bin.provenance.sourcePath, - ...bin.generatedCli.routes.map((route) => route.source), - ])]), + sourceInputs, virtualSource: generatedCliBinEntrySource({ commands: bin.generatedCli.commands, plugin: { @@ -124,8 +130,23 @@ export const planPackageEntries = async ( version: model.metadata.version, }, routes: bin.generatedCli.routes, + ...(rendered ? { workerFile } : {}), }), }); + if (rendered) { + const renderedRoutes = bin.generatedCli.routes.filter((route) => + bin.generatedCli!.commands.some((command) => command.rendered && command.routeId === route.id)); + entries.push({ + executable: false, + name: `bin-${bin.name}-flight`, + outputRelativePath: `bin/${workerFile}`, + reactServer: true, + rscManifest: true, + source: bin.source, + sourceInputs, + virtualSource: generatedRenderedRouteWorkerSource({ routes: renderedRoutes }), + }); + } continue; } // A bin entry exporting `main` (or a default function) receives the diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index 0d0cfb1e0..ca7524cd9 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -16,6 +16,57 @@ import type { CompiledCliCommand, CompiledCliOption } from './routes/types.ts'; * plain-object harnesses. */ +/** + * Structural mirrors of the runtime package's versioned Agent Document and + * render-event contracts (`AGENT_DOCUMENT_VERSION` 1). The generated + * executable feeds real runtime values through these shapes; keeping them + * structural means this shell never imports `@agent-bundle/runtime` — the + * generated bundle resolves the runtime from the consumer project instead. + */ +export type CliRenderedDocumentNode = + | { readonly children: readonly CliRenderedDocumentNode[]; readonly kind: 'result'; readonly metadata?: unknown } + | { readonly kind: 'markdown'; readonly text: string } + | { readonly kind: 'text'; readonly text: string } + | { readonly kind: 'context'; readonly text: string } + | { readonly kind: 'json'; readonly value: unknown } + | { readonly completed: number; readonly kind: 'progress'; readonly message?: string; readonly total?: number } + | { readonly data: string; readonly kind: 'image'; readonly mimeType: string } + | { readonly data: string; readonly kind: 'audio'; readonly mimeType: string } + | { readonly kind: 'resource'; readonly mimeType?: string; readonly name: string; readonly uri: string } + | { readonly code: string; readonly kind: 'error'; readonly message: string }; + +export interface CliRenderedDocument { + readonly root: CliRenderedDocumentNode; + readonly status: 'failed' | 'represented-error' | 'success'; + readonly value?: unknown; + readonly version: number; +} + +export type CliRenderedEvent = + | { readonly document: CliRenderedDocument; readonly sequence: number; readonly type: 'shell' } + | { readonly completed: number; readonly message?: string; readonly sequence: number; readonly total?: number; readonly type: 'progress' } + | { readonly boundaryId: string; readonly document: CliRenderedDocument; readonly sequence: number; readonly type: 'replace' } + | { readonly boundaryId?: string; readonly error: { readonly code: string; readonly message: string }; readonly sequence: number; readonly type: 'error' } + | { readonly document: CliRenderedDocument; readonly sequence: number; readonly type: 'complete' }; + +/** + * The four output modes of one rendered invocation: interactive `tty` + * updates progress in place before the final document; piped `markdown` + * emits exactly one final document with no partial fallbacks; `json` emits + * the canonical validated final value; `ndjson` emits the sequence-numbered + * render-event stream (an Agent Bundle CLI/script dialect — never MCP + * JSON-RPC, never written to an MCP server's stdout). + */ +export type CliOutputMode = 'json' | 'markdown' | 'ndjson' | 'tty'; + +/** One rendered run: a live render-event stream plus its validation and teardown. */ +export interface GeneratedCliRenderSession { + readonly close: () => Promise; + readonly events: () => ReadableStream; + /** Validates the complete document's value (the route's `resultSchema.parse`). */ + readonly validate: (value: unknown) => unknown; +} + /** Raised for argv-shape failures: unknown commands or options, missing or malformed values. */ export class CliUsageError extends Error { constructor(message: string) { @@ -42,17 +93,31 @@ export interface GeneratedCliExecuteContext { readonly signal: AbortSignal; } +export interface GeneratedCliRenderContext { + /** The raw argv the command consumed, for the dispatch invocation's `args`. */ + readonly args: readonly string[]; + 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. */ + /** Runs one resolved plain command with parsed input; returns the validated result. */ readonly execute: ( command: CompiledCliCommand, input: Readonly>, context: GeneratedCliExecuteContext, ) => Promise; + /** True when stdout is an interactive terminal; rendered commands then update progress in place. */ + readonly isTty?: () => boolean; readonly name: string; + /** Opens one rendered run for a resolved `.tsx` command with parsed input. */ + readonly render?: ( + command: CompiledCliCommand, + input: Readonly>, + context: GeneratedCliRenderContext, + ) => GeneratedCliRenderSession; readonly signal?: AbortSignal; readonly version: string; readonly writeErr?: (text: string) => void; @@ -119,6 +184,13 @@ const globalOptionRows: readonly (readonly [string, string])[] = [ [' --version', 'Print the version.'], ]; +const renderedOptionRows: readonly (readonly [string, string])[] = [ + ['-h, --help', 'Show help.'], + [' --json', 'Emit the canonical JSON result.'], + [' --ndjson', 'Emit the sequence-numbered render-event stream.'], + [' --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(' ')}`}`; @@ -148,7 +220,7 @@ const commandHelp = (name: string, command: CompiledCliCommand): string => { ...(option.defaultValue === undefined ? [] : [`[default: ${JSON.stringify(option.defaultValue)}]`]), ].filter((part) => part !== '').join(' '), ]); - lines.push('', 'Options:', helpColumns([...optionRows, ...globalOptionRows])); + lines.push('', 'Options:', helpColumns([...optionRows, ...(command.rendered ? renderedOptionRows : globalOptionRows)])); return `${lines.join('\n')}\n`; }; @@ -182,6 +254,7 @@ const treeHelp = ( interface ParsedArgv { readonly input: Readonly>; readonly json: boolean; + readonly ndjson: boolean; } const coerceValue = (option: CompiledCliOption, value: string): unknown => { @@ -242,6 +315,7 @@ const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): const values = new Map(); const bare: string[] = []; let json = false; + let ndjson = false; let index = 0; const readOption = (raw: string): void => { const separator = raw.indexOf('='); @@ -251,6 +325,10 @@ const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): json = true; return; } + if (name === 'ndjson' && inline === undefined) { + ndjson = true; + return; + } const option = options.get(name); if (option === undefined) throw new CliUsageError(`Unknown option: --${name}.`); if (option.kind === 'boolean') { @@ -317,11 +395,12 @@ const parseCommandArgv = (command: CompiledCliCommand, argv: readonly string[]): throw new CliUsageError(`Missing required option: --${option.option}.`); } } - return { input: Object.fromEntries(values), json }; + if (json && ndjson) throw new CliUsageError('Use either --json or --ndjson, not both.'); + return { input: Object.fromEntries(values), json, ndjson }; }; -const resultExitCode = (command: CompiledCliCommand, result: unknown): number => { - if (command.exitCode === 'zero') return 0; +const resultExitCode = (policy: 'result' | 'zero', result: unknown): number => { + if (policy === 'zero') return 0; const exitCode = typeof result === 'object' && result !== null ? (result as Record)['exitCode'] : undefined; @@ -331,6 +410,147 @@ const resultExitCode = (command: CompiledCliCommand, result: unknown): number => return exitCode; }; +const markdownBlocks = (node: CliRenderedDocumentNode): readonly string[] => { + switch (node.kind) { + case 'result': + return node.children.flatMap(markdownBlocks); + case 'markdown': + case 'text': + return [node.text]; + case 'context': + return [node.text.split('\n').map((line) => `> ${line}`).join('\n')]; + case 'json': + return [`\`\`\`json\n${JSON.stringify(node.value, null, 2)}\n\`\`\``]; + case 'progress': + // Transient by contract: partial fallbacks never reach final Markdown. + return []; + case 'image': + return [`![image](data:${node.mimeType};base64,${node.data})`]; + case 'audio': + return [`[audio](data:${node.mimeType};base64,${node.data})`]; + case 'resource': + return [`[${node.name}](${node.uri})`]; + case 'error': + return [`**[${node.code}]** ${node.message}`]; + default: { + const unreachable: never = node; + throw new TypeError(`Unsupported Agent Document node ${String((unreachable as { kind?: string }).kind)}.`); + } + } +}; + +/** Projects one final Agent Document onto stable Markdown (the piped and TTY final output). */ +export const projectCliDocumentToMarkdown = (document: CliRenderedDocument): string => { + const blocks = markdownBlocks(document.root).filter((block) => block.trim() !== ''); + return blocks.length === 0 ? '' : `${blocks.join('\n\n')}\n`; +}; + +const progressLine = (event: { readonly completed: number; readonly message?: string; readonly total?: number }): string => { + const counter = event.total === undefined ? String(event.completed) : `${String(event.completed)}/${String(event.total)}`; + return event.message === undefined ? counter : `${event.message} (${counter})`; +}; + +const clearProgressLine = '\r\u001B[2K'; + +interface RenderedRunOptions { + /** Exit-code policy of the invocation: a routed command's policy, or `zero` for rendered scripts. */ + readonly exitCode: 'result' | 'zero'; + readonly mode: CliOutputMode; + readonly session: GeneratedCliRenderSession; + readonly signal: AbortSignal; + readonly writeErr: (text: string) => void; + readonly writeOut: (text: string) => void; +} + +/** + * Drives one rendered run through its output mode: machine output on stdout, + * diagnostics on stderr, deterministic exit codes (0 success or the `result` + * policy's `exitCode`, 1 render/contract failure). + */ +const runRenderedInvocation = async (options: RenderedRunOptions): Promise => { + const { mode, writeErr, writeOut } = options; + const reader = options.session.events().getReader(); + let complete: CliRenderedDocument | undefined; + let progressShown = false; + const clearProgress = (): void => { + if (progressShown) { + writeOut(clearProgressLine); + progressShown = false; + } + }; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + const event = next.value; + if (mode === 'ndjson') { + writeOut(`${JSON.stringify(event)}\n`); + } + switch (event.type) { + case 'shell': + case 'replace': + break; + case 'progress': + if (mode === 'tty') { + writeOut(`${clearProgressLine}${progressLine(event)}`); + progressShown = true; + } + break; + case 'error': + if (mode !== 'ndjson') { + clearProgress(); + writeErr(`[${event.error.code}] ${event.error.message}\n`); + } + break; + case 'complete': + complete = event.document; + break; + default: { + const unreachable: never = event; + throw new TypeError(`Unsupported render event ${String((unreachable as { type?: string }).type)}.`); + } + } + } + } catch (error) { + clearProgress(); + if (options.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) { + writeErr('Aborted.\n'); + return 1; + } + writeErr(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + clearProgress(); + if (complete === undefined) { + writeErr('The render ended without a complete document.\n'); + return 1; + } + let value: unknown = complete.value; + try { + value = options.session.validate(value); + } catch (error) { + writeErr(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + switch (mode) { + case 'json': + writeOut(`${JSON.stringify(value ?? null)}\n`); + break; + case 'ndjson': + break; + case 'tty': + case 'markdown': + writeOut(projectCliDocumentToMarkdown(complete)); + break; + default: { + const unreachable: never = mode; + throw new TypeError(`Unsupported output mode ${String(unreachable)}.`); + } + } + if (complete.status !== 'success') return 1; + return resultExitCode(options.exitCode, value); +}; + /** * Runs one routed-CLI invocation to completion and returns the process exit * code. Help and machine output go through `writeOut`; diagnostics through @@ -388,10 +608,36 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro } const parsed = parseCommandArgv(command, rest); signal.throwIfAborted(); + if (command.rendered) { + if (options.render === undefined) { + throw new Error(`Rendered command ${command.path.join(' ')} has no render host.`); + } + const mode: CliOutputMode = parsed.ndjson + ? 'ndjson' + : parsed.json + ? 'json' + : (options.isTty ?? (() => process.stdout.isTTY === true))() + ? 'tty' + : 'markdown'; + const session = options.render(command, parsed.input, { args: rest, signal }); + try { + return await runRenderedInvocation({ + exitCode: command.exitCode, + mode, + session, + signal, + writeErr, + writeOut, + }); + } finally { + await session.close(); + } + } + if (parsed.ndjson) throw new CliUsageError('--ndjson requires a rendered command.'); const result = await options.execute(command, parsed.input, { json: parsed.json, signal }); signal.throwIfAborted(); writeOut(`${JSON.stringify(result)}\n`); - return resultExitCode(command, result); + return resultExitCode(command.exitCode, result); } catch (error) { if (signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) { writeErr('Aborted.\n'); @@ -407,26 +653,108 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro } }; +export interface RunGeneratedRenderedScriptOptions { + readonly argv: readonly string[]; + /** Opens one rendered run for the script with the mode flags removed from argv. */ + readonly createSession: ( + argv: readonly string[], + context: { readonly signal: AbortSignal }, + ) => GeneratedCliRenderSession; + readonly isTty?: () => boolean; + readonly name: string; + readonly signal?: AbortSignal; + readonly writeErr?: (text: string) => void; + readonly writeOut?: (text: string) => void; +} + /** - * 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}. + * Runs one rendered script (`src/scripts/.tsx`) to completion and + * returns the process exit code. The framework dialect reserves exactly + * `--json` and `--ndjson` (before a `--` terminator); every other argument + * passes through to the script component's `argv` prop untouched. Exit codes + * derive from the final document status: 0 on `success`, 1 otherwise. */ -export const runGeneratedCliProcess = async ( - options: Omit, -): Promise => { +export const runGeneratedRenderedScript = async ( + options: RunGeneratedRenderedScriptOptions, +): 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 terminator = options.argv.indexOf('--'); + const visible = terminator === -1 ? options.argv : options.argv.slice(0, terminator); + const json = visible.includes('--json'); + const ndjson = visible.includes('--ndjson'); + if (json && ndjson) { + writeErr('Use either --json or --ndjson, not both.\n'); + return 2; + } + const argv = options.argv.filter((argument, index) => + (terminator !== -1 && index > terminator) || (argument !== '--json' && argument !== '--ndjson')); + const mode: CliOutputMode = ndjson + ? 'ndjson' + : json + ? 'json' + : (options.isTty ?? (() => process.stdout.isTTY === true))() + ? 'tty' + : 'markdown'; + const session = options.createSession(argv, { signal }); + try { + return await runRenderedInvocation({ + exitCode: 'zero', + mode, + session, + signal, + writeErr, + writeOut, + }); + } finally { + await session.close(); + } +}; + +interface ProcessSignalWiring { + readonly exitCode: () => number | undefined; + readonly signal: AbortSignal; +} + +const wireProcessSignals = (): ProcessSignalWiring => { 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')); + controller.abort(new DOMException('The process received a termination signal', 'AbortError')); }; process.once('SIGINT', () => onSignal(130)); process.once('SIGTERM', () => onSignal(143)); + return { exitCode: () => signalExitCode, signal: controller.signal }; +}; + +/** + * 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 wiring = wireProcessSignals(); const code = await runGeneratedCliEntry({ ...options, argv: process.argv.slice(2), - signal: controller.signal, + signal: wiring.signal, + }); + process.exitCode = wiring.exitCode() ?? code; +}; + +/** The generated rendered-script envelope, mirroring {@link runGeneratedCliProcess}. */ +export const runGeneratedRenderedScriptProcess = async ( + options: Omit, +): Promise => { + const wiring = wireProcessSignals(); + const code = await runGeneratedRenderedScript({ + ...options, + argv: process.argv.slice(2), + signal: wiring.signal, }); - process.exitCode = signalExitCode ?? code; + process.exitCode = wiring.exitCode() ?? code; }; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 3d3a8e920..b2c6f0e47 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -669,21 +669,26 @@ const normalizeScripts = ( }; }); // Conventional `src/scripts/` routes ship through the same pipeline as - // explicit entries (#102 stage 1). The judgment is shared with source - // validation: routes the pipeline cannot ship (rendered, nested, or - // conflicting with a configured name) are AB4807-AB4809 errors there, so - // omitting them here is deterministic hygiene, never a silent choice. + // explicit entries (#102 stage 1); rendered (`.tsx`/`.jsx`) routes ship + // through the Agent renderer pipeline (#102 stage 3). The judgment is + // shared with source validation: routes neither pipeline can ship (nested, + // or conflicting with a configured name) are AB4808/AB4809 errors there, + // so omitting them here is deterministic hygiene, never a silent choice. const configured = configuredScriptNames(loaded.config); const conventional = (discovered.routeGraph?.scripts ?? []) - .filter((route) => judgeScriptRoute(route, configured) === 'shippable') - .map((route): NormalizedScript => ({ - id: route.id, - mode: scriptMode(route.source), - name: scriptRouteName(route), - provenance: { kind: 'conventional', sourcePath: route.source }, - source: route.source, - targets: sortedUnique(targetNames), - })); + .flatMap((route): NormalizedScript[] => { + const judgment = judgeScriptRoute(route, configured); + if (judgment !== 'shippable' && judgment !== 'rendered') return []; + return [{ + id: route.id, + mode: scriptMode(route.source), + name: scriptRouteName(route), + provenance: { kind: 'conventional', sourcePath: route.source }, + ...(judgment === 'rendered' ? { rendered: true as const } : {}), + source: route.source, + targets: sortedUnique(targetNames), + }]; + }); return [...explicit, ...conventional].sort((left, right) => left.name.localeCompare(right.name)); }; diff --git a/packages/agent-bundle/src/config/script-routes.ts b/packages/agent-bundle/src/config/script-routes.ts index c7a204afd..1b2a3f366 100644 --- a/packages/agent-bundle/src/config/script-routes.ts +++ b/packages/agent-bundle/src/config/script-routes.ts @@ -5,24 +5,29 @@ import type { AgentBundleConfig } from '../core/types.ts'; import type { CompiledAgentRoute } from '../routes/types.ts'; /** - * The #102 stage-1 judgment of one conventional `src/scripts/` route. - * Normalization ships exactly the `shippable` routes through the explicit - * `scripts` pipeline; source validation reports every other state as a hard - * error (`AB4807`-`AB4809`). Both sides share this rule so no discovered - * script route is ever dropped silently. + * The #102 judgment of one conventional `src/scripts/` route. Normalization + * ships `shippable` routes through the explicit `scripts` pipeline and + * `rendered` routes through the Agent renderer pipeline (#102 stage 3); + * source validation reports the remaining states as hard errors + * (`AB4808`/`AB4809`). Both sides share this rule so no discovered script + * route is ever dropped silently. */ export type ScriptRouteJudgment = /** A configured `scripts` entry already uses this identity for another file. */ | 'conflicting' /** Nested below the scripts root; the flat scripts artifact layout cannot place it yet. */ | 'nested' - /** A rendered-script module (`.tsx`/`.jsx`); needs the Agent renderer (#102 stage 3). */ + /** A rendered-script module (`.tsx`/`.jsx`) executed through the Agent renderer. */ | 'rendered' /** A plain module directly under `src/scripts/` with an unclaimed identity. */ | 'shippable'; const renderedScriptExtensions = new Set(['.jsx', '.tsx']); +/** True for a rendered-script module (`.tsx`/`.jsx`). */ +export const isRenderedScriptRoute = (route: CompiledAgentRoute): boolean => + renderedScriptExtensions.has(extname(route.source).toLowerCase()); + /** The path-derived identity of one script route (`script:release/verify` -> `release/verify`). */ export const scriptRouteName = (route: CompiledAgentRoute): string => route.id.slice('script:'.length); @@ -35,8 +40,8 @@ export const judgeScriptRoute = ( route: CompiledAgentRoute, configuredNames: ReadonlySet, ): ScriptRouteJudgment => { - if (renderedScriptExtensions.has(extname(route.source).toLowerCase())) return 'rendered'; const name = scriptRouteName(route); if (name.includes('/')) return 'nested'; - return configuredNames.has(name) ? 'conflicting' : 'shippable'; + if (configuredNames.has(name)) return 'conflicting'; + return isRenderedScriptRoute(route) ? 'rendered' : 'shippable'; }; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index c9b7261f7..7f6cab14a 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -15,7 +15,6 @@ import { satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; import { isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts'; -import { isRenderedCliRoute } from '../routes/cli-commands.ts'; import type { AgentBundleBinEntry, AgentBundleHookEntry, @@ -1489,15 +1488,9 @@ const validateConventionalScripts = ( const judgment = judgeScriptRoute(route, configured); switch (judgment) { case 'shippable': - break; + // Rendered scripts ship through the Agent renderer pipeline (#102 + // stage 3); AB4807 is retired and never reused. case 'rendered': - diagnostics.push({ - code: 'AB4807', - message: `Conventional script ${relativePath} is a rendered-script module; rendered scripts are not supported yet.`, - recovery: 'Rename the module to .ts to ship a plain script, prefix a path segment with "_" to keep it private, or declare it under scripts in config to opt into plain bundling.', - severity: 'error', - sourcePath: route.source, - }); break; case 'nested': diagnostics.push({ @@ -1527,12 +1520,12 @@ const validateConventionalScripts = ( }; /** - * 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 + * The routed-CLI packaging gate (#102): a generated-mode `src/cli/**` + * surface compiles into one framework-generated bin, so an explicit `bin` + * entry shadowing the generated executable's name is a hard error naming its * explicit resolution. Discovery is not a packaging choice — a route never - * disappears silently. + * disappears silently. (AB4816, the stage-2 rendered-command gate, is + * retired: rendered commands render through the dispatcher since stage 3.) */ const validateConventionalCliRoutes = ( loaded: LoadedConfig, @@ -1541,16 +1534,6 @@ const validateConventionalCliRoutes = ( 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; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 6e4a72953..73ed0a249 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -344,6 +344,8 @@ export interface NormalizedScript { readonly mode: 'bundle' | 'copy'; readonly name: string; readonly provenance: SourceProvenance; + /** True for a conventional rendered-script route (`src/scripts/.tsx`) executed through the Agent renderer (#102 stage 3). */ + readonly rendered?: true; readonly source: string; readonly targets: readonly string[]; } diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index 163c33ca4..57091378a 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -16,7 +16,8 @@ import type { CompiledCliOption } from './types.ts'; * - 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()` + * `z.boolean()`, `z.url()` (a string-valued option validated as a URL at + * run time), `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 @@ -213,6 +214,12 @@ const scalarBaseOf = ( if (args.length > 0) return reject(`z.${method} with arguments`); return { base: { kind: method }, ok: true }; } + case 'url': { + // A string-valued option; the module's real zod schema enforces the + // URL format at run time. + if (args.length > 0) return reject('z.url with arguments'); + return { base: { kind: 'string' }, ok: true }; + } case 'enum': { const argument = args.length === 1 ? unwrapExpression(args[0]!) : undefined; if (argument === undefined || !ts.isArrayLiteralExpression(argument)) { diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index a9eceb43d..c04c8b518 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -7,13 +7,15 @@ 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 + * The #102 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). + * argv grammar supplies the option surface. Plain (`.ts`) routes execute + * directly; rendered (`.tsx`/`.jsx`) routes render through the dispatcher + * (#102 stage 3) — both share one contract: `inputSchema`, `resultSchema`, + * and one async default function. */ const renderedCliExtensions = new Set(['.jsx', '.tsx']); @@ -195,7 +197,6 @@ export const compileCliCommands = async ( 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; @@ -237,12 +238,13 @@ export const compileCliCommands = async ( exitCode: config.exitCode, options, path: cliCommandPath(route), + rendered: isRenderedCliRoute(route), routeId: route.id, }); } - // Collision checks run over the compiled commands plus the rendered routes' - // claimed paths, so a `.tsx` sibling still collides deterministically. + // Collision checks run over every discovered route's claimed path, so a + // sibling that compiled no command still collides deterministically. const claimedPaths = new Map(); for (const route of routes) { claimedPaths.set(cliCommandPath(route).join('/'), route.provenance.relativePath); diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 1f40bc01a..2f444c42b 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -123,15 +123,17 @@ export interface CompiledCliCommand { readonly options: readonly CompiledCliOption[]; /** Command path segments below the CLI root (`['library', 'audit']`). */ readonly path: readonly string[]; + /** True for a `.tsx` route whose async default Server Component renders through the dispatcher (#102 stage 3). */ + readonly rendered: boolean; 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. + * The collision-checked command graph compiled from the route surface; + * present only in `generated` mode. Plain (`.ts`) routes execute directly; + * rendered (`.tsx`) routes render through the dispatcher. */ readonly commands?: readonly CompiledCliCommand[]; readonly mode: CompiledCliMode; diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index e5e6afe0c..928cf9183 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -137,13 +137,13 @@ const cliArguments = ( options: RenderRouteOptions, provenance: RenderedRouteProvenance, ): readonly string[] => { - const candidate = options.args ?? options.input ?? []; + const candidate = options.args ?? []; if (Array.isArray(candidate) && candidate.every((value) => typeof value === 'string')) { return candidate as readonly string[]; } throw new AgentTestError( 'invalid-input', - 'A cli route renders with string arguments.', + 'A cli route invocation carries string arguments.', { details: [`received: ${captured(candidate)}`], provenance, @@ -173,7 +173,7 @@ const invocationFor = ( case 'script': return { kind: 'script', - props: { name: routeId, ...(options.input === undefined ? {} : { input: options.input as never }) }, + props: { input: cliArguments(options, provenance) as never, name: routeId }, }; default: { const exhaustive: never = kind; @@ -204,12 +204,16 @@ const protocolName = (routeId: string): string => routeId.slice(routeId.lastInde /** * Props the route component receives. MCP route kinds get exactly the public * route contract's `{ input, signal }` — the same props the generated server's - * Flight worker passes. The kinds whose public surface has not landed yet - * receive their invocation props beside the signal. + * Flight worker passes. Rendered CLI commands get the routed command + * contract's `{ input, signal }` (parsed input); rendered scripts get + * `{ argv, signal }` — both exactly what the generated executables pass + * (#102 stage 3). Event routes receive their invocation props beside the + * signal until their public surface hardens. */ const componentProps = ( invocation: AgentRenderInvocation, kind: RenderableRouteKind, + options: RenderRouteOptions, signal: AbortSignal, ): Readonly> => { switch (kind) { @@ -218,8 +222,10 @@ const componentProps = ( case 'tool': return { input: (invocation.props as { readonly input?: unknown }).input, signal }; case 'cli': - case 'event-route': + return { input: options.input ?? {}, signal }; case 'script': + return { argv: (invocation.props as { readonly input?: unknown }).input ?? [], signal }; + case 'event-route': return { ...invocation.props, signal }; default: { const exhaustive: never = kind; @@ -419,7 +425,7 @@ export const renderRoute = async ( }, async () => drain(renderer.renderAgentFlight( renderer.createElement( resolved.component as never, - componentProps(request.invocation, resolved.kind, request.signal) as never, + componentProps(request.invocation, resolved.kind, options, request.signal) as never, ), { signal: request.signal }, )))), diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index a896b8b36..0bd369f65 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -1044,7 +1044,9 @@ it('refuses unshippable conventional script routes with actionable diagnostics', const result = await validate({ root }); const gate = result.diagnostics.filter(({ code }) => ['AB4807', 'AB4808', 'AB4809'].includes(code)); - expect(gate.map(({ code }) => code).sort()).toEqual(['AB4807', 'AB4808', 'AB4809']); + // AB4807 is retired (#102 stage 3): the rendered-notes route ships + // through the Agent renderer pipeline instead of failing validation. + expect(gate.map(({ code }) => code).sort()).toEqual(['AB4808', 'AB4809']); for (const diagnostic of gate) { expect(diagnostic.severity).toBe('error'); expect(diagnostic.recovery).toBeTruthy(); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 5e87e0d1e..3db175b6b 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -84,6 +84,40 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 '}', '', ].join('\n')), + writeProjectFile(root, 'src/cli/report.tsx', [ + "import React from 'react';", + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { description: 'Render a library report.', positionals: ['root'] };", + 'export const inputSchema = z.object({ root: z.string().min(1) }).strict();', + 'export const resultSchema = z.object({ books: z.number(), root: z.string() }).strict();', + 'export default async function Report({ input, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' const context = await agent();', + " await context.progress.report({ completed: 1, message: 'scanning', total: 2 });", + ' const result = { books: 2, root: input.root };', + ' return (', + ' ', + ' {`Found **2** books under ${input.root}.`}', + ' ', + ' );', + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/scripts/summarize.tsx', [ + "import React from 'react';", + "import { Agent } from '@agent-bundle/runtime';", + 'export default async function Summarize({ argv, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' const result = { arguments: argv.length };', + ' return (', + ' ', + ' {`Summarized ${String(argv.length)} arguments.`}', + ' ', + ' );', + '}', + '', + ].join('\n')), ]); const result = await build({ output: 'artifact', packageOutputs: true, root }); @@ -130,4 +164,34 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 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') }); + + // The rendered .tsx command (#102 stage 3) renders through the dispatcher + // against the sibling react-server worker. + const workerPath = join(root, 'dist', 'bin', 'cli-bin-fixture-flight.mjs'); + await expect(stat(workerPath)).resolves.toMatchObject({}); + // Piped output is exactly one final Markdown document, no partial fallbacks. + const piped = await execFile(binPath, ['report', '/library']); + expect(piped.stdout).toBe('Found **2** books under /library.\n'); + // --json returns the canonical validated final value. + const reportJson = await execFile(binPath, ['report', '/library', '--json']); + expect(JSON.parse(reportJson.stdout)).toEqual({ books: 2, root: '/library' }); + // --ndjson exposes the sequence-numbered render-event stream, including + // the progress the component reported through the request context. + const reportEvents = await execFile(binPath, ['report', '/library', '--ndjson']); + const events = reportEvents.stdout.trimEnd().split('\n') + .map((line) => JSON.parse(line) as { sequence: number; type: string }); + expect(events.map((event) => event.sequence)).toEqual(events.map((_, index) => index)); + expect(events.some((event) => event.type === 'progress')).toBe(true); + expect(events[events.length - 1]!.type).toBe('complete'); + // Rendered input-validation failures stay usage failures. + await expect(execFile(binPath, ['report'])).rejects.toMatchObject({ code: 2, stdout: '' }); + + // The rendered .tsx script (#102 stage 3) ships beside plain scripts in + // the target artifact with the same output contract. + const scriptPath = join(root, 'artifact', 'portable', 'scripts', 'summarize.mjs'); + await expect(stat(join(root, 'artifact', 'portable', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); + const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); + expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); + const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); + expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1 }); }); diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index b63ec308d..2dc31bad7 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -5,7 +5,15 @@ 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 { + CliInputError, + projectCliDocumentToMarkdown, + runGeneratedCliEntry, + runGeneratedRenderedScript, + type CliRenderedDocument, + type CliRenderedEvent, + type GeneratedCliRenderSession, +} 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'; @@ -165,6 +173,7 @@ describe('compiled command graph', () => { exitCode: 'zero', options: [{ key: 'verbose', kind: 'boolean', option: 'verbose', repeated: false, required: false }], path: ['doctor'], + rendered: false, routeId: 'cli:doctor', }, { @@ -176,6 +185,7 @@ describe('compiled command graph', () => { { key: 'sources', kind: 'string', option: 'sources', positional: 0, repeated: true, required: true }, ], path: ['library', 'audit'], + rendered: false, routeId: 'cli:library/audit', }, ]); @@ -259,7 +269,7 @@ describe('compiled command graph', () => { expect(messages).toContain('after an optional one'); }); - it('compiles no command for rendered routes and gates them with AB4816 in source validation', async () => { + it('compiles rendered .tsx routes into rendered commands beside plain ones (#102 stage 3)', async () => { const root = await createRoot(); await writeTree(root, { 'agent-bundle.config.ts': [ @@ -272,11 +282,16 @@ describe('compiled command graph', () => { }); const graph = await compileRouteGraph(root, fixtureConfig()); expect(graph.diagnostics).toEqual([]); - expect(graph.cli?.commands?.map((command) => command.routeId)).toEqual(['cli:inspect']); + expect(graph.cli?.commands?.map((command) => [command.routeId, command.rendered])).toEqual([ + ['cli:doctor', true], + ['cli:inspect', false], + ]); + // AB4816 (the stage-2 rendered-command gate) is retired: source + // validation accepts the rendered surface. const result = await inspect({ root }); - expect(result.state).toBe('invalid'); - expect(codesOf(result.diagnostics)).toContain('AB4816'); + expect(result.state).toBe('ready'); + expect(codesOf(result.diagnostics)).not.toContain('AB4816'); }); it('errors with AB4813 when an explicit bin entry claims the generated executable name', async () => { @@ -306,6 +321,7 @@ describe('generated bin normalization', () => { exitCode: 'zero', options: [], path: ['inspect'], + rendered: false, routeId: 'cli:inspect', }; const surface = (overrides: Partial = {}): CompiledCliSurface => ({ @@ -387,6 +403,7 @@ describe('generated CLI shell', () => { { key: 'verbose', kind: 'boolean', option: 'verbose', repeated: false, required: false }, ], path: ['doctor'], + rendered: false, routeId: 'cli:doctor', }, { @@ -399,6 +416,7 @@ describe('generated CLI shell', () => { { key: 'sources', kind: 'string', option: 'sources', positional: 0, repeated: true, required: true }, ], path: ['library', 'audit'], + rendered: false, routeId: 'cli:library/audit', }, ]; @@ -552,4 +570,229 @@ describe('generated CLI shell', () => { expect(aborted.stderr).toBe('Aborted.\n'); expect(aborted.calls).toEqual([]); }); + + it('rejects --ndjson on a plain command as a usage failure', async () => { + const result = await run(['doctor', '/library', '--ndjson']); + expect(result.code).toBe(2); + expect(result.stderr).toContain('--ndjson requires a rendered command.'); + }); +}); + +const completeDocument = ( + status: CliRenderedDocument['status'], + value: unknown, + children: readonly CliRenderedDocument['root'][] = [], +): CliRenderedDocument => ({ + root: { children: [...children], kind: 'result' }, + status, + value, + version: 1, +}); + +const eventStream = (events: readonly CliRenderedEvent[]): ReadableStream => + new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(event); + controller.close(); + }, + }); + +describe('rendered command projection (#102 stage 3)', () => { + const renderedCommand: CompiledCliCommand = { + aliases: [], + description: 'Render a report.', + exitCode: 'zero', + options: [{ key: 'root', kind: 'string', option: 'root', positional: 0, repeated: false, required: true }], + path: ['report'], + rendered: true, + routeId: 'cli:report', + }; + const document = completeDocument('success', { books: 2 }, [ + { kind: 'markdown', text: 'Found **2** books.' }, + { completed: 2, kind: 'progress', total: 2 }, + ]); + const events: readonly CliRenderedEvent[] = [ + { document: completeDocument('success', undefined), sequence: 0, type: 'shell' }, + { completed: 1, message: 'auditing', sequence: 1, total: 2, type: 'progress' }, + { document, sequence: 2, type: 'complete' }, + ]; + + interface RenderedRun { + readonly closed: number; + readonly code: number; + readonly stderr: string; + readonly stdout: string; + } + + const runRendered = async ( + argv: readonly string[], + options: { + readonly events?: readonly CliRenderedEvent[]; + readonly isTty?: boolean; + readonly validate?: (value: unknown) => unknown; + } = {}, + ): Promise => { + const stdout: string[] = []; + const stderr: string[] = []; + let closed = 0; + const session: GeneratedCliRenderSession = { + close: async () => { + closed += 1; + }, + events: () => eventStream(options.events ?? events), + validate: options.validate ?? ((value) => value), + }; + const code = await runGeneratedCliEntry({ + argv, + commands: [renderedCommand], + execute: async () => { + throw new Error('plain execute must not run for a rendered command'); + }, + isTty: () => options.isTty ?? false, + name: 'curator', + render: () => session, + version: '1.2.3', + writeErr: (text) => void stderr.push(text), + writeOut: (text) => void stdout.push(text), + }); + return { closed, code, stderr: stderr.join(''), stdout: stdout.join('') }; + }; + + it('emits one final Markdown document when piped, with no partial fallbacks', async () => { + const piped = await runRendered(['report', '/library']); + expect(piped.code).toBe(0); + expect(piped.stdout).toBe('Found **2** books.\n'); + expect(piped.stderr).toBe(''); + expect(piped.closed).toBe(1); + }); + + it('updates progress in place on a TTY before the final document', async () => { + const tty = await runRendered(['report', '/library'], { isTty: true }); + expect(tty.code).toBe(0); + expect(tty.stdout).toBe('\r\u001B[2Kauditing (1/2)\r\u001B[2KFound **2** books.\n'); + }); + + it('emits the canonical validated final value under --json', async () => { + const json = await runRendered(['report', '/library', '--json'], { + validate: (value) => ({ ...(value as Record), validated: true }), + }); + expect(json.code).toBe(0); + expect(json.stdout).toBe('{"books":2,"validated":true}\n'); + }); + + it('emits the sequence-numbered render-event stream under --ndjson', async () => { + const ndjson = await runRendered(['report', '/library', '--ndjson']); + expect(ndjson.code).toBe(0); + const lines = ndjson.stdout.trimEnd().split('\n').map((line) => JSON.parse(line) as { sequence: number; type: string }); + expect(lines.map((line) => [line.sequence, line.type])).toEqual([ + [0, 'shell'], + [1, 'progress'], + [2, 'complete'], + ]); + }); + + it('maps non-success documents, validation failures, and missing completion to exit 1', async () => { + const represented = await runRendered(['report', '/library'], { + events: [{ document: completeDocument('represented-error', { ok: false }), sequence: 0, type: 'complete' }], + }); + expect(represented.code).toBe(1); + + const invalid = await runRendered(['report', '/library'], { + validate: () => { + throw new Error('result contract violated'); + }, + }); + expect(invalid.code).toBe(1); + expect(invalid.stderr).toContain('result contract violated'); + + const incomplete = await runRendered(['report', '/library'], { + events: [{ document: completeDocument('success', undefined), sequence: 0, type: 'shell' }], + }); + expect(incomplete.code).toBe(1); + expect(incomplete.stderr).toContain('without a complete document'); + }); + + it('rejects --json combined with --ndjson', async () => { + const both = await runRendered(['report', '/library', '--json', '--ndjson']); + expect(both.code).toBe(2); + expect(both.stderr).toContain('Use either --json or --ndjson, not both.'); + }); +}); + +describe('rendered script projection (#102 stage 3)', () => { + it('reserves --json/--ndjson, passes the rest as argv, and derives exit codes from status', async () => { + const stdout: string[] = []; + const captured: (readonly string[])[] = []; + const document = completeDocument('success', { ok: true }, [{ kind: 'text', text: 'Summarized.' }]); + const code = await runGeneratedRenderedScript({ + argv: ['--json', 'a', '--', '--ndjson'], + createSession: (argv) => { + captured.push(argv); + return { + close: async () => undefined, + events: () => eventStream([{ document, sequence: 0, type: 'complete' }]), + validate: (value) => value, + }; + }, + isTty: () => false, + name: 'summarize', + writeErr: () => undefined, + writeOut: (text) => void stdout.push(text), + }); + expect(code).toBe(0); + // --json is the framework dialect; the -- terminator and everything + // after it pass through untouched (the script owns its own argv). + expect(captured).toEqual([['a', '--', '--ndjson']]); + expect(stdout.join('')).toBe('{"ok":true}\n'); + + const failed = await runGeneratedRenderedScript({ + argv: [], + createSession: () => ({ + close: async () => undefined, + events: () => eventStream([{ document: completeDocument('failed', undefined), sequence: 0, type: 'complete' }]), + validate: (value) => value, + }), + isTty: () => false, + name: 'summarize', + writeErr: () => undefined, + writeOut: () => undefined, + }); + expect(failed).toBe(1); + }); +}); + +describe('final document Markdown projection', () => { + it('projects every node kind onto stable Markdown and omits transient progress', () => { + const markdown = projectCliDocumentToMarkdown({ + root: { + children: [ + { kind: 'markdown', text: '# Report' }, + { kind: 'text', text: 'Two books found.' }, + { kind: 'context', text: 'Guidance line.' }, + { kind: 'json', value: { books: 2 } }, + { completed: 2, kind: 'progress', total: 2 }, + { kind: 'resource', name: 'receipt', uri: 'file:///tmp/receipt.json' }, + { code: 'partial', kind: 'error', message: 'one source skipped' }, + ], + kind: 'result', + }, + status: 'success', + value: { books: 2 }, + version: 1, + }); + expect(markdown).toBe([ + '# Report', + '', + 'Two books found.', + '', + '> Guidance line.', + '', + '```json\n{\n "books": 2\n}\n```', + '', + '[receipt](file:///tmp/receipt.json)', + '', + '**[partial]** one source skipped', + '', + ].join('\n')); + }); }); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index ba61344d7..a25157ec0 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -850,6 +850,16 @@ it('normalizes shippable conventional script routes through the explicit scripts source: `${root}/src/tasks/detect-risk.ts`, targets: ['claude'], }, + // Rendered routes ship through the renderer pipeline (#102 stage 3). + { + id: 'script:render-notes', + mode: 'bundle', + name: 'render-notes', + provenance: { kind: 'conventional', sourcePath: `${root}/src/scripts/render-notes.tsx` }, + rendered: true, + source: `${root}/src/scripts/render-notes.tsx`, + targets: ['portable'], + }, { id: 'script:verify-release', mode: 'bundle', @@ -881,7 +891,7 @@ it('normalizes conventional scripts when config declares none', async () => { ]); }); -it('gates rendered, nested, and conflicting conventional script routes as AB4807-AB4809', () => { +it('gates nested and conflicting conventional script routes as AB4808/AB4809 and ships rendered ones', () => { const root = '/workspace/project'; const loaded = loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' }, @@ -901,6 +911,8 @@ it('gates rendered, nested, and conflicting conventional script routes as AB4807 const gate = validateSource(loaded, discovered, registry) .filter(({ code }) => code.startsWith('AB48')); + // AB4807 is retired: rendered script routes ship through the Agent + // renderer pipeline (#102 stage 3) instead of failing validation. expect(gate).toEqual([ { code: 'AB4809', @@ -916,19 +928,26 @@ it('gates rendered, nested, and conflicting conventional script routes as AB4807 severity: 'error', sourcePath: `${root}/src/scripts/release/tag.ts`, }, + ]); +}); + +it('normalizes rendered conventional script routes onto the renderer pipeline (#102 stage 3)', async () => { + const root = '/workspace/project'; + const model = await normalizeProject( + loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }), + { routeGraph: routeGraphWithScripts(root, ['src/scripts/render-notes.tsx']), skills: [] }, + registry, + ); + + expect(model.scripts).toEqual([ { - code: 'AB4807', - message: 'Conventional script src/scripts/render-notes.tsx is a rendered-script module; rendered scripts are not supported yet.', - recovery: 'Rename the module to .ts to ship a plain script, prefix a path segment with "_" to keep it private, or declare it under scripts in config to opt into plain bundling.', - severity: 'error', - sourcePath: `${root}/src/scripts/render-notes.tsx`, - }, - { - code: 'AB4807', - message: 'Conventional script src/scripts/render-poster.jsx is a rendered-script module; rendered scripts are not supported yet.', - recovery: 'Rename the module to .ts to ship a plain script, prefix a path segment with "_" to keep it private, or declare it under scripts in config to opt into plain bundling.', - severity: 'error', - sourcePath: `${root}/src/scripts/render-poster.jsx`, + id: 'script:render-notes', + mode: 'bundle', + name: 'render-notes', + provenance: { kind: 'conventional', sourcePath: `${root}/src/scripts/render-notes.tsx` }, + rendered: true, + source: `${root}/src/scripts/render-notes.tsx`, + targets: ['portable'], }, ]); }); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index cf1726d22..63a069a6d 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -40,9 +40,14 @@ const fixtureConfig = (extra: Readonly> = {}): AgentBund }); const conventionalTree: Readonly> = { - 'src/cli/doctor.tsx': moduleSource, - // Plain CLI routes compile through the stage-2 argv grammar, so the - // fixture carries a real (statically parseable) zod object schema. + // CLI routes (plain and rendered) compile through the argv grammar, so the + // fixtures carry real (statically parseable) zod object schemas. + 'src/cli/doctor.tsx': [ + 'export const inputSchema = z.object({ verbose: z.boolean().optional() }).strict();', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'), 'src/cli/library/audit.ts': [ "export const config = { description: 'Audit the library.' };", 'export const inputSchema = z.object({ strict: z.boolean().optional() }).strict();', @@ -148,7 +153,7 @@ it('skips ignored paths, private segments, and declaration files', async () => { expect(graph.scripts).toEqual([]); }); -it('discovers .jsx script routes so the rendered-script gate can judge them', async () => { +it('discovers .jsx script routes so the rendered-script pipeline can ship them', async () => { const root = await createRoot(); await writeTree(root, { 'src/scripts/rebuild-index.ts': moduleSource, @@ -157,7 +162,7 @@ it('discovers .jsx script routes so the rendered-script gate can judge them', as const graph = await compileRouteGraph(root, fixtureConfig()); // Discovery is not a packaging choice: the .jsx module compiles into the - // graph so source validation can gate it as AB4807 instead of dropping it. + // graph so normalization ships it through the renderer pipeline (#102 s3). expect(graph.diagnostics).toEqual([]); expect(graph.scripts.map((route) => route.id)).toEqual(['script:rebuild-index', 'script:render-poster']); expect(graph.scripts.find((route) => route.id === 'script:render-poster')).toMatchObject({ diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 3bec5b76a..ad65c2f5b 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -126,9 +126,13 @@ projections run — `inputSchema.parse` → `execute` → `resultSchema.parse` while `cli` and `mcp` are optional per-surface declarations. `render` is required on every operation but consumed only by the MCP projection, where `lowerMcpResult` synchronously lowers its element tree into the -`CallToolResult`; the CLI never renders JSX and instead prints the -validated result as one line of JSON. Operation modules are `.tsx` only -because `render` returns JSX. The end-to-end walkthrough lives in +`CallToolResult`; the `runRscCli` compatibility path never renders JSX and +instead prints the validated result as one line of JSON. Operation modules +are `.tsx` only because `render` returns JSX. (Routed `src/cli/**` commands +are the framework-mode CLI: there, `.tsx` routes do render — through the +Agent renderer's dispatcher with TTY/Markdown/`--json`/`--ndjson` output +modes — while plain `.ts` routes keep the one-JSON-line contract.) The +end-to-end walkthrough lives in [Framework mode](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/framework-mode.md). Use `runRscCli(application, argv)` in the conventional `src/cli.ts` entry and diff --git a/packages/rsc-runtime/tests/docs-contract.test.ts b/packages/rsc-runtime/tests/docs-contract.test.ts index 6c6a1ff12..bd6b2445e 100644 --- a/packages/rsc-runtime/tests/docs-contract.test.ts +++ b/packages/rsc-runtime/tests/docs-contract.test.ts @@ -5,9 +5,18 @@ import { z } from 'zod'; import { Mcp, defineOperation, defineRscApplication, lowerMcpResult, runRscCli } from '../src/index.js'; /** - * The `status` operation printed in `docs/framework-mode.md` and this - * package's README, with a render counter so the "only MCP consumes - * `render`" claim is observable. + * The `status` operation printed in this package's README, with a render + * counter so the documented compatibility claim stays observable. + * + * Pin flip (#102 stage 3): the old pin read "CLI writes one JSON line, + * `render` never invoked" for the whole CLI story. Routed `src/cli/**` + * commands now DO render — `.tsx` routes render through the dispatcher with + * TTY/Markdown/`--json`/`--ndjson` output modes (pinned by + * `packages/agent-bundle/tests/cli-routes.test.ts` and + * `cli-routes-build.test.ts`). What this file keeps pinning is the narrowed + * claim the docs still make: the handwritten `runRscCli` compatibility path + * serializes the validated result as one JSON line and never invokes + * `render`; only the MCP projection consumes it. */ const documentedStatus = (onRender: () => void) => defineOperation({ cli: { @@ -36,8 +45,8 @@ const documentedStatus = (onRender: () => void) => defineOperation({ resultSchema: z.object({ status: z.literal('ready') }).strict(), }); -describe('documented operation model', () => { - it('serves both projections from one shared core and renders only for MCP', async () => { +describe('documented operation model (the runRscCli compatibility path)', () => { + it('serves both projections from one shared core; only MCP consumes render on this path', async () => { let renders = 0; const status = documentedStatus(() => { renders += 1; @@ -50,7 +59,8 @@ describe('documented operation model', () => { const output: string[] = []; await expect(runRscCli(application, ['status'], { write: (value) => output.push(value) })).resolves.toBe(0); - // The CLI projection prints one line of JSON and never touches `render`. + // The compatibility CLI projection prints one line of JSON and never + // touches `render`; routed `.tsx` commands are the rendering CLI path. expect(output.join('')).toBe('{"status":"ready"}\n'); expect(renders).toBe(0); From 66ff550b882daa0f3467496ce42c501214949c0e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 20:20:09 +0000 Subject: [PATCH 3/3] test(harness): pin the rendered flag on compiled CLI commands (#190 reconcile) --- packages/agent-bundle/tests/test-harness-manifest.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 2f96b2f10..ee576b79b 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -75,6 +75,7 @@ describe('the compiled test manifest', () => { exitCode: 'result', options: [expect.objectContaining({ key: 'dryRun', kind: 'boolean', option: 'dry-run' })], path: ['db', 'migrate'], + rendered: false, routeId: 'cli:db/migrate', }, { @@ -87,6 +88,7 @@ describe('the compiled test manifest', () => { expect.objectContaining({ key: 'shelf', positional: 0, required: true }), ], path: ['inventory'], + rendered: false, routeId: 'cli:inventory', }, ]);