From 3d3b3f4b67e3eda7716005a28af4de558da83696 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 01:58:54 +0000 Subject: [PATCH 1/7] feat(cli): route first-party CLI terminal I/O through Effect Terminal/Stdio; spell routed-CLI input errors in CLI terms (#465) --- .changeset/465-cli-terminal-input-errors.md | 5 + docs/diagnostics.md | 6 +- docs/effect-conventions.md | 58 ++++- docs/entry-conventions.md | 21 ++ .../agent-bundle/src/build/entry-shell.ts | 12 +- packages/agent-bundle/src/cli-entry.ts | 236 +++++++++++++++++- packages/agent-bundle/src/cli.ts | 235 +++++++++-------- packages/agent-bundle/src/effect/terminal.ts | 46 ++++ packages/agent-bundle/src/test/cli.ts | 4 +- packages/agent-bundle/src/test/render.ts | 4 +- .../tests/cli-routes-build.test.ts | 26 +- .../agent-bundle/tests/cli-routes.test.ts | 110 ++++++++ packages/agent-bundle/tests/cli.test.ts | 23 +- .../agent-bundle/tests/dev-workbench.test.ts | 9 +- packages/agent-bundle/tests/doctor.test.ts | 54 ++-- packages/agent-bundle/tests/eval-cli.test.ts | 11 +- .../agent-bundle/tests/inspect-state.test.ts | 14 +- packages/agent-bundle/tests/install.test.ts | 33 +-- .../agent-bundle/tests/package-build.test.ts | 16 +- packages/agent-bundle/tests/prepack.test.ts | 7 +- .../tests/projection/cli-input-errors.test.ts | 98 ++++++++ .../agent-bundle/tests/route-graph.test.ts | 19 +- .../tests/support/cli-terminal.ts | 44 ++++ .../en/guide/authoring/package-entries.mdx | 15 ++ .../zh/guide/authoring/package-entries.mdx | 14 ++ 25 files changed, 878 insertions(+), 242 deletions(-) create mode 100644 .changeset/465-cli-terminal-input-errors.md create mode 100644 packages/agent-bundle/src/effect/terminal.ts create mode 100644 packages/agent-bundle/tests/projection/cli-input-errors.test.ts create mode 100644 packages/agent-bundle/tests/support/cli-terminal.ts diff --git a/.changeset/465-cli-terminal-input-errors.md b/.changeset/465-cli-terminal-input-errors.md new file mode 100644 index 000000000..c25d7baa3 --- /dev/null +++ b/.changeset/465-cli-terminal-input-errors.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root by `@effect/platform-node` (`NodeTerminal`/`NodeStdio`, new dependency); `runCli` takes `{ services }` in place of the former stream injection. Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#PR) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d0324ded4..353136545 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -690,7 +690,11 @@ 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. +`--ndjson`, and `--version` are owned by the generated shell. An +`inputSchema` rejection is reported one issue per line in CLI terms — +`Invalid value for : expected ; received .` — then +the usage line; `--json` writes one `{"error":{"code":"CLI_INPUT_INVALID", +...}}` line to stderr and `--ndjson` one `type: "error"` event (#465). The power-tier `routes.mcpCommands` option projects tools from generated MCP servers into that same command graph. Each tool becomes diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 7eafad7ca..123325136 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -234,7 +234,9 @@ motivated the decline is still real and is what the keep-raw list below encodes: the pinned `FileSystem` has no `lstat`, `OpenFlag` accepts only string flags (no `O_NOFOLLOW`), and there is no directory fsync. `NodeRuntime.runMain` stays banned (the 130/143 signal-distinct exit -contract). +contract). The same package's `Terminal` and `Stdio` services are adopted for +the first-party CLI's user-facing text — see +[Terminal and Stdio](#terminal-and-stdio-user-facing-cli-text). ### Adopt @@ -265,10 +267,14 @@ contract). - Layer wiring: one composition root per process. The scaffolder provides `NodeServices.layer` immediately before its boundary's `runPromise`; `agent-bundle`'s public API functions provide `platformLayer` through - `runWithPlatform`; the dev server (phase 2) gets one - `makeScopedEffectRuntime(platformLayer)` in `startDevServer`, disposed - from the session's `close`. Never provide a platform layer deep inside - library code. + `runWithPlatform`; the first-party CLI's root is the + `makeScopedEffectRuntime(nodeCliServices)` in `runCli` (today `NodeTerminal` + + `NodeStdio`, see [Terminal and + Stdio](#terminal-and-stdio-user-facing-cli-text)) and widens to + `platformLayer` there when CLI code adopts the filesystem services; the dev + server (phase 2) gets one `makeScopedEffectRuntime(platformLayer)` in + `startDevServer`, disposed from the session's `close`. Never provide a + platform layer deep inside library code. - Errors: `PlatformError` flows through the Effect error channel and is mapped once, at the boundary, onto the existing contract. Where a user-facing AB#### diagnostic already exists for the failure, map to it @@ -335,6 +341,47 @@ artifacts, hook wrappers, and compiler hot paths never import this module; the dev server picks it up in phase 2 through `makeScopedEffectRuntime(platformLayer)`. +### Terminal and Stdio: user-facing CLI text + +Adopted 2026-09-03 for the first-party `agent-bundle` CLI (`src/cli.ts`); the +Node implementations come from `@effect/platform-node-shared@4.0.0-rc.112`, +the same dependency `platform.ts` builds `platformLayer` from (never +`@effect/platform-node`, for the consumer-footprint reason above). +`effect/Terminal` is the sanctioned way to touch stdin/stdout for +**user-facing text**: human command output, Commander help and argv errors, +and the Workbench startup URL line go through `Terminal.display`, and any +future interactive prompt goes through `terminal.readLine` (EOF surfaces as +`Terminal.QuitError`, so a prompt must handle it). `Terminal.display` is +stdout-only; **diagnostics** (the canonical JSON diagnostics document) go +through `Stdio.stderr()`, and **machine output** (`--json`, stable JSON +lines) goes through `Stdio.stdout()` so its bytes stay exact. The helpers +live in `src/effect/terminal.ts` (`display`, `writeStderr`, `writeStdout`). + +Wiring rules: + +- Provide the process-backed layers **once**, at the CLI composition root + (`runCli`), through one `makeScopedEffectRuntime(nodeCliServices)` from + `src/effect/boundary.ts`, and close it when the command finishes (a + foreground `dev` session keeps it until the session closes). No other + module provides `NodeTerminal.layer` / `NodeStdio.layer`. +- `nodeCliServices` is `Layer.mergeAll(NodeTerminal.layer, NodeStdio.layer)` + from the `@effect/platform-node-shared/NodeTerminal` and `/NodeStdio` + subpaths, not the whole `platformLayer`: the CLI's help/version path does + not use child-process, crypto, or filesystem services, and loading them + measured at roughly +400 ms of startup. +- Keep `display` text explicit about line endings (`\n`); the service writes + what it is given. +- Tests provide a capture layer (`tests/support/cli-terminal.ts`: + `Terminal.make({ display })` + `Stdio.layerTest({ stdout, stderr })`) + through `runCli(args, { services })`; they never spy on `process.stdout`. +- **Protocol stdout stays raw.** MCP stdio JSON-RPC (`mcp-entry.ts`, + `mcp run`), hook result JSON (`adapters/hook-contract.ts`), the emitted + routed-CLI shell (`cli-entry.ts`'s `writeOut`/`writeErr` ports and the + `entry-shell.ts` bin template), generated installers (`install-entry.ts`, + `install/surface.ts`), and child/worker stderr forwarding keep their direct + `process.stdout`/`process.stderr` adapters: emitted artifacts must not carry + a platform runtime, and byte-exact protocol frames are not terminal text. + ## Effect Schema wire contracts (Schema projections) Evaluated 2026-09-01 against `effect@4.0.0-rc.112` for the wire-contract @@ -412,6 +459,7 @@ wire contracts](#effect-schema-wire-contracts-schema-projections). | --- | --- | --- | | `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships | | `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract | +| `@effect/platform-node-shared` (`NodeTerminal` / `NodeStdio`) + `effect/Terminal`, `effect/Stdio` | first-party CLI user-facing text, diagnostics, and machine output (`src/cli.ts`, `src/effect/terminal.ts`) and `create-agent-bundle`'s `--help` / flag-error text (2026-09-03) | re-pin re-checks `Terminal.display` stays stdout-only, `readLine` EOF → `QuitError`, the `Stdio` sink contract, and re-measures `agent-bundle --version` startup against the recorded +180 ms budget | | `Schema` / `SchemaAST` / `SchemaParser` projections (`toType` / `toEncoded`) for wire contracts | **declined** (2026-09-01) | revisit at Effect GA or on the first encoded/decoded-divergent wire contract; re-pin re-checks the projections API and the `onExcessProperty` parse-option default | ## Language service diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index d8323a9ef..61cec058f 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -678,6 +678,27 @@ 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. +When the module's `inputSchema` rejects the parsed argv, the shell reports +each issue in CLI terms rather than the raw schema issue JSON (#465): one +line per issue naming the argument as typed (`--max-files` for a named +option, `` for a positional, `--input.` for a projected MCP +command, `input` when no single argument is at fault), the expectation, and +the received value as canonical JSON, followed by the command's exact usage +line and the `--help` hint, all on stderr: + +```text +Invalid value for --max-files: expected number <= 55000; received 300000. +Usage: curator doctor [options] +Run 'curator doctor --help' for usage. +``` + +Under `--json` stdout stays empty and stderr carries exactly one canonical +line, `{"error":{"code":"CLI_INPUT_INVALID","issues":[{"expected":..., +"message":...,"received":...,"target":...}],"usage":"Usage: ..."}}`; under +`--ndjson` the stdout stream carries one `type: "error"` event with the same +`error` object (plus the joined `message`) at `sequence: 0`. The exit code +is 2 in every mode. + 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 diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 16b82a3a8..99e42a52b 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -301,7 +301,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) const plainIndent = options.state === undefined ? ' ' : ' '; const stateFallback = options.stateFallback ?? 'cwd'; return [ - `import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, + `import { cliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`, rendered ? "import { available, createAgentRenderDispatcher, runAgentRequest, unavailable } from '@agent-bundle/runtime';" : "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", @@ -320,11 +320,13 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '', `const commands = Object.freeze(${stableJson(options.commands)});`, '', - 'const parseInput = (route, input) => {', + // A schema failure becomes a CliInputError whose issues name the CLI + // argument, the expectation, and the received value (#465). + 'const parseInput = (command, route, input) => {', ' try {', ' return route.module.inputSchema.parse(input);', ' } catch (error) {', - ' throw new CliInputError(error instanceof Error ? error.message : String(error));', + ' throw cliInputError(command, input, error);', ' }', '};', '', @@ -334,7 +336,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '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 parsed = parseInput(command, route, input);', ' const cwd = process.cwd();', ...processHitSource(' '), ...(options.state === undefined @@ -373,7 +375,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) '', 'const render = (command, input, context) => {', ' const route = routes[command.routeId];', - ' const parsed = parseInput(route, input);', + ' const parsed = parseInput(command, route, input);', ' if (command.mcp !== undefined) {', ' return openRenderedSession({', " invocation: { kind: 'tool', props: { input: parsed, operationId: command.routeId } },", diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index f045312eb..abaa1ea19 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -76,18 +76,184 @@ export class CliUsageError extends Error { } } +/** + * One input-validation failure of a routed command, already spelled in CLI + * terms (#465): the argument the user typed rather than the schema path. + */ +export interface CliInputIssue { + /** The plain-language expectation, e.g. `number <= 55000` or `one of: json, text`. */ + readonly expected: string; + /** The schema's own message for the issue, kept for machine consumers. */ + readonly message: string; + /** The value the command received at the issue path; absent when nothing was received. */ + readonly received?: unknown; + /** + * The CLI spelling of the failing argument: `--flag` for a named option, + * `` for a positional, `--input.` for a projected MCP command, + * or `input` when the issue is not attributable to one argument. + */ + readonly target: string; +} + +/** The machine-readable error code of an input-validation failure in `--json` and `--ndjson` output. */ +export const cliInputInvalidCode = 'CLI_INPUT_INVALID'; + /** * 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. + * execution failure. Construct it through {@link cliInputError} so a schema + * failure carries its {@link CliInputIssue} list; the message-only form stays + * for failures that are not attributable to individual arguments. */ export class CliInputError extends Error { - constructor(message: string) { + readonly issues: readonly CliInputIssue[]; + + constructor(message: string, issues: readonly CliInputIssue[] = []) { super(message); this.name = 'CliInputError'; + this.issues = Object.freeze([...issues]); } } +/** The structural shape of a zod (v3/v4) issue, matched without importing zod: routed executables resolve zod from the consumer project. */ +interface SchemaIssueLike { + readonly code?: unknown; + readonly expected?: unknown; + readonly format?: unknown; + readonly inclusive?: unknown; + readonly keys?: unknown; + readonly maximum?: unknown; + readonly message: string; + readonly minimum?: unknown; + readonly origin?: unknown; + readonly path: readonly PropertyKey[]; + readonly pattern?: unknown; + readonly values?: unknown; +} + +const isSchemaIssue = (value: unknown): value is SchemaIssueLike => + typeof value === 'object' + && value !== null + && typeof (value as { readonly message?: unknown }).message === 'string' + && Array.isArray((value as { readonly path?: unknown }).path); + +const schemaIssuesOf = (error: unknown): readonly SchemaIssueLike[] | undefined => { + const issues = (error as { readonly issues?: unknown } | null)?.issues; + if (!Array.isArray(issues) || issues.length === 0 || !issues.every(isSchemaIssue)) return undefined; + return issues; +}; + +/** `number <= 55000`, `non-empty string`, `array with at most 8 items`, ... */ +const boundLabel = (origin: unknown, bound: unknown, inclusive: unknown, direction: 'max' | 'min'): string => { + const inclusiveBound = inclusive !== false; + const value = String(bound); + const quantifier = direction === 'max' + ? (inclusiveBound ? 'at most' : 'fewer than') + : (inclusiveBound ? 'at least' : 'more than'); + switch (origin) { + case 'string': + if (direction === 'min' && inclusiveBound && bound === 1) return 'non-empty string'; + return `string with ${quantifier} ${value} characters`; + case 'array': + case 'set': + return `${String(origin)} with ${quantifier} ${value} items`; + default: { + const comparator = direction === 'max' ? (inclusiveBound ? '<=' : '<') : (inclusiveBound ? '>=' : '>'); + return `${typeof origin === 'string' ? origin : 'value'} ${comparator} ${value}`; + } + } +}; + +/** The plain-language expectation of one schema issue; falls back to the schema's message. */ +const expectationOf = (issue: SchemaIssueLike): string => { + switch (issue.code) { + case 'invalid_type': + return typeof issue.expected === 'string' ? issue.expected : issue.message; + case 'too_big': + return boundLabel(issue.origin, issue.maximum, issue.inclusive, 'max'); + case 'too_small': + return boundLabel(issue.origin, issue.minimum, issue.inclusive, 'min'); + case 'invalid_value': + case 'invalid_enum_value': + case 'invalid_literal': + return Array.isArray(issue.values) + ? `one of: ${issue.values.map((value) => JSON.stringify(value)).join(', ')}` + : issue.message; + case 'invalid_format': + case 'invalid_string': + if (issue.format === 'regex' && issue.pattern !== undefined) return `string matching ${String(issue.pattern)}`; + return typeof issue.format === 'string' ? `${issue.format} string` : issue.message; + case 'unrecognized_keys': + return Array.isArray(issue.keys) + ? `no unknown ${issue.keys.length === 1 ? 'key' : 'keys'} ${issue.keys.map((key) => JSON.stringify(key)).join(', ')}` + : issue.message; + default: + return issue.message; + } +}; + +const valueAt = (input: unknown, path: readonly PropertyKey[]): unknown => { + let cursor: unknown = input; + for (const segment of path) { + if (cursor === null || typeof cursor !== 'object') return undefined; + cursor = (cursor as Record)[segment]; + } + return cursor; +}; + +const pathSuffix = (path: readonly PropertyKey[]): string => + path.map((segment) => (typeof segment === 'number' ? `[${String(segment)}]` : `.${String(segment)}`)).join(''); + +/** + * Spells a schema path the way the user typed it: the first segment is the + * schema property the compiler projected onto argv (`--kebab-flag` or + * ``); a projected MCP command's whole input arrived through + * `--input`, so its path renders as `--input.`. + */ +const targetOf = (command: CompiledCliCommand, path: readonly PropertyKey[]): string => { + if (path.length === 0) return 'input'; + if (command.mcp !== undefined) return `--input${pathSuffix(path)}`; + const [head, ...rest] = path; + const option = command.options.find((candidate) => candidate.key === head); + if (option === undefined) return `input${pathSuffix(path)}`; + const spelled = option.positional === undefined ? `--${option.option}` : `<${option.option}>`; + return `${spelled}${pathSuffix(rest)}`; +}; + +/** The one-line human rendering of one input issue. */ +export const cliInputIssueLine = (issue: CliInputIssue): string => + `Invalid value for ${issue.target}: expected ${issue.expected}; received ${ + issue.received === undefined ? 'nothing' : stableJson(issue.received) + }.`; + +/** + * Maps a route module's `inputSchema` failure onto a {@link CliInputError} + * whose issues name the CLI argument, the expectation, and the received + * value (#465). Any other thrown value keeps its message, exactly as before. + * Generated executables, the routed-CLI test harness, and the rendered-command + * harness all call this so every surface reports the same text. + */ +export const cliInputError = ( + command: CompiledCliCommand, + input: Readonly>, + error: unknown, +): CliInputError => { + const schemaIssues = schemaIssuesOf(error); + if (schemaIssues === undefined) { + return new CliInputError(error instanceof Error ? error.message : String(error)); + } + const issues = schemaIssues.map((issue): CliInputIssue => { + const received = valueAt(input, issue.path); + return { + expected: expectationOf(issue), + message: issue.message, + ...(received === undefined ? {} : { received }), + target: targetOf(command, issue.path), + }; + }); + return new CliInputError(issues.map(cliInputIssueLine).join('\n'), issues); +}; + export interface GeneratedCliExecuteContext { /** The raw argv the command consumed, for the provider invocation's `args`. */ readonly args: readonly string[]; @@ -649,6 +815,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro let node = tree; let index = 0; + let parsed: ParsedArgv | undefined; try { if (options.argv[0] === '--version') { writeOut(`${options.name} ${options.version}\n`); @@ -691,7 +858,7 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro writeOut(commandHelp(options.name, command)); return 0; } - const parsed = parseMcpCommandInput(command, parseCommandArgv(command, rest)); + parsed = parseMcpCommandInput(command, parseCommandArgv(command, rest)); signal.throwIfAborted(); if (command.rendered) { if (options.render === undefined) { @@ -729,16 +896,71 @@ export const runGeneratedCliEntry = async (options: RunGeneratedCliOptions): Pro writeErr('Aborted.\n'); return 1; } + const helpPath = node.path.length === 0 ? '' : ` ${node.path.join(' ')}`; + const helpHint = `Run '${options.name}${helpPath} --help' for usage.\n`; + if (error instanceof CliInputError && error.issues.length > 0 && node.command !== undefined) { + writeInputIssues({ + command: node.command, + issues: error.issues, + mode: parsed?.ndjson === true ? 'ndjson' : parsed?.json === true ? 'json' : 'text', + name: options.name, + writeErr, + writeOut, + }); + if (parsed?.json !== true && parsed?.ndjson !== true) writeErr(helpHint); + return 2; + } 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`); - } + if (usage) writeErr(helpHint); return usage ? 2 : 1; } }; +interface InputIssuesReport { + readonly command: CompiledCliCommand; + readonly issues: readonly CliInputIssue[]; + readonly mode: 'json' | 'ndjson' | 'text'; + readonly name: string; + readonly writeErr: (text: string) => void; + readonly writeOut: (text: string) => void; +} + +/** + * Reports input-validation issues (#465) per output mode: plain text on + * stderr, one issue per line and the exact usage line; `--json` keeps stdout + * empty and writes one canonical error object to stderr; `--ndjson` keeps the + * stdout stream machine-only with one canonical error event. + */ +const writeInputIssues = (report: InputIssuesReport): void => { + const usage = commandUsage(report.name, report.command); + switch (report.mode) { + case 'json': + report.writeErr(`${stableJson({ error: { code: cliInputInvalidCode, issues: report.issues, usage } })}\n`); + return; + case 'ndjson': + report.writeOut(`${stableJson({ + error: { + code: cliInputInvalidCode, + issues: report.issues, + message: report.issues.map(cliInputIssueLine).join('\n'), + usage, + }, + sequence: 0, + type: 'error', + })}\n`); + return; + case 'text': + for (const issue of report.issues) report.writeErr(`${cliInputIssueLine(issue)}\n`); + report.writeErr(`${usage}\n`); + return; + default: { + const unreachable: never = report.mode; + throw new TypeError(`Unsupported output mode ${String(unreachable)}.`); + } + } +}; + export interface RunGeneratedRenderedScriptOptions { readonly argv: readonly string[]; /** Opens one rendered run for the script with the mode flags removed from argv. */ diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index f774d557c..8abc5944d 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command, CommanderError, InvalidArgumentError } from 'commander'; +import type { Effect, Layer } from 'effect'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -44,16 +45,19 @@ import { formatInstallResult, formatUninstallResult } from './install/format.ts' import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; +import { makeScopedEffectRuntime } from './effect/boundary.ts'; +import { type CliServices, display, nodeCliServices, writeStderr, writeStdout } from './effect/terminal.ts'; declare const __AGENT_BUNDLE_VERSION__: string; -interface Output { - write(chunk: string): unknown; -} - -export interface CliStreams { - readonly stderr?: Output; - readonly stdout?: Output; +/** + * The CLI's terminal services (#465 / Effect Terminal adoption). User-facing + * text is written through `Terminal.display`; diagnostics and machine output + * through `Stdio`. The process-backed Node layers are the default; tests + * provide a capture layer instead of spying on `process.stdout`. + */ +export interface CliOutput { + readonly services?: Layer.Layer; } interface CliSignalSource { @@ -286,40 +290,36 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => { }]; }; -const writeMachine = (output: Output, result: unknown): void => { - output.write(`${stableJson(result === undefined ? null : result)}\n`); -}; +/** One canonical JSON line: the `--json` document on stdout, or the diagnostics document on stderr. */ +const machineLine = (result: unknown): string => `${stableJson(result === undefined ? null : result)}\n`; -const writeHumanBuild = (output: Output, result: Awaited>): void => { - // Errors abort the command before this writer runs, so any diagnostics +const humanBuild = (result: Awaited>): string => { + const out: string[] = []; + // Errors abort the command before this formatter runs, so any diagnostics // reaching it are informational nudges or host-validation warnings. for (const diagnostic of result.diagnostics) { - output.write(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); + out.push(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); } - output.write(`Built ${result.model.metadata.name} to ${result.build.outputRoot}\n`); + out.push(`Built ${result.model.metadata.name} to ${result.build.outputRoot}\n`); for (const report of result.hostValidation ?? []) { - output.write( + out.push( `Host validation (${report.target}): ${report.status}` + `${report.version === undefined ? '' : ` (Claude Code ${report.version})`}` + `${report.load === undefined ? '' : `, load check ${report.load.status}`}\n`, ); } if (result.packageBuild !== undefined) { - output.write(`Package build (${result.packageBuild.files.length} file(s)) at ${result.packageBuild.outputRoot}\n`); + out.push(`Package build (${result.packageBuild.files.length} file(s)) at ${result.packageBuild.outputRoot}\n`); } + return out.join(''); }; -const writeHumanPrepack = (output: Output, result: Awaited>): void => { - output.write( - `Prepack validated ${result.pack.files.length} file(s) for ${result.build.model.metadata.name}\n`, - ); -}; +const humanPrepack = (result: Awaited>): string => + `Prepack validated ${result.pack.files.length} file(s) for ${result.build.model.metadata.name}\n`; const shortContentHash = (hash: string): string => hash.slice(0, 12); -const writeHumanInstall = (output: Output, result: InstallResult): void => { - output.write(formatInstallResult(result)); -}; +const humanInstall = (result: InstallResult): string => formatInstallResult(result); const writeHumanUninstall = (output: Output, result: UninstallResult): void => { output.write(formatUninstallResult(result)); @@ -372,11 +372,12 @@ const formatByteSize = (bytes: number): string => { return `${(kibibytes / 1024).toFixed(1).replace(/\.0$/u, '')} MiB`; }; -const writeHumanDoctor = (output: Output, result: DoctorReport): void => { +const humanDoctor = (result: DoctorReport): string => { + const out: string[] = []; for (const host of result.hosts) { const detail = host.probe.version ?? host.probe.evidence; - output.write(`${host.host}: ${host.probe.status}${detail === undefined ? '' : ` (${detail})`}\n`); - output.write( + out.push(`${host.host}: ${host.probe.status}${detail === undefined ? '' : ` (${detail})`}\n`); + out.push( ` inventory: ${host.inventory.status}` + `${host.inventory.status === 'known' ? ` (${host.inventory.findings.length} finding(s))` : ''}\n`, ); @@ -384,9 +385,9 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => { const identity = host.bundle.name === undefined ? '' : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; - output.write(` bundle:${identity} ${host.bundle.state}\n`); + out.push(` bundle:${identity} ${host.bundle.state}\n`); if (host.bundle.comparison !== undefined) { - output.write(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); + out.push(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); } for (const validation of host.bundle.hostValidation ?? []) { output.write( @@ -412,72 +413,74 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => { if (uniqueReports.length > 0) { const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); - output.write( + out.push( ` durable state: ${stores} ${stores === 1 ? 'store' : 'stores'}, ${formatByteSize(bytes)}\n`, ); } } - output.write( + out.push( `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + `${result.endpoints.summary.staleSockets} stale socket(s), ` + `${result.endpoints.summary.staleLocks} stale lock(s)\n`, ); for (const entry of result.diagnostics) { - output.write(`${entry.code}: ${entry.message}\nRecovery: ${entry.recovery}\n`); + out.push(`${entry.code}: ${entry.message}\nRecovery: ${entry.recovery}\n`); } - output.write( + out.push( `Doctor summary: ${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ` + `${result.summary.infos} info(s)\n`, ); + return out.join(''); }; -const writeHumanInspect = (output: Output, result: Awaited>): void => { +const humanInspect = (result: Awaited>): string => { + const out: string[] = []; if (result.state === 'invalid') { for (const diagnostic of result.diagnostics) { - output.write(`${diagnostic.code}: ${diagnostic.message}\nRecovery: ${diagnostic.recovery}\n`); + out.push(`${diagnostic.code}: ${diagnostic.message}\nRecovery: ${diagnostic.recovery}\n`); } - return; + return out.join(''); } if (result.selected?.bundler !== undefined) { // The bundler focus is a debugging dump: the full synthesized // configuration is the human output, not a one-line summary. - output.write(`${JSON.stringify(result.selected.bundler, null, 2)}\n`); - return; + out.push(`${JSON.stringify(result.selected.bundler, null, 2)}\n`); + return out.join(''); } if (result.selected?.routes !== undefined) { // The route focus follows the bundler contract: the compiled graph is // the human output, not a one-line summary. - output.write(`${JSON.stringify(result.selected.routes, null, 2)}\n`); - return; + out.push(`${JSON.stringify(result.selected.routes, null, 2)}\n`); + return out.join(''); } if (result.selected?.state !== undefined) { - output.write(`${JSON.stringify(result.selected.state, null, 2)}\n`); - return; + out.push(`${JSON.stringify(result.selected.state, null, 2)}\n`); + return out.join(''); } - output.write(`Inspected ${result.model.metadata.name}: ${result.plans.map((plan) => plan.target).join(', ')}\n`); + out.push(`Inspected ${result.model.metadata.name}: ${result.plans.map((plan) => plan.target).join(', ')}\n`); // Release identity is derived from package.json (issue #94); a project // without a package version gets a clearly labeled development fallback. if (result.projectContext.packageName !== undefined) { - output.write(`Package: ${result.projectContext.packageName}\n`); + out.push(`Package: ${result.projectContext.packageName}\n`); } - output.write(`Version: ${projectVersionLabel(result.projectContext)}\n`); + out.push(`Version: ${projectVersionLabel(result.projectContext)}\n`); if (result.model.state !== undefined) { const driver = result.model.state.lifetime === 'workspace-durable' ? 'sqlite' : 'memory'; - output.write(`state: ${result.model.state.id} (${result.model.state.lifetime}, ${driver} driver)\n`); + out.push(`state: ${result.model.state.id} (${result.model.state.lifetime}, ${driver} driver)\n`); } // Per-target component accounting: what each host emits and, for every // omission, whether the author excluded it or the host's pinned capability // judgment (degraded/unavailable/prohibited, with its reason) ruled it out. for (const plan of result.plans) { - output.write(`${plan.target}: ${plan.selected.length} component(s) selected, ${plan.skipped.length} omitted\n`); + out.push(`${plan.target}: ${plan.selected.length} component(s) selected, ${plan.skipped.length} omitted\n`); for (const component of plan.skipped) { - output.write(` omitted ${component.kind} ${component.name}: ${formatInspectionOmission(component)}\n`); + out.push(` omitted ${component.kind} ${component.name}: ${formatInspectionOmission(component)}\n`); } // Feature-set omissions (#100): the component ships, minus a feature the // host's `.` row does not support. for (const component of plan.selected) { for (const omitted of component.omittedFeatures ?? []) { - output.write(` ${component.kind} ${component.name} omits ${omitted.feature}: ${formatCapabilityJudgment(omitted.capability)}\n`); + out.push(` ${component.kind} ${component.name} omits ${omitted.feature}: ${formatCapabilityJudgment(omitted.capability)}\n`); } } // The kind matrix names every canonical kind this host cannot emit, with @@ -486,9 +489,10 @@ const writeHumanInspect = (output: Output, result: Awaited report.capability !== undefined && report.capability.state !== 'supported') .map((report) => `${report.kind} (${report.capability!.state})`); if (unsupportedKinds.length > 0) { - output.write(` kinds this host cannot emit: ${unsupportedKinds.join(', ')}\n`); + out.push(` kinds this host cannot emit: ${unsupportedKinds.join(', ')}\n`); } } + return out.join(''); }; const formatCapabilityJudgment = (capability: InspectionComponentCapability): string => { @@ -525,16 +529,18 @@ const formatInspectionOmission = (component: InspectionSkippedComponent): string const emptyEvalSummary = Object.freeze({ cases: 0, fail: 0, inconclusive: 0, pass: 0, trials: 0 }); /** Inconclusive trials are counted on their own line; they are never reported as failures. */ -const writeHumanEval = (output: Output, result: Awaited>): void => { +const humanEval = (result: Awaited>): string => { + const out: string[] = []; for (const diagnostic of result.diagnostics) { - output.write(`${diagnostic.code}: ${diagnostic.message}\n`); + out.push(`${diagnostic.code}: ${diagnostic.message}\n`); } const summary = result.run.summary ?? emptyEvalSummary; - output.write([ + out.push([ `Evaluated ${summary.cases} case(s) in run ${result.run.id}: `, `${summary.pass} passed, ${summary.fail} failed, ${summary.inconclusive} inconclusive `, `across ${summary.trials} trial(s)\n`, ].join('')); + return out.join(''); }; const signed = (value: number): string => `${value > 0 ? '+' : ''}${value}`; @@ -559,60 +565,86 @@ const formatComparisonDelta = (delta: EvalComparisonDelta): string => [ ...(delta.totalTokens === undefined ? [] : [`tokens ${signed(delta.totalTokens)}`]), ].join(', '); -const writeHumanEvalComparison = (output: Output, result: Awaited>): void => { +const humanEvalComparison = (result: Awaited>): string => { + const out: string[] = []; const { summary } = result; - output.write([ + out.push([ `Compared ${result.baselineRunId} to ${result.candidateRunId}: `, `${summary.comparable} comparable, ${summary.nonComparable} non-comparable `, `(${summary.reliability} reliability, ${summary.smoke} smoke)\n`, ].join('')); for (const row of result.rows) { - output.write(`case ${row.caseId} / host ${row.host} / model ${row.model ?? 'unverified'}\n`); - if (row.baseline !== undefined) output.write(` baseline: ${formatComparisonMetrics(row.baseline)}\n`); - if (row.candidate !== undefined) output.write(` candidate: ${formatComparisonMetrics(row.candidate)}\n`); + out.push(`case ${row.caseId} / host ${row.host} / model ${row.model ?? 'unverified'}\n`); + if (row.baseline !== undefined) out.push(` baseline: ${formatComparisonMetrics(row.baseline)}\n`); + if (row.candidate !== undefined) out.push(` candidate: ${formatComparisonMetrics(row.candidate)}\n`); if (row.comparable) { - output.write(` delta: ${formatComparisonDelta(row.delta)}\n`); + out.push(` delta: ${formatComparisonDelta(row.delta)}\n`); continue; } - for (const cause of row.causes) output.write(` not comparable: ${cause.code}: ${cause.message}\n`); + for (const cause of row.causes) out.push(` not comparable: ${cause.code}: ${cause.message}\n`); } + return out.join(''); }; -const writeHumanValidate = (output: Output, result: Awaited>): void => { +const humanValidate = (result: Awaited>): string => { + const out: string[] = []; // Errors abort the command before this writer runs, so any diagnostics // reaching it are informational nudges or warnings worth surfacing. for (const diagnostic of result.diagnostics) { - output.write(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); + out.push(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); } - output.write(result.diagnostics.some((diagnostic) => diagnostic.severity === 'error') + out.push(result.diagnostics.some((diagnostic) => diagnostic.severity === 'error') ? `Validation reported ${result.diagnostics.length} diagnostic(s)\n` : 'Validation succeeded\n'); + return out.join(''); }; +/** + * Closes the foreground development session on SIGINT/SIGTERM. Returns a + * promise that settles once a signal has closed the session (so the caller + * can keep the terminal services alive until the close diagnostics, if any, + * have been written); it never settles when no signal arrives. + */ const closeForegroundOnSignal = ( session: Pick>, 'close'>, signals: CliSignalSource, - stderr: Output, -): void => { + writeDiagnostics: (text: string) => Promise, +): Promise => new Promise((settle) => { const terminationSignals = ['SIGINT', 'SIGTERM'] as const; let closing: Promise | undefined; const close = (): void => { - closing ??= session.close().catch((error: unknown) => { - writeMachine(stderr, diagnosticsFor(error)); - }).finally(() => { + closing ??= session.close().catch((error: unknown) => writeDiagnostics(machineLine(diagnosticsFor(error)))).finally(() => { for (const signal of terminationSignals) signals.removeListener(signal, close); + settle(); }); }; for (const signal of terminationSignals) signals.once(signal, close); -}; +}); + +/** Commander writes help and argv errors synchronously; the CLI queues them and replays them through the terminal services in order. */ +interface QueuedWrite { + readonly stream: 'stderr' | 'stdout'; + readonly text: string; +} export const runCli = async ( args: string[], - streams: CliStreams = {}, + output: CliOutput = {}, dependencies: CliDependencies = {}, ): Promise => { - const stdout = streams.stdout ?? process.stdout; - const stderr = streams.stderr ?? process.stderr; + // The terminal services are provided exactly once, here at the composition + // root; every write below runs through the boundary against this runtime. + const runtime = makeScopedEffectRuntime(output.services ?? nodeCliServices); + const run = (effect: Effect.Effect): Promise => runtime.run(effect); + const machine = (result: unknown): Promise => run(writeStdout(machineLine(result))); + const diagnostics = (text: string): Promise => run(writeStderr(text)); + const queued: QueuedWrite[] = []; + const flushQueued = async (): Promise => { + for (const entry of queued.splice(0)) { + await (entry.stream === 'stdout' ? run(display(entry.text)) : diagnostics(entry.text)); + } + }; + let foreground: Promise | undefined; let exitCode = 0; const program = new Command(); program @@ -621,8 +653,8 @@ export const runCli = async ( .exitOverride() .showHelpAfterError(false) .configureOutput({ - writeErr: (chunk) => stderr.write(chunk), - writeOut: (chunk) => stdout.write(chunk), + writeErr: (chunk) => void queued.push({ stream: 'stderr', text: chunk }), + writeOut: (chunk) => void queued.push({ stream: 'stdout', text: chunk }), }); const devCommand = program.command('dev').description('Serve the packaged development workbench on loopback') @@ -642,8 +674,8 @@ export const runCli = async ( ...(options.port === undefined ? {} : { port: options.port }), root: options.root, }); - stdout.write(`Development workbench at ${session.url}\n`); - closeForegroundOnSignal(session, dependencies.signals ?? process, stderr); + await run(display(`Development workbench at ${session.url}\n`)); + foreground = closeForegroundOnSignal(session, dependencies.signals ?? process, diagnostics); }); const devProxyCommand = devCommand.command('proxy') @@ -653,13 +685,17 @@ export const runCli = async ( .option('--url ', 'Explicit loopback development server origin'); devProxyCommand.action(async (options: DevProxyCommandOptions) => { const proxy = dependencies.runHostMcpProxy ?? (await import('./dev/host-mcp-proxy.ts')).runHostMcpProxy; + // The bridge reports at arbitrary times over its lifetime; a serial chain + // keeps its stderr lines in order and lets the action await the last one. + let pending = Promise.resolve(); exitCode = await proxy({ projectRoot: devCommand.opts().root, serverName: options.server, target: options.target, ...(options.url === undefined ? {} : { url: options.url }), - writeDiagnostic: (message) => { stderr.write(`${message}\n`); }, + writeDiagnostic: (message) => { pending = pending.then(() => diagnostics(`${message}\n`)); }, }); + await pending; }); const buildCommand = configureSourceOptions( @@ -681,8 +717,7 @@ export const runCli = async ( if (result.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { throw new DiagnosticError(result.diagnostics); } - if (options.json === true) writeMachine(stdout, result); - else writeHumanBuild(stdout, result); + await (options.json === true ? machine(result) : run(display(humanBuild(result)))); }); const prepackCommand = configureSourceOptions( @@ -695,8 +730,7 @@ export const runCli = async ( output: options.output, packageOutputs: true, }); - if (options.json === true) writeMachine(stdout, result); - else writeHumanPrepack(stdout, result); + await (options.json === true ? machine(result) : run(display(humanPrepack(result)))); }); const installCommand = program.command('install') @@ -724,8 +758,7 @@ export const runCli = async ( ...(options.mode === undefined ? {} : { mode: options.mode }), scope: installScope(options.scope), }); - if (options.json === true) writeMachine(stdout, result); - else writeHumanInstall(stdout, result); + await (options.json === true ? machine(result) : run(display(humanInstall(result)))); }); const uninstallCommand = program.command('uninstall') @@ -775,8 +808,7 @@ export const runCli = async ( ...(options.from === undefined ? {} : { from: options.from }), ...(options.host.length === 0 ? {} : { hosts: options.host }), }); - if (options.json === true) writeMachine(stdout, result); - else writeHumanDoctor(stdout, result); + await (options.json === true ? machine(result) : run(display(humanDoctor(result)))); if (result.diagnostics.some((entry) => entry.severity === 'error')) exitCode = 1; }); @@ -798,8 +830,7 @@ export const runCli = async ( if (result.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { throw new DiagnosticError(result.diagnostics); } - if (options.json === true) writeMachine(stdout, result); - else writeHumanValidate(stdout, result); + await (options.json === true ? machine(result) : run(display(humanValidate(result)))); }); const evalCommand = configureSourceOptions( @@ -820,8 +851,7 @@ export const runCli = async ( ...(options.suite === undefined || options.suite.length === 0 ? {} : { suites: options.suite }), ...(options.trials === undefined ? {} : { trials: options.trials }), }); - if (options.json === true) writeMachine(stdout, result); - else writeHumanEval(stdout, result); + await (options.json === true ? machine(result) : run(display(humanEval(result)))); const summary = result.run.summary ?? emptyEvalSummary; // Inconclusive trials produced no evidence, so they cannot report success either. if (summary.fail > 0 || summary.inconclusive > 0) exitCode = 1; @@ -840,8 +870,7 @@ export const runCli = async ( baseRunId: baseline, candidateRunId: candidate, }); - if (sourceOptions.json === true) writeMachine(stdout, result); - else writeHumanEvalComparison(stdout, result); + await (sourceOptions.json === true ? machine(result) : run(display(humanEvalComparison(result)))); }); const inspectCommand = configureInspectOptions( @@ -873,8 +902,7 @@ export const runCli = async ( ...(options.state === true ? { focus: 'state' as const } : {}), ...(options.target === undefined ? {} : { target: options.target }), }); - if (options.json === true) writeMachine(stdout, result); - else writeHumanInspect(stdout, result); + await (options.json === true ? machine(result) : run(display(humanInspect(result)))); if (result.state === 'invalid') exitCode = 1; }); @@ -890,8 +918,7 @@ export const runCli = async ( server: options.server, target: options.target, }); - if (options.json === true) writeMachine(stdout, result); - else stdout.write(`Listed ${result.tools.length} tool(s) from ${options.server}\n`); + await (options.json === true ? machine(result) : run(display(`Listed ${result.tools.length} tool(s) from ${options.server}\n`))); }); const mcpInvokeCommand = configureArtifactOptions( @@ -915,8 +942,7 @@ export const runCli = async ( target: options.target, tool: options.tool, }); - if (options.json === true) writeMachine(stdout, result); - else stdout.write(`Invoked ${options.tool} on ${options.server}\n`); + await (options.json === true ? machine(result) : run(display(`Invoked ${options.tool} on ${options.server}\n`))); }); const mcpRunCommand = configureArtifactOptions( @@ -957,8 +983,7 @@ export const runCli = async ( hooksListCommand.action(async (options: ArtifactCommandOptions) => { const { listHooks } = await import('./api.ts'); const result = await listHooks({ ...artifactOptions(options), target: options.target }); - if (options.json === true) writeMachine(stdout, result); - else stdout.write(`Listed ${result.length} hook(s)${options.target === undefined ? '' : ` from ${options.target}`}\n`); + await (options.json === true ? machine(result) : run(display(`Listed ${result.length} hook(s)${options.target === undefined ? '' : ` from ${options.target}`}\n`))); }); const hooksSimulateCommand = configureArtifactOptions( @@ -979,19 +1004,25 @@ export const runCli = async ( input: await parseJsonObject(options), target: options.target, }); - if (options.json === true) writeMachine(stdout, result); - else stdout.write(`Simulated ${options.hook}\n`); + await (options.json === true ? machine(result) : run(display(`Simulated ${options.hook}\n`))); }); try { await program.parseAsync(args, { from: 'user' }); + await flushQueued(); return exitCode; } catch (error) { + await flushQueued(); if (error instanceof CommanderError) { return error.exitCode === 0 ? 0 : 2; } - writeMachine(stderr, diagnosticsFor(error)); + await diagnostics(machineLine(diagnosticsFor(error))); return 1; + } finally { + // A foreground session outlives this call; its close diagnostics still + // need the services, so the runtime follows the session instead. + if (foreground === undefined) await runtime.close(); + else void foreground.then(() => runtime.close()); } }; diff --git a/packages/agent-bundle/src/effect/terminal.ts b/packages/agent-bundle/src/effect/terminal.ts new file mode 100644 index 000000000..5d25eaec5 --- /dev/null +++ b/packages/agent-bundle/src/effect/terminal.ts @@ -0,0 +1,46 @@ +import * as NodeStdio from '@effect/platform-node-shared/NodeStdio'; +import * as NodeTerminal from '@effect/platform-node-shared/NodeTerminal'; +import { Effect, Layer, type PlatformError, Stdio, Stream, Terminal } from 'effect'; + +/** + * The first-party CLI's terminal seam. User-facing text reaches stdout only + * through Effect's `Terminal` service (`terminal.display`); diagnostics reach + * stderr and machine output (canonical JSON) reaches stdout byte-exact through + * the `Stdio` service. Nothing in this package outside the generated-artifact + * shells writes to `process.stdout` / `process.stderr` for user-facing text. + * + * The Node layers are provided exactly once, at the CLI composition root + * (`src/cli.ts`), right before the boundary runs the program; tests provide a + * capture layer instead. Emitted artifacts (routed CLI bins, hook wrappers, + * installers, MCP entries) never import this module: they stay self-contained + * and keep their raw stream adapters. See `docs/effect-conventions.md`. + */ + +/** The services a CLI output program needs. */ +export type CliServices = Stdio.Stdio | Terminal.Terminal; + +/** + * Node-backed `Terminal` + `Stdio`. Composed from the two narrow layers, not + * `NodeServices.layer`: the aggregate barrel also loads the child-process, + * crypto, and filesystem services plus `undici`, which the CLI never uses and + * which would quadruple its startup cost. + */ +export const nodeCliServices: Layer.Layer = Layer.mergeAll(NodeTerminal.layer, NodeStdio.layer); + +/** Writes user-facing text to stdout through the `Terminal` service. */ +export const display = Effect.fnUntraced(function* (text: string): Effect.fn.Return { + const terminal = yield* Terminal.Terminal; + yield* terminal.display(text); +}); + +/** Writes machine output (canonical JSON, NDJSON) to stdout byte-exact through the `Stdio` service. */ +export const writeStdout = Effect.fnUntraced(function* (text: string): Effect.fn.Return { + const stdio = yield* Stdio.Stdio; + yield* Stream.run(Stream.make(text), stdio.stdout()); +}); + +/** Writes a diagnostic to stderr through the `Stdio` service. */ +export const writeStderr = Effect.fnUntraced(function* (text: string): Effect.fn.Return { + const stdio = yield* Stdio.Stdio; + yield* Stream.run(Stream.make(text), stdio.stderr()); +}); diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts index c8c0e880b..46731be30 100644 --- a/packages/agent-bundle/src/test/cli.ts +++ b/packages/agent-bundle/src/test/cli.ts @@ -20,7 +20,7 @@ import type * as AgentRuntime from '@agent-bundle/runtime'; import type { RegisteredRouteId } from '@agent-bundle/runtime'; -import { CliInputError, runGeneratedCliEntry } from '../cli-entry.ts'; +import { cliInputError, runGeneratedCliEntry } from '../cli-entry.ts'; import type { CliRenderedEvent } from '../cli-entry.ts'; import { createProviderProcessLifetime } from '../routes/provider-execution.ts'; import type { CompiledCliCommand } from '../routes/types.ts'; @@ -242,7 +242,7 @@ export const invokeCli = async ( try { parsed = module.inputSchema.parse(input); } catch (error) { - throw new CliInputError(error instanceof Error ? error.message : String(error)); + throw cliInputError(command, input, error); } const root = process.cwd(); // Same provider invocation the generated plain-command path builds (#366). diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 58ef0450b..1913456c2 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -23,7 +23,7 @@ import type { } from '@agent-bundle/runtime'; import type * as React from 'react'; -import { CliInputError } from '../cli-entry.ts'; +import { cliInputError } from '../cli-entry.ts'; import type { CliRenderedEvent, GeneratedCliRenderContext, @@ -863,7 +863,7 @@ export const prepareCliRenderHost = async ( try { parsed = module.inputSchema.parse(input); } catch (error) { - throw new CliInputError(error instanceof Error ? error.message : String(error)); + throw cliInputError(command, input, error); } const commandName = command.path.join(' '); const invocation: AgentRenderInvocation = command.mcp === undefined diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 895aa8351..7a59ed108 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -246,9 +246,31 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // Usage and input-validation failures exit 2 with diagnostics on stderr only. await expect(execFile(binPath, ['unknown'])).rejects.toMatchObject({ code: 2, stdout: '' }); + // The packed executable spells a schema rejection in CLI terms (#465): the + // argument, the expectation, the received value, then the usage line — + // never the raw zod issue JSON. 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') }); + await expect(tooMany).rejects.toMatchObject({ + code: 2, + stderr: [ + 'Invalid value for : expected array with at most 8 items; received ["a","b","c","d","e","f","g","h","i"].', + 'Usage: cli-bin-fixture library audit [options] ', + "Run 'cli-bin-fixture library audit --help' for usage.", + '', + ].join('\n'), + stdout: '', + }); + const tooManyJson = execFile(binPath, ['library', 'audit', '--max-findings', '-1', 'a', '--json']); + await expect(tooManyJson).rejects.toMatchObject({ code: 2, stdout: '' }); + await tooManyJson.catch((failure: { readonly stderr: string }) => { + expect(JSON.parse(failure.stderr)).toEqual({ + error: { + code: 'CLI_INPUT_INVALID', + issues: [{ expected: 'number >= 0', message: expect.any(String), received: -1, target: '--max-findings' }], + usage: 'Usage: cli-bin-fixture library audit [options] ', + }, + }); + }); // The rendered .tsx command (#102 stage 3) renders through the dispatcher // against the sibling react-server worker. diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index ee3c6cd54..b73474f67 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -3,10 +3,12 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; import { inspect } from '../src/api.ts'; import { CliInputError, + cliInputError, projectCliDocumentToMarkdown, runGeneratedCliEntry, runGeneratedRenderedScript, @@ -868,6 +870,114 @@ describe('generated CLI shell', () => { expect(invalid.stderr).toContain('for usage'); }); + describe('input-validation failures (#465)', () => { + const doctor = commands[0]!; + const audit = commands[1]!; + const doctorSchema = z.object({ + maxFiles: z.number().int().max(55_000).default(8), + root: z.string().min(1), + verbose: z.boolean().optional(), + }).strict(); + const failure = (schema: z.ZodType, input: Readonly>): unknown => { + const result = schema.safeParse(input); + if (result.success) throw new Error('fixture input unexpectedly valid'); + return result.error; + }; + + it('spells each zod issue as the CLI argument, the expectation, and the received value', () => { + const error = cliInputError(doctor, { maxFiles: 300_000, root: '/library' }, failure(doctorSchema, { maxFiles: 300_000, root: '/library' })); + expect(error).toBeInstanceOf(CliInputError); + expect(error.issues).toEqual([{ + expected: 'number <= 55000', + message: expect.stringContaining('55000'), + received: 300_000, + target: '--max-files', + }]); + expect(error.message).toBe('Invalid value for --max-files: expected number <= 55000; received 300000.'); + expect(error.message).not.toContain('"code"'); + + const positional = cliInputError(doctor, { root: '' }, failure(doctorSchema, { root: '' })); + expect(positional.message).toBe('Invalid value for : expected non-empty string; received "".'); + + const missing = cliInputError(doctor, {}, failure(doctorSchema, {})); + expect(missing.issues).toEqual([{ expected: 'string', message: expect.any(String), target: '' }]); + expect(missing.message).toBe('Invalid value for : expected string; received nothing.'); + + const type = cliInputError(doctor, { maxFiles: 'many', root: '/library' }, failure(doctorSchema, { maxFiles: 'many', root: '/library' })); + expect(type.message).toBe('Invalid value for --max-files: expected number; received "many".'); + }); + + it('maps enum, length, unknown-key, and multi-issue failures one line per issue', () => { + const auditSchema = z.object({ + format: z.enum(['json', 'table']).optional(), + report: z.string(), + sources: z.array(z.string()).max(2), + }).strict(); + const input = { extra: true, format: 'xml', report: 'r', sources: ['a', 'b', 'c'] }; + const error = cliInputError(audit, input, failure(auditSchema, input)); + expect(error.issues.map((issue) => issue.target)).toEqual(['--format', '', 'input']); + expect(error.message.split('\n')).toEqual([ + 'Invalid value for --format: expected one of: "json", "table"; received "xml".', + 'Invalid value for : expected array with at most 2 items; received ["a","b","c"].', + 'Invalid value for input: expected no unknown key "extra"; received {"extra":true,"format":"xml","report":"r","sources":["a","b","c"]}.', + ]); + }); + + it('spells a projected MCP command path as --input.', () => { + const schema = z.object({ message: z.string(), nested: z.object({ count: z.number() }).optional() }); + const input = { message: 42, nested: { count: 'x' } }; + const error = cliInputError(readOnlyTool, input, failure(schema, input)); + expect(error.message.split('\n')).toEqual([ + 'Invalid value for --input.message: expected string; received 42.', + 'Invalid value for --input.nested.count: expected number; received "x".', + ]); + }); + + it('keeps a non-schema failure message-only', () => { + const error = cliInputError(doctor, {}, new Error('root must be absolute')); + expect(error.issues).toEqual([]); + expect(error.message).toBe('root must be absolute'); + }); + + it('prints one line per issue, the exact usage line, and the help hint; exit 2', async () => { + const input = { maxFiles: 300_000, root: '/library' }; + const invalid = await run(['doctor', '/library', '--max-files', '300000'], { + throws: cliInputError(doctor, input, failure(doctorSchema, input)), + }); + expect(invalid.code).toBe(2); + expect(invalid.stdout).toBe(''); + expect(invalid.stderr).toBe([ + 'Invalid value for --max-files: expected number <= 55000; received 300000.', + 'Usage: curator doctor [options] ', + "Run 'curator doctor --help' for usage.", + '', + ].join('\n')); + }); + + it('emits one canonical error object on stderr under --json and keeps stdout empty', async () => { + const input = { maxFiles: 300_000, root: '/library' }; + const invalid = await run(['doctor', '/library', '--max-files', '300000', '--json'], { + throws: cliInputError(doctor, input, failure(doctorSchema, input)), + }); + expect(invalid.code).toBe(2); + expect(invalid.stdout).toBe(''); + expect(invalid.stderr.endsWith('\n')).toBe(true); + expect(invalid.stderr.trimEnd().split('\n')).toHaveLength(1); + expect(JSON.parse(invalid.stderr)).toEqual({ + error: { + code: 'CLI_INPUT_INVALID', + issues: [{ + expected: 'number <= 55000', + message: expect.stringContaining('55000'), + received: 300_000, + target: '--max-files', + }], + usage: 'Usage: curator doctor [options] ', + }, + }); + }); + }); + 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); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index a491c5b61..426b1d17b 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; import { runCli as runSourceCli, type CliDependencies } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { cachedNpmInstallArguments, packOutputFromJson } from './support/shared-pack.ts'; import { timeScale } from './support/time-scale.ts'; @@ -42,14 +43,10 @@ const runSourceCliWithOutput = async ( args: string[], dependencies: CliDependencies = {}, ): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { - const stderr: string[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); - const code = await runSourceCli(args, { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }, dependencies); - return { code, stderr: stderr.join(''), stdout: stdout.join('') }; + const code = await runSourceCli(args, terminal.output, dependencies); + return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; const createCliProject = async (): Promise<{ readonly output: string; readonly root: string }> => { @@ -737,17 +734,13 @@ it('reports a generated Flight worker collision before compiling scripts', async }, 30_000 * timeScale); it('dispatches the install command through the native installer surface', async () => { - const stderr: string[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); const calls: unknown[] = []; Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runSourceCli( ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--json'], - { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }, + terminal.output, { installBundle: async (options: unknown) => { calls.push(options); @@ -764,14 +757,14 @@ it('dispatches the install command through the native installer surface', async ); expect(code).toBe(0); - expect(stderr.join('')).toBe(''); + expect(terminal.stderr()).toBe(''); expect(calls).toEqual([{ from: '/tmp/example bundle', host: 'claude', replace: false, scope: 'project', }]); - expect(JSON.parse(stdout.join(''))).toMatchObject({ + expect(JSON.parse(terminal.stdout())).toMatchObject({ host: 'claude', plugin: 'fixture', state: 'installed', diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 836a1d7c9..d8dc5c3cb 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -9,6 +9,7 @@ import { TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { runCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; import { closeDevServerLifecycle, @@ -1827,7 +1828,7 @@ it('retains sandbox startup and foreground cleanup failures structurally', async }); it('passes --no-open, the requested port, and repeatable dev host installs to the public dev API', async () => { - const stdout: string[] = []; + const terminal = captureCliTerminal(); const received: unknown[] = []; const exitCode = await runCli([ 'dev', @@ -1840,9 +1841,7 @@ it('passes --no-open, the requested port, and repeatable dev host installs to th 'cursor', '--install-host', 'claude', - ], { - stdout: { write: (value) => stdout.push(value) }, - }, { + ], terminal.output, { startDevServer: async (options) => { received.push(options); return { @@ -1861,7 +1860,7 @@ it('passes --no-open, the requested port, and repeatable dev host installs to th port: 4100, root: '/project', })]); - expect(stdout.join('')).toBe('Development workbench at http://127.0.0.1:4100\n'); + expect(terminal.stdout()).toBe('Development workbench at http://127.0.0.1:4100\n'); }); it('passes explicit Agent API enablement and disablement through the dev CLI', async () => { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 95453c079..7844787a9 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -9,6 +9,7 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; import { runCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; import { installBundle } from '../src/install/install.ts'; import { emptyContentHash, installReceiptFile, installReceiptFormat, installReceiptScopeKey, treeInventory } from '../src/install/receipt.ts'; @@ -540,22 +541,14 @@ it('inventories durable SQLite stores and sidecars without opening them', async summary: { bytes: 15, stores: 1 }, }); - const human: string[] = []; - const humanCode = await runCli( - ['doctor'], - { stdout: { write: (chunk: string) => human.push(chunk) } }, - { runDoctor: async () => report }, - ); + const human = captureCliTerminal(); + const humanCode = await runCli(['doctor'], human.output, { runDoctor: async () => report }); expect(humanCode).toBe(0); - expect(human.join('')).toContain('durable state: 1 store, 15 B'); + expect(human.stdout()).toContain('durable state: 1 store, 15 B'); - const json: string[] = []; - await runCli( - ['doctor', '--json'], - { stdout: { write: (chunk: string) => json.push(chunk) } }, - { runDoctor: async () => report }, - ); - expect(JSON.parse(json.join('')).hosts[0].inventory.findings[0].durableState).toMatchObject({ + const json = captureCliTerminal(); + await runCli(['doctor', '--json'], json.output, { runDoctor: async () => report }); + expect(JSON.parse(json.stdout()).hosts[0].inventory.findings[0].durableState).toMatchObject({ findings: [{ bytes: 15, file: store }], summary: { bytes: 15, stores: 1 }, }); @@ -2571,7 +2564,7 @@ const cliReport = ( }); it('prints human Doctor output and exits zero for warnings', async () => { - const stdout: string[] = []; + const terminal = captureCliTerminal(); const report = cliReport([{ code: 'AB7314', message: 'Stale endpoint.', @@ -2584,20 +2577,16 @@ it('prints human Doctor output and exits zero for warnings', async () => { probe: Object.freeze({ evidence: 'directory', status: 'available' }), receipts: Object.freeze([]), }]); - const code = await runCli( - ['doctor'], - { stdout: { write: (chunk: string) => stdout.push(chunk) } }, - { runDoctor: async () => report }, - ); + const code = await runCli(['doctor'], terminal.output, { runDoctor: async () => report }); expect(code).toBe(0); - expect(stdout.join('')).toContain('cursor: available (directory)'); - expect(stdout.join('')).toContain('AB7314: Stale endpoint.'); - expect(stdout.join('')).toContain('Recovery: Remove it manually.'); - expect(stdout.join('')).toContain('Doctor summary: 0 error(s), 1 warning(s), 0 info(s)'); + expect(terminal.stdout()).toContain('cursor: available (directory)'); + expect(terminal.stdout()).toContain('AB7314: Stale endpoint.'); + expect(terminal.stdout()).toContain('Recovery: Remove it manually.'); + expect(terminal.stdout()).toContain('Doctor summary: 0 error(s), 1 warning(s), 0 info(s)'); }); it('prints one stable JSON report, forwards filters, and gates only errors', async () => { - const stdout: string[] = []; + const terminal = captureCliTerminal(); const calls: unknown[] = []; const report = cliReport([{ code: 'AB7301', @@ -2607,7 +2596,7 @@ it('prints one stable JSON report, forwards filters, and gates only errors', asy }]); const code = await runCli( ['doctor', '--host', 'claude', '--host', 'cursor', '--from', '/bundle', '--json'], - { stdout: { write: (chunk: string) => stdout.push(chunk) } }, + terminal.output, { runDoctor: async (options) => { calls.push(options); @@ -2617,18 +2606,15 @@ it('prints one stable JSON report, forwards filters, and gates only errors', asy ); expect(code).toBe(1); expect(calls).toEqual([{ from: '/bundle', hosts: ['claude', 'cursor'] }]); - expect(JSON.parse(stdout.join(''))).toEqual(report); - expect(stdout.join('').trim()).toBe(JSON.stringify(JSON.parse(stdout.join('')))); + expect(JSON.parse(terminal.stdout())).toEqual(report); + expect(terminal.stdout().trim()).toBe(JSON.stringify(JSON.parse(terminal.stdout()))); }); it('rejects an invalid Doctor host as a usage error', async () => { - const stderr: string[] = []; - const code = await runCli( - ['doctor', '--host', 'portable'], - { stderr: { write: (chunk: string) => stderr.push(chunk) } }, - ); + const terminal = captureCliTerminal(); + const code = await runCli(['doctor', '--host', 'portable'], terminal.output); expect(code).toBe(2); - expect(stderr.join('')).toContain('Doctor host must be claude, codex, or cursor.'); + expect(terminal.stderr()).toContain('Doctor host must be claude, codex, or cursor.'); }); const writeHookedCursorPlugin = async (pluginRoot: string, version = '1.2.3'): Promise => { diff --git a/packages/agent-bundle/tests/eval-cli.test.ts b/packages/agent-bundle/tests/eval-cli.test.ts index e53079d64..6ef9eb02a 100644 --- a/packages/agent-bundle/tests/eval-cli.test.ts +++ b/packages/agent-bundle/tests/eval-cli.test.ts @@ -9,6 +9,7 @@ import { runCli } from '../src/cli.ts'; import { createEvalRun, type EvalTrialRecordInput } from '../src/eval/index.ts'; import type { EvalRunResult } from '../src/dev/eval/eval-service.ts'; import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { seedEvalProject } from './support/eval-project.ts'; const runCliWithOutput = async (args: readonly string[]): Promise<{ @@ -16,14 +17,10 @@ const runCliWithOutput = async (args: readonly string[]): Promise<{ readonly stderr: string; readonly stdout: string; }> => { - const stderr: string[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); - const code = await runCli([...args], { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }); - return { code, stderr: stderr.join(''), stdout: stdout.join('') }; + const code = await runCli([...args], terminal.output); + return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; interface PersistComparisonRunOptions { diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 8b4a16595..e5ef21c5f 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -7,6 +7,7 @@ import { expect, it } from '@rstest/core'; import { runCli } from '../src/cli.ts'; import { agentStateDefaultBudgets } from '../src/core/state-inspection.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; it('keeps static inspection defaults aligned with the runtime package', () => { expect(agentStateDefaultBudgets).toEqual({ @@ -39,17 +40,10 @@ const inspectCli = async ( root: string, args: readonly string[], ): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { - const stderr: string[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); - const code = await runCli( - ['inspect', '--root', root, ...args], - { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }, - ); - return { code, stderr: stderr.join(''), stdout: stdout.join('') }; + const code = await runCli(['inspect', '--root', root, ...args], terminal.output); + return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; }; it('inspects volatile and workspace-durable state without inventing runtime paths', async () => { diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 23b42e0d0..9b37ab802 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -22,6 +22,7 @@ import { } from '../src/install/receipt.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; interface CommandCall { readonly args: readonly string[]; @@ -1381,17 +1382,13 @@ it('rejects a Cursor plugin name that could escape the local install root', asyn }); it('dispatches the public CLI install command to the native installer', async () => { - const stderr: string[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); const calls: unknown[] = []; Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runCli( ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--force', '--json'], - { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }, + terminal.output, { installBundle: async (options: unknown) => { calls.push(options); @@ -1408,14 +1405,14 @@ it('dispatches the public CLI install command to the native installer', async () ); expect(code).toBe(0); - expect(stderr.join('')).toBe(''); + expect(terminal.stderr()).toBe(''); expect(calls).toEqual([{ from: '/tmp/example bundle', host: 'claude', replace: true, scope: 'project', }]); - expect(JSON.parse(stdout.join(''))).toMatchObject({ + expect(JSON.parse(terminal.stdout())).toMatchObject({ host: 'claude', plugin: 'fixture', state: 'installed', @@ -1707,8 +1704,6 @@ it('rejects an install mode for hosts other than Cursor', async () => { }); it('passes --mode through the public CLI and prints the staged next steps', async () => { - const stderr: string[] = []; - const stdout: string[] = []; const calls: unknown[] = []; Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const dependencies = { @@ -1729,19 +1724,17 @@ it('passes --mode through the public CLI and prints the staged next steps', asyn }, } as unknown as Parameters[2]; + const terminal = captureCliTerminal(); const code = await runCli( ['install', 'cursor', '--from', '/tmp/example bundle', '--mode', 'marketplace'], - { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }, + terminal.output, dependencies, ); expect(code).toBe(0); - expect(stderr.join('')).toBe(''); + expect(terminal.stderr()).toBe(''); expect(calls).toEqual([{ from: '/tmp/example bundle', host: 'cursor', mode: 'marketplace', replace: false, scope: 'user' }]); - expect(stdout.join('')).toBe([ + expect(terminal.stdout()).toBe([ 'Staged fixture@1.0.0 for cursor (marketplace mode) at /home/user/.cursor/agent-bundle/marketplaces/fixture', 'Marketplace: fixture-marketplace @ abc123', 'Next steps:', @@ -1749,16 +1742,14 @@ it('passes --mode through the public CLI and prints the staged next steps', asyn '', ].join('\n')); + const invalidTerminal = captureCliTerminal(); const invalid = await runCli( ['install', 'cursor', '--from', '/tmp/example bundle', '--mode', 'remote'], - { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: () => undefined }, - }, + invalidTerminal.output, dependencies, ); expect(invalid).not.toBe(0); - expect(stderr.join('')).toContain('Install mode must be local or marketplace.'); + expect(invalidTerminal.stderr()).toContain('Install mode must be local or marketplace.'); }); it('labels a repeated marketplace-mode run as already staged, not installed', () => { diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 541aac033..90ce6847f 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it } from '@rstest/core'; import { build, runMcp } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { mcpServerStateDirectory } from '../src/services/mcp-run.ts'; const execFile = promisify(executeFile); @@ -260,14 +261,11 @@ describe('framework-owned package build', () => { }); await installTypescriptToolchain(root); - const stderr: string[] = []; - const exitCode = await runCli( - ['build', '--root', root, '--output', 'artifact'], - { stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: () => undefined } }, - ); + const terminal = captureCliTerminal(); + const exitCode = await runCli(['build', '--root', root, '--output', 'artifact'], terminal.output); expect(exitCode).toBe(1); - const diagnostics = JSON.parse(stderr.join('')) as readonly { + const diagnostics = JSON.parse(terminal.stderr()) as readonly { code: string; message: string; recovery?: string; @@ -476,13 +474,13 @@ describe('mcp run', () => { }, 120_000); it('rejects --env-file combined with --no-env', async () => { - const stderr: string[] = []; + const terminal = captureCliTerminal(); const exitCode = await runCli( ['mcp', 'run', '--root', '.', '--artifact', 'artifact', '--target', 'portable', '--server', 's', '--env-file', 'x.env', '--no-env'], - { stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: () => undefined } }, + terminal.output, ); expect(exitCode).toBe(1); - expect(stderr.join('')).toContain('Use either --env-file or --no-env, not both.'); + expect(terminal.stderr()).toContain('Use either --env-file or --no-env, not both.'); }); it('runs a built server end to end through the CLI and forwards its exit code', async () => { diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 9122cbffb..3002d42c6 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, expect, it } from '@rstest/core'; import { prepack } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { packInventoryDiagnostics, packOutputFromJson, @@ -104,11 +105,11 @@ it('prepack validates the complete dry-run inventory', async () => { it('exposes --root, --output, and --json through the prepack command', async () => { const calls: unknown[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runCli( ['prepack', '--root', projectRoot, '--output', 'host-packs', '--json'], - { stderr: { write: () => undefined }, stdout: { write: (chunk: string) => stdout.push(chunk) } }, + terminal.output, { prepack: async (options) => { calls.push(options); @@ -122,7 +123,7 @@ it('exposes --root, --output, and --json through the prepack command', async () packageOutputs: true, root: projectRoot, })]); - expect(JSON.parse(stdout.join(''))).toMatchObject({ + expect(JSON.parse(terminal.stdout())).toMatchObject({ build: { model: { metadata: { name: 'installer-fixture' } } }, pack: { files: expect.any(Array) }, }); diff --git a/packages/agent-bundle/tests/projection/cli-input-errors.test.ts b/packages/agent-bundle/tests/projection/cli-input-errors.test.ts new file mode 100644 index 000000000..7337dee08 --- /dev/null +++ b/packages/agent-bundle/tests/projection/cli-input-errors.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from '@rstest/core'; + +import { cliNdjson, invokeCli } from '../../src/test/cli.ts'; + +/** + * Issue #465 at the `cli-dispatch` proof level: a route module's `inputSchema` + * rejection must reach the operator as plain language that names the argument + * they typed, never as the raw zod issue JSON. The `--json` and `--ndjson` + * modes stay machine-only and carry the same issues as one structured error. + */ +describe('input-validation failures through the routed CLI shell (#465)', () => { + it('names the flag, the expectation, and the received value, then the exact usage line', async () => { + const run = await invokeCli(['inventory', 'fiction', '--limit', '9']); + + expect(run.exitCode).toBe(2); + expect(run.stdout).toBe(''); + expect(run.value).toBeUndefined(); + expect(run.stderr).toBe([ + 'Invalid value for --limit: expected number <= 8; received 9.', + 'Usage: route-harness inventory [options] ', + "Run 'route-harness inventory --help' for usage.", + '', + ].join('\n')); + expect(run.stderr).not.toContain('"code"'); + expect(run.stderr).not.toContain('too_big'); + }); + + it('spells a positional as and lists several issues one per line', async () => { + const run = await invokeCli(['inventory', '', '--limit', '0']); + + expect(run.exitCode).toBe(2); + expect(run.stdout).toBe(''); + expect(run.stderr.split('\n')).toEqual([ + 'Invalid value for --limit: expected number >= 1; received 0.', + 'Invalid value for : expected non-empty string; received "".', + 'Usage: route-harness inventory [options] ', + "Run 'route-harness inventory --help' for usage.", + '', + ]); + }); + + it('spells a projected MCP command input path as --input. through the rendered path', async () => { + const run = await invokeCli(['harness', 'echo', '--input', '{"message":42}']); + + expect(run.exitCode).toBe(2); + expect(run.stdout).toBe(''); + expect(run.stderr).toBe([ + 'Invalid value for --input.message: expected string; received 42.', + 'Usage: route-harness harness echo [options]', + "Run 'route-harness harness echo --help' for usage.", + '', + ].join('\n')); + }); + + it('keeps stdout empty under --json and writes one canonical error object to stderr', async () => { + const run = await invokeCli(['inventory', 'fiction', '--limit', '9', '--json']); + + expect(run.exitCode).toBe(2); + expect(run.stdout).toBe(''); + expect(run.stderr.trimEnd().split('\n')).toHaveLength(1); + expect(JSON.parse(run.stderr)).toEqual({ + error: { + code: 'CLI_INPUT_INVALID', + issues: [{ + expected: 'number <= 8', + message: expect.stringContaining('8'), + received: 9, + target: '--limit', + }], + usage: 'Usage: route-harness inventory [options] ', + }, + }); + }); + + it('keeps the --ndjson stream machine-only with one canonical error event', async () => { + const run = await invokeCli(['harness', 'echo', '--input', '{"message":42}', '--ndjson']); + + expect(run.exitCode).toBe(2); + expect(run.stderr).toBe(''); + const events = cliNdjson(run); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + error: { + code: 'CLI_INPUT_INVALID', + issues: [{ + expected: 'string', + message: expect.any(String), + received: 42, + target: '--input.message', + }], + message: 'Invalid value for --input.message: expected string; received 42.', + usage: 'Usage: route-harness harness echo [options]', + }, + sequence: 0, + type: 'error', + }); + }); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index b9dde67ac..089fb853a 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -7,6 +7,7 @@ import ts from 'typescript-5'; import { inspect, type ReadyInspectResult, validate } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { discoverProject } from '../src/config/discover.ts'; import type { AgentBundleConfig } from '../src/core/types.ts'; import { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from '../src/routes/graph.ts'; @@ -791,22 +792,16 @@ it('dumps the graph through the CLI --routes focus and rejects ambiguous focuses const root = await createInspectProject({ 'src/mcp/curator/tools/inspect.ts': moduleSource, }); - const stdout: string[] = []; - const code = await runCli(['inspect', '--root', root, '--routes', '--json'], { - stderr: { write: () => undefined }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }); + const terminal = captureCliTerminal(); + const code = await runCli(['inspect', '--root', root, '--routes', '--json'], terminal.output); expect(code).toBe(0); - const document = JSON.parse(stdout.join('')) as ReadyInspectResult; + const document = JSON.parse(terminal.stdout()) as ReadyInspectResult; expect(document.selected?.routes?.servers?.[0]).toMatchObject({ id: 'mcp:curator', mode: 'generated' }); - const stderr: string[] = []; - const ambiguous = await runCli(['inspect', '--root', root, '--routes', '--skills'], { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: () => undefined }, - }); + const ambiguousTerminal = captureCliTerminal(); + const ambiguous = await runCli(['inspect', '--root', root, '--routes', '--skills'], ambiguousTerminal.output); expect(ambiguous).toBe(1); - expect(stderr.join('')).toContain('Choose at most one inspect focus.'); + expect(ambiguousTerminal.stderr()).toContain('Choose at most one inspect focus.'); }); it('evaluates a stateful config factory once when inspecting the routes focus', async () => { diff --git a/packages/agent-bundle/tests/support/cli-terminal.ts b/packages/agent-bundle/tests/support/cli-terminal.ts new file mode 100644 index 000000000..27d9e11aa --- /dev/null +++ b/packages/agent-bundle/tests/support/cli-terminal.ts @@ -0,0 +1,44 @@ +import { Effect, Layer, Sink, Stdio, Terminal } from 'effect'; + +import type { CliOutput } from '../../src/cli.ts'; + +export interface CapturedCliTerminal { + /** Pass as the `runCli` output argument: a capture `Terminal` + `Stdio` layer instead of the process streams. */ + readonly output: CliOutput; + /** Everything written to stderr through `Stdio`. */ + readonly stderr: () => string; + /** Everything written to stdout: `Terminal.display` text and `Stdio` machine output, in order. */ + readonly stdout: () => string; +} + +const chunkText = (chunk: string | Uint8Array): string => + typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk); + +/** + * The test seam for the first-party CLI's terminal I/O: a `Terminal` whose + * `display` appends to a buffer, and a `Stdio` whose stdout/stderr sinks + * append to the same buffers, so tests never spy on `process.stdout`. + * `readLine` replays `lines` and then quits, like a closed stdin. + */ +export const captureCliTerminal = (lines: readonly string[] = []): CapturedCliTerminal => { + const out: string[] = []; + const err: string[] = []; + const scripted = [...lines]; + const terminal = Terminal.make({ + columns: Effect.succeed(80), + display: (text) => Effect.sync(() => void out.push(text)), + readInput: Effect.die('key input is not used by the agent-bundle CLI'), + readLine: Effect.suspend(() => + scripted.length > 0 ? Effect.succeed(scripted.shift()!) : Effect.fail(new Terminal.QuitError({}))), + rows: Effect.succeed(24), + }); + const stdio = Stdio.layerTest({ + stderr: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void err.push(chunkText(chunk)))), + stdout: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void out.push(chunkText(chunk)))), + }); + return Object.freeze({ + output: { services: Layer.merge(Layer.succeed(Terminal.Terminal, terminal), stdio) }, + stderr: () => err.join(''), + stdout: () => out.join(''), + }); +}; diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 116105263..9701ccf95 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -112,6 +112,21 @@ deterministically: | `2` | Usage or input failure. | | `130` / `143` | SIGINT / SIGTERM, which also reach the route's `AbortSignal`. | +When `inputSchema` rejects the parsed argv, the shell spells each issue in CLI terms instead of +printing the raw schema issue JSON: the argument as typed (`--max-files`, ``, or +`--input.` for a projected MCP command), the expectation, and the received value, one line +per issue, then the exact usage line and the `--help` hint — all on stderr, exit code `2`: + +```text +Invalid value for --max-files: expected number <= 55000; received 300000. +Usage: curator doctor [options] +Run 'curator doctor --help' for usage. +``` + +Under `--json` stdout stays empty and stderr carries one canonical +`{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line; under `--ndjson` the +stream carries one `type: "error"` event with the same `error` object. + A `.tsx` command route swaps the default function for an async default Server Component with the same props and renders through the runtime dispatcher against a sibling `dist/bin/-flight.mjs` worker. It gains the four output modes described in diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index cc1febd0c..80abc550f 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -103,6 +103,20 @@ export default async function inspect({ input, signal }: CliRouteProps`,或投影 MCP 命令的 `--input.`)、期望值与 +实际收到的值,每个问题一行,随后是该命令的准确用法行与 `--help` 提示——全部写到 stderr,退出码为 `2`: + +```text +Invalid value for --max-files: expected number <= 55000; received 300000. +Usage: curator doctor [options] +Run 'curator doctor --help' for usage. +``` + +在 `--json` 下 stdout 保持为空,stderr 只输出一行规范化的 +`{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}`;在 `--ndjson` 下,事件流中只包含一个 +携带相同 `error` 对象的 `type: "error"` 事件。 + `.tsx` 命令路由把默认函数换成一个具有相同 props 的 async 默认 Server Component,并通过运行时分发器 针对同级的 `dist/bin/-flight.mjs` worker 渲染。它由此获得 [脚本与资源](./scripts-assets.mdx)中描述的四种输出模式。路由式 CLI 项目需要把 `@agent-bundle/runtime` From 8959bcf2e78bb3e91f518f3ec856cea9f942fd1f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:00:16 +0000 Subject: [PATCH 2/7] chore(changeset): reference #505 --- .changeset/465-cli-terminal-input-errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/465-cli-terminal-input-errors.md b/.changeset/465-cli-terminal-input-errors.md index c25d7baa3..c61d49f49 100644 --- a/.changeset/465-cli-terminal-input-errors.md +++ b/.changeset/465-cli-terminal-input-errors.md @@ -2,4 +2,4 @@ 'agent-bundle': patch --- -Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root by `@effect/platform-node` (`NodeTerminal`/`NodeStdio`, new dependency); `runCli` takes `{ services }` in place of the former stream injection. Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#PR) +Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root by `@effect/platform-node` (`NodeTerminal`/`NodeStdio`, new dependency); `runCli` takes `{ services }` in place of the former stream injection. Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#505) From bd07e2005f8eabeec1aa1129c67c222e929e6aa0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:19:10 +0000 Subject: [PATCH 3/7] fix(cli): format doctor host-validation lines through the human formatter after rebase --- packages/agent-bundle/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 8abc5944d..ad876de3b 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -390,7 +390,7 @@ const humanDoctor = (result: DoctorReport): string => { out.push(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); } for (const validation of host.bundle.hostValidation ?? []) { - output.write( + out.push( ` host validation (${validation.copy} ${validation.pluginDirectory}` + `${validation.scope === undefined ? '' : `, scope ${validation.scope}`}): ${validation.status}\n`, ); From 7b7c42afe8698a164afd6fb789c77b7f8484af64 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:27:17 +0000 Subject: [PATCH 4/7] fix(cli-entry): keep string-refinement operands and exact lengths in input-issue expectations (review) --- packages/agent-bundle/src/cli-entry.ts | 56 ++++++++++++++----- .../agent-bundle/tests/cli-routes.test.ts | 15 +++++ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/agent-bundle/src/cli-entry.ts b/packages/agent-bundle/src/cli-entry.ts index abaa1ea19..f443661cc 100644 --- a/packages/agent-bundle/src/cli-entry.ts +++ b/packages/agent-bundle/src/cli-entry.ts @@ -118,8 +118,10 @@ export class CliInputError extends Error { /** The structural shape of a zod (v3/v4) issue, matched without importing zod: routed executables resolve zod from the consumer project. */ interface SchemaIssueLike { readonly code?: unknown; + readonly exact?: unknown; readonly expected?: unknown; readonly format?: unknown; + readonly includes?: unknown; readonly inclusive?: unknown; readonly keys?: unknown; readonly maximum?: unknown; @@ -128,6 +130,8 @@ interface SchemaIssueLike { readonly origin?: unknown; readonly path: readonly PropertyKey[]; readonly pattern?: unknown; + readonly prefix?: unknown; + readonly suffix?: unknown; readonly values?: unknown; } @@ -143,36 +147,59 @@ const schemaIssuesOf = (error: unknown): readonly SchemaIssueLike[] | undefined return issues; }; -/** `number <= 55000`, `non-empty string`, `array with at most 8 items`, ... */ -const boundLabel = (origin: unknown, bound: unknown, inclusive: unknown, direction: 'max' | 'min'): string => { - const inclusiveBound = inclusive !== false; +/** `number <= 55000`, `non-empty string`, `array with at most 8 items`, `string with exactly 4 characters`, ... */ +const boundLabel = (issue: SchemaIssueLike, direction: 'max' | 'min'): string => { + const bound = direction === 'max' ? issue.maximum : issue.minimum; + const inclusiveBound = issue.inclusive !== false; const value = String(bound); - const quantifier = direction === 'max' - ? (inclusiveBound ? 'at most' : 'fewer than') - : (inclusiveBound ? 'at least' : 'more than'); - switch (origin) { + const quantifier = issue.exact === true + ? 'exactly' + : direction === 'max' + ? (inclusiveBound ? 'at most' : 'fewer than') + : (inclusiveBound ? 'at least' : 'more than'); + switch (issue.origin) { case 'string': - if (direction === 'min' && inclusiveBound && bound === 1) return 'non-empty string'; + if (direction === 'min' && inclusiveBound && bound === 1 && issue.exact !== true) return 'non-empty string'; return `string with ${quantifier} ${value} characters`; case 'array': case 'set': - return `${String(origin)} with ${quantifier} ${value} items`; + return `${String(issue.origin)} with ${quantifier} ${value} items`; default: { - const comparator = direction === 'max' ? (inclusiveBound ? '<=' : '<') : (inclusiveBound ? '>=' : '>'); - return `${typeof origin === 'string' ? origin : 'value'} ${comparator} ${value}`; + const comparator = issue.exact === true + ? '==' + : direction === 'max' ? (inclusiveBound ? '<=' : '<') : (inclusiveBound ? '>=' : '>'); + return `${typeof issue.origin === 'string' ? issue.origin : 'value'} ${comparator} ${value}`; } } }; +/** The string-format refinements keep their operand: the user needs the prefix, suffix, substring, or pattern to fix the value. */ +const formatLabel = (issue: SchemaIssueLike): string => { + switch (issue.format) { + case 'starts_with': + return `string starting with ${JSON.stringify(issue.prefix)}`; + case 'ends_with': + return `string ending with ${JSON.stringify(issue.suffix)}`; + case 'includes': + return `string containing ${JSON.stringify(issue.includes)}`; + case 'regex': + return `string matching ${String(issue.pattern)}`; + case 'url': + return 'URL'; + default: + return issue.message; + } +}; + /** The plain-language expectation of one schema issue; falls back to the schema's message. */ const expectationOf = (issue: SchemaIssueLike): string => { switch (issue.code) { case 'invalid_type': return typeof issue.expected === 'string' ? issue.expected : issue.message; case 'too_big': - return boundLabel(issue.origin, issue.maximum, issue.inclusive, 'max'); + return boundLabel(issue, 'max'); case 'too_small': - return boundLabel(issue.origin, issue.minimum, issue.inclusive, 'min'); + return boundLabel(issue, 'min'); case 'invalid_value': case 'invalid_enum_value': case 'invalid_literal': @@ -181,8 +208,7 @@ const expectationOf = (issue: SchemaIssueLike): string => { : issue.message; case 'invalid_format': case 'invalid_string': - if (issue.format === 'regex' && issue.pattern !== undefined) return `string matching ${String(issue.pattern)}`; - return typeof issue.format === 'string' ? `${issue.format} string` : issue.message; + return formatLabel(issue); case 'unrecognized_keys': return Array.isArray(issue.keys) ? `no unknown ${issue.keys.length === 1 ? 'key' : 'keys'} ${issue.keys.map((key) => JSON.stringify(key)).join(', ')}` diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index b73474f67..4569451d1 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -923,6 +923,21 @@ describe('generated CLI shell', () => { ]); }); + it('keeps the operand of every string refinement in the bounded grammar', () => { + const cases: readonly [schema: z.ZodType, value: string, expected: string][] = [ + [z.object({ root: z.string().startsWith('/') }), 'relative', 'string starting with "/"'], + [z.object({ root: z.string().endsWith('.json') }), 'config.yaml', 'string ending with ".json"'], + [z.object({ root: z.string().includes('@') }), 'nobody', 'string containing "@"'], + [z.object({ root: z.string().regex(/^[a-z]+$/u) }), 'Nope', 'string matching /^[a-z]+$/u'], + [z.object({ root: z.string().length(4) }), 'abc', 'string with exactly 4 characters'], + [z.object({ root: z.url() }), 'not a url', 'URL'], + ]; + for (const [schema, value, expected] of cases) { + const error = cliInputError(doctor, { root: value }, failure(schema, { root: value })); + expect(error.message).toBe(`Invalid value for : expected ${expected}; received ${JSON.stringify(value)}.`); + } + }); + it('spells a projected MCP command path as --input.', () => { const schema = z.object({ message: z.string(), nested: z.object({ count: z.number() }).optional() }); const input = { message: 42, nested: { count: 'x' } }; From e94a7a2ea02394fdfe467f8b685cd04a5f49532c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 03:02:18 +0000 Subject: [PATCH 5/7] test: link only a missing @types/node into packed-consumer fixtures; assert the #465 flag error in the audiobook-curator dispatch proof --- .../tests/route-unit/cli-dispatch.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts b/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts index 92a16e28a..8b02ff5f3 100644 --- a/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts +++ b/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts @@ -189,15 +189,20 @@ describe('audiobook-curator at the CLI dispatch proof level', () => { } }); - it('maps the inspect zod bounds failure to exit 2', async () => { + it('maps the inspect zod bounds failure to a flag error and exit 2 (#465)', async () => { const { library } = await temporaryLibrary(); const run = await invokeCli(['inspect', library, '--max-files', '0']); expect(run.exitCode).toBe(2); expect(run.stdout).toBe(''); - expect(run.stderr).toContain('maxFiles'); - expect(run.stderr).toContain('expected number to be >=1'); - expect(run.stderr).toContain("Run 'audiobook-curator inspect --help' for usage."); + expect(run.stderr).toBe([ + 'Invalid value for --max-files: expected number >= 1; received 0.', + 'Usage: audiobook-curator inspect [options] ', + "Run 'audiobook-curator inspect --help' for usage.", + '', + ].join('\n')); + expect(run.stderr).not.toContain('maxFiles'); + expect(run.stderr).not.toContain('too_small'); }); }); From 23492cfc5a129c184b5ca7e846c62bc1851a5412 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:03:07 +0000 Subject: [PATCH 6/7] feat(create-agent-bundle): route --help and flag-error text through Terminal/Stdio at the NodeServices root; port uninstall output to the CLI's Effect services after rebase --- .changeset/465-cli-terminal-input-errors.md | 3 +- docs/effect-conventions.md | 4 ++ packages/agent-bundle/src/cli.ts | 13 ++-- packages/agent-bundle/tests/uninstall.test.ts | 23 +++---- packages/create-agent-bundle/src/index.ts | 44 +++++++++---- .../tests/cli-text.test.ts | 62 +++++++++++++++++++ 6 files changed, 114 insertions(+), 35 deletions(-) create mode 100644 packages/create-agent-bundle/tests/cli-text.test.ts diff --git a/.changeset/465-cli-terminal-input-errors.md b/.changeset/465-cli-terminal-input-errors.md index c61d49f49..a46474fa6 100644 --- a/.changeset/465-cli-terminal-input-errors.md +++ b/.changeset/465-cli-terminal-input-errors.md @@ -1,5 +1,6 @@ --- 'agent-bundle': patch +'create-agent-bundle': patch --- -Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root by `@effect/platform-node` (`NodeTerminal`/`NodeStdio`, new dependency); `runCli` takes `{ services }` in place of the former stream injection. Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#505) +Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root by `@effect/platform-node` (`NodeTerminal`/`NodeStdio`, new dependency); `runCli` takes `{ services }` in place of the former stream injection. `create-agent-bundle --help` and its flag-error text go through the same services at its existing `NodeServices` root (Clack still renders the prompts). Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#505) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 123325136..bdc6c88bb 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -369,6 +369,10 @@ Wiring rules: subpaths, not the whole `platformLayer`: the CLI's help/version path does not use child-process, crypto, or filesystem services, and loading them measured at roughly +400 ms of startup. +- The scaffolder (`packages/create-agent-bundle/src/index.ts`) uses the same + two services from its existing `NodeServices.layer` root for `--help` + (`Terminal.display`) and flag errors (`Stdio.stderr()`); Clack stays the + prompt renderer and is not replaced by `readLine`. - Keep `display` text explicit about line endings (`\n`); the service writes what it is given. - Tests provide a capture layer (`tests/support/cli-terminal.ts`: diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index ad876de3b..94b6e2b78 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -321,9 +321,7 @@ const shortContentHash = (hash: string): string => hash.slice(0, 12); const humanInstall = (result: InstallResult): string => formatInstallResult(result); -const writeHumanUninstall = (output: Output, result: UninstallResult): void => { - output.write(formatUninstallResult(result)); -}; +const humanUninstall = (result: UninstallResult): string => formatUninstallResult(result); const describeLifecycle = (lifecycle: DoctorLifecycle): string => { const observations = (['placed', 'registered', 'enabled', 'active'] as const).map((stage) => { @@ -396,13 +394,13 @@ const humanDoctor = (result: DoctorReport): string => { ); } if (host.bundle.lifecycle !== undefined) { - output.write(` lifecycle: ${describeLifecycle(host.bundle.lifecycle)}\n`); + out.push(` lifecycle: ${describeLifecycle(host.bundle.lifecycle)}\n`); } } if (host.receipts.length > 0) { - output.write(` receipts: ${host.receipts.length} store receipt(s)\n`); + out.push(` receipts: ${host.receipts.length} store receipt(s)\n`); for (const receipt of host.receipts) { - output.write(` ${receipt.plugin}@${receipt.version} (${receipt.mode}, ${receipt.scope}): ${receipt.state}\n`); + out.push(` ${receipt.plugin}@${receipt.version} (${receipt.mode}, ${receipt.scope}): ${receipt.state}\n`); } } const reports = [ @@ -793,8 +791,7 @@ export const runCli = async ( ...(options.purgeData === undefined ? {} : { purgeData: options.purgeData }), scope: installScope(options.scope), }); - if (options.json === true) writeMachine(stdout, result); - else writeHumanUninstall(stdout, result); + await (options.json === true ? machine(result) : run(display(humanUninstall(result)))); }); const doctorCommand = program.command('doctor') diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 4e03c0346..6166b339b 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -17,6 +17,7 @@ import { readInstallReceiptFile, } from '../src/install/receipt.ts'; import { uninstallBundle, type UninstallResult } from '../src/install/uninstall.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; import { diffTreeSnapshots, snapshotTree, treesIdentical } from './support/tree-snapshot.ts'; interface CommandCall { @@ -1246,8 +1247,7 @@ it('rejects an uninstall mode for hosts other than Cursor before touching anythi }); it('exposes uninstall through the public CLI with every lifecycle flag', async () => { - const stderr: string[] = []; - const stdout: string[] = []; + const terminal = captureCliTerminal(); const calls: unknown[] = []; Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const result: UninstallResult = { @@ -1268,10 +1268,7 @@ it('exposes uninstall through the public CLI with every lifecycle flag', async ( }; const code = await runCli( ['uninstall', 'cursor', '--from', '/tmp/example bundle', '--mode', 'local', '--plan', '--force', '--purge-data', '--confirm-purge', '--json'], - { - stderr: { write: (chunk: string) => stderr.push(chunk) }, - stdout: { write: (chunk: string) => stdout.push(chunk) }, - }, + terminal.output, { uninstallBundle: async (options: unknown) => { calls.push(options); @@ -1280,7 +1277,7 @@ it('exposes uninstall through the public CLI with every lifecycle flag', async ( } as unknown as Parameters[2], ); expect(code).toBe(0); - expect(stderr.join('')).toBe(''); + expect(terminal.stderr()).toBe(''); expect(calls).toEqual([{ confirmPurge: true, force: true, @@ -1291,15 +1288,15 @@ it('exposes uninstall through the public CLI with every lifecycle flag', async ( purgeData: true, scope: 'user', }]); - expect(JSON.parse(stdout.join(''))).toMatchObject({ plugin: 'fixture', state: 'planned' }); + expect(JSON.parse(terminal.stdout())).toMatchObject({ plugin: 'fixture', state: 'planned' }); - const human: string[] = []; + const human = captureCliTerminal(); await runCli( ['uninstall', 'cursor', '--keep-data'], - { stderr: { write: () => undefined }, stdout: { write: (chunk: string) => human.push(chunk) } }, + human.output, { uninstallBundle: async () => result } as unknown as Parameters[2], ); - expect(human.join('')).toContain('Would uninstall fixture@1.0.0 for cursor (local mode)'); - expect(human.join('')).toContain('/home/example/.cursor/plugins/local/fixture/payload.txt'); - expect(human.join('')).toContain('Data (keep): kept'); + expect(human.stdout()).toContain('Would uninstall fixture@1.0.0 for cursor (local mode)'); + expect(human.stdout()).toContain('/home/example/.cursor/plugins/local/fixture/payload.txt'); + expect(human.stdout()).toContain('Data (keep): kept'); }); diff --git a/packages/create-agent-bundle/src/index.ts b/packages/create-agent-bundle/src/index.ts index 8b741c102..5603f9c2a 100644 --- a/packages/create-agent-bundle/src/index.ts +++ b/packages/create-agent-bundle/src/index.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { cancel, intro, isCancel, log, multiselect, note, outro, select, text } from '@clack/prompts'; import * as NodeServices from '@effect/platform-node/NodeServices'; -import { Effect, FileSystem, Path } from 'effect'; +import { Effect, FileSystem, Path, Stdio, Stream, Terminal } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { mapCause, runPromise } from './effect/boundary.ts'; @@ -144,22 +144,40 @@ const scaffoldProgram = Effect.fnUntraced(function* ( }))); }); -export const runCli = async (argv: readonly string[]): Promise<0 | 1 | 2> => { +/** `--help`: user-facing text, so it goes through the `Terminal` service (stdout). */ +const showHelp = Effect.gen(function* () { + const terminal = yield* Terminal.Terminal; + yield* terminal.display(helpText); + return 0 as const; +}); + +/** A flag error: the message and the help text on stderr through `Stdio` (`Terminal.display` is stdout-only). */ +const usageFailure = (error: UsageError) => Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + yield* Stream.run(Stream.make(`${error.message}\n\n${helpText}`), stdio.stderr()); + return 2 as const; +}); + +/** + * The whole CLI as one program over the platform services, so tests can run + * the help and flag-error paths against a capture `Terminal` / `Stdio` layer. + * A non-usage `parseFlags` failure is a bug and keeps throwing. + */ +export const cliProgram = ( + argv: readonly string[], +): Effect.Effect<0 | 1 | 2, PlatformError, FileSystem.FileSystem | Path.Path | Stdio.Stdio | Terminal.Terminal> => { let flags: ParsedFlags; try { flags = parseFlags(argv); } catch (error) { - if (error instanceof UsageError) { - process.stderr.write(`${error.message}\n\n${helpText}`); - return 2; - } + if (error instanceof UsageError) return usageFailure(error); throw error; } - if (flags.help) { - process.stdout.write(helpText); - return 0; - } - // The one composition root: the Node platform services are provided here - // and nowhere else in the package. - return runPromise(Effect.provide(scaffoldProgram(flags), NodeServices.layer)); + return flags.help ? showHelp : scaffoldProgram(flags); }; + +export const runCli = (argv: readonly string[]): Promise<0 | 1 | 2> => + // The one composition root: the Node platform services are provided here + // and nowhere else in the package. Clack stays the prompt renderer; only + // the plain help and flag-error text goes through Terminal / Stdio. + runPromise(Effect.provide(cliProgram(argv), NodeServices.layer)); diff --git a/packages/create-agent-bundle/tests/cli-text.test.ts b/packages/create-agent-bundle/tests/cli-text.test.ts new file mode 100644 index 000000000..135b07054 --- /dev/null +++ b/packages/create-agent-bundle/tests/cli-text.test.ts @@ -0,0 +1,62 @@ +import { Effect, FileSystem, Layer, Path, Sink, Stdio, Terminal } from 'effect'; +import { describe, expect, it } from '@rstest/core'; + +import { runPromise } from '../src/effect/boundary.ts'; +import { cliProgram } from '../src/index.ts'; +import { helpText } from '../src/options.ts'; + +/** + * The scaffolder's plain text — `--help` and a flag error — goes through the + * `Terminal` / `Stdio` services, so it is proven against a capture layer + * rather than by spying on `process.stdout`. Clack renders the prompts and is + * not under test here. + */ + +type CliLayer = Layer.Layer; + +/** + * Terminal and Stdio capture what the CLI writes; the filesystem is a noop + * stub because neither `--help` nor a flag error may touch it (a call would + * fail with `NotFound` and surface as a test failure). + */ +const captureLayer = (): { readonly layer: CliLayer; readonly stderr: () => string; readonly stdout: () => string } => { + const out: string[] = []; + const err: string[] = []; + const decode = (chunk: string | Uint8Array): string => (typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + const terminal = Terminal.make({ + columns: Effect.succeed(80), + display: (text) => Effect.sync(() => void out.push(text)), + readInput: Effect.die('key input is not used by create-agent-bundle'), + readLine: Effect.fail(new Terminal.QuitError({})), + rows: Effect.succeed(24), + }); + const stdio = Stdio.layerTest({ + stderr: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void err.push(decode(chunk)))), + stdout: () => Sink.forEach((chunk: string | Uint8Array) => Effect.sync(() => void out.push(decode(chunk)))), + }); + return { + layer: Layer.mergeAll(Layer.succeed(Terminal.Terminal, terminal), stdio, FileSystem.layerNoop({}), Path.layer), + stderr: () => err.join(''), + stdout: () => out.join(''), + }; +}; + +describe('create-agent-bundle plain CLI text', () => { + it('prints --help through Terminal.display on stdout and exits 0', async () => { + const captured = captureLayer(); + const exitCode = await runPromise(Effect.provide(cliProgram(['--help']), captured.layer)); + + expect(exitCode).toBe(0); + expect(captured.stdout()).toBe(helpText); + expect(captured.stderr()).toBe(''); + }); + + it('prints a flag error and the help text through Stdio.stderr and exits 2', async () => { + const captured = captureLayer(); + const exitCode = await runPromise(Effect.provide(cliProgram(['one', 'two']), captured.layer)); + + expect(exitCode).toBe(2); + expect(captured.stdout()).toBe(''); + expect(captured.stderr()).toBe(`Pass at most one directory argument.\n\n${helpText}`); + }); +}); From 7e48cd792fc52fca6a0e1f87fccfc6b4dbe76331 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:49:25 +0000 Subject: [PATCH 7/7] chore(effect): take Terminal/Stdio from @effect/platform-node-shared, the dependency agent-bundle already carries (#508); drop @effect/platform-node --- .changeset/465-cli-terminal-input-errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/465-cli-terminal-input-errors.md b/.changeset/465-cli-terminal-input-errors.md index a46474fa6..525665dd0 100644 --- a/.changeset/465-cli-terminal-input-errors.md +++ b/.changeset/465-cli-terminal-input-errors.md @@ -3,4 +3,4 @@ 'create-agent-bundle': patch --- -Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root by `@effect/platform-node` (`NodeTerminal`/`NodeStdio`, new dependency); `runCli` takes `{ services }` in place of the former stream injection. `create-agent-bundle --help` and its flag-error text go through the same services at its existing `NodeServices` root (Clack still renders the prompts). Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#505) +Report routed-CLI input-validation failures in CLI terms instead of raw zod issue JSON, and route the first-party `agent-bundle` CLI's terminal I/O through Effect's `Terminal`/`Stdio` services. A generated executable (`dist/bin/.js`, the artifact `bin/.mjs`, and the `invokeCli` test harness) whose route `inputSchema` rejects the parsed argv now prints one line per issue — `Invalid value for --max-wait-ms: expected number <= 55000; received 300000.` naming the flag, ``, or projected-MCP `--input.` — followed by the exact `Usage:` line and the `--help` hint on stderr, still exit 2; under `--json` stdout stays empty and stderr carries one canonical `{"error":{"code":"CLI_INPUT_INVALID","issues":[...],"usage":"..."}}` line, and `--ndjson` emits one `type: "error"` event. `CliInputError` from `agent-bundle/cli-entry` gains a typed `issues` list and a `cliInputError(command, input, error)` constructor. The `agent-bundle` CLI (`build`, `prepack`, `install`, `doctor`, `validate`, `eval`, `inspect`, `dev`, `mcp run`, help and version) writes user-facing text through `Terminal.display` and diagnostics/`--json` output through `Stdio`, provided once at the CLI root from `@effect/platform-node-shared` (`NodeTerminal`/`NodeStdio`, the package `agent-bundle` already depends on); `runCli` takes `{ services }` in place of the former stream injection. `create-agent-bundle --help` and its flag-error text go through the same services at its existing `NodeServices` root (Clack still renders the prompts). Protocol stdout (MCP stdio, hook results, emitted routed-CLI and installer shells) is unchanged. Fixes #465 (#505)