Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/465-cli-terminal-input-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +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/<name>.js`, the artifact `bin/<name>.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, `<positional>`, or projected-MCP `--input.<path>` — 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)
6 changes: 5 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>: expected <expectation>; received <JSON>.` — 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
Expand Down
62 changes: 57 additions & 5 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -335,6 +341,51 @@ 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.
- 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`:
`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
Expand Down Expand Up @@ -412,6 +463,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
Expand Down
21 changes: 21 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<root>` for a positional, `--input.<path>` 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] <root>
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
Expand Down
13 changes: 9 additions & 4 deletions examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] <root>',
"Run 'audiobook-curator inspect --help' for usage.",
'',
].join('\n'));
expect(run.stderr).not.toContain('maxFiles');
expect(run.stderr).not.toContain('too_small');
});
});

Expand Down
12 changes: 7 additions & 5 deletions packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';",
Expand All @@ -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);',
' }',
'};',
'',
Expand All @@ -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
Expand Down Expand Up @@ -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 } },",
Expand Down
Loading
Loading