Skip to content

Commit 9df37f8

Browse files
feat(cli): rendered commands and output modes (#102 stage 3) (#186)
* feat(cli): compile src/cli/** routes into a routed CLI executable (#102 stage 2) Conventional command routes now compile into one collision-checked command graph on the route-graph IR: path nesting is identity, the static config export supplies description/aliases/positionals/exit-code policy, and a bounded documented zod grammar projects each route's inputSchema onto argv with named AB4814 diagnostics for everything outside it. The graph feeds the existing package-build pipeline as one generated Rslib executable (cli-entry runtime shell aliased in, exactly like mcp-entry); commands run inside the typed Agent request context and keep the one-JSON-line stdout contract. New diagnostics AB4813 (command/alias/bin collisions), AB4814 (argv policy), AB4815 (route contract), AB4816 (rendered commands gated until stage 3). * feat(cli): render .tsx commands and scripts through the dispatcher (#102 stage 3) Rendered CLI routes and conventional rendered scripts now execute through the runtime dispatcher's public stream() against a sibling react-server worker, with four output modes: in-place TTY progress, one final piped Markdown document, --json canonical value, and --ndjson sequence-numbered render events (CLI/script dialect, never MCP stdout). Plain .ts keeps ordinary Node semantics. Lifts the AB4807/AB4816 stage gates (retired, not reused), consciously flips the docs-contract pin to the narrowed runRscCli compatibility claim with the routed-CLI replacement pins, and updates framework-mode's power-tier reference. Proof: audiobook-curator's manual CLI operation registry migrates to fifteen src/cli/ routes — fourteen plain commands byte-parity, library-audit rendered. * test(harness): pin the rendered flag on compiled CLI commands (#190 reconcile)
1 parent 3ae7721 commit 9df37f8

53 files changed

Lines changed: 1853 additions & 781 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@agent-bundle/runtime": patch
3+
---
4+
5+
Narrow the documented CLI claim to the `runRscCli` compatibility path: it
6+
still serializes the validated result as one JSON line and never invokes
7+
`render`, while routed `src/cli/**` `.tsx` commands render through the Agent
8+
renderer's dispatcher (#102 stage 3). Documentation and pin-test wording
9+
only; no runtime behavior changes.

‎.changeset/rendered-cli-stage3.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"agent-bundle": minor
3+
---
4+
5+
Render `.tsx` CLI commands and scripts through the Agent renderer (#102
6+
stage 3). A `src/cli/<command>.tsx` route's async default Server Component
7+
now renders through the runtime dispatcher's public stream against a sibling
8+
react-server worker with four output modes: interactive TTY progress updated
9+
in place, exactly one final Markdown document when piped (no partial
10+
fallbacks), `--json` for the canonical validated final value, and `--ndjson`
11+
for the sequence-numbered render-event stream (a CLI/script dialect, never
12+
written to an MCP stdout). Diagnostics stay on stderr; machine output owns
13+
stdout; exit codes stay deterministic (status- or result-policy-derived,
14+
130/143 on signals reaching the route's `AbortSignal`). Conventional
15+
`src/scripts/<name>.tsx` routes ship the same way with `{ argv, signal }`
16+
component props — lifting the stage-1 `AB4807` gate — while plain `.ts`
17+
scripts and commands keep ordinary Node semantics and never enter the
18+
renderer. The stage-2 `AB4816` gate is retired; the route-unit test harness
19+
now passes rendered CLI/script routes the same props the generated
20+
executables do.

‎docs/diagnostics.md‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,28 +149,49 @@ last-good file, while a successful route-free preparation removes it.
149149
Conventional `src/scripts/` routes ship through the same pipeline as
150150
explicit `scripts` entries (#102 stage 1): a plain module directly under
151151
`src/scripts/` compiles to `scripts/<name>.mjs` in every selected target
152-
artifact with `provenance.kind: 'conventional'`. Script routes that pipeline
153-
cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions.
152+
artifact with `provenance.kind: 'conventional'`. A rendered module
153+
(`src/scripts/<name>.tsx`/`.jsx`, #102 stage 3) compiles to the same
154+
`scripts/<name>.mjs` plus a sibling `scripts/<name>-flight.mjs` react-server
155+
worker: its async default component receives `{ argv, signal }` and renders
156+
through the Agent renderer with the full CLI output contract (`--json`,
157+
`--ndjson`, interactive TTY progress, piped Markdown); the framework dialect
158+
reserves exactly `--json` and `--ndjson`, every other argument passes
159+
through as `argv`, and the exit code derives from the final document status
160+
(0 on `success`, 1 otherwise). Explicit `scripts` config entries keep
161+
ordinary Node semantics regardless of extension — config always wins, and
162+
only the conventional route contract opts into rendering. Script routes
163+
neither pipeline can ship are hard errors (`AB4808`/`AB4809`), never silent
164+
omissions.
154165

155166
Conventional `src/cli/**` routes compile into one collision-checked command
156-
graph (#102 stage 2): the file path below the CLI root is the command
167+
graph (#102 stages 2-3): the file path below the CLI root is the command
157168
nesting (`src/cli/library/audit.ts` runs as `<bin> library audit`), the
158169
static `config` export supplies `description`, `aliases`, `positionals`, and
159170
the `exitCode` policy, and the graph feeds one framework-generated package
160171
executable named after the plugin (`dist/bin/<plugin-name>.js`), replacing
161-
the `src/cli.ts` convention for that project. A plain command route exports
172+
the `src/cli.ts` convention for that project. Every command route exports
162173
`inputSchema` and `resultSchema` zod schemas plus one async default function
163-
receiving `{ input, signal }`; the command runs inside the typed Agent
164-
request context, writes one canonical JSON line to stdout, and exits 0 (or
165-
the validated result's integer `exitCode` under `config.exitCode: 'result'`),
166-
1 on execution failure, 2 on usage or input-validation failure, 130/143
167-
after SIGINT/SIGTERM. `--help`, `--json`, and `--version` are owned by the
168-
generated shell.
174+
receiving `{ input, signal }`, and runs inside the typed Agent request
175+
context. A plain (`.ts`) command executes directly and writes one canonical
176+
JSON line to stdout. A rendered (`.tsx`) command's async default Server
177+
Component renders through the runtime dispatcher against a sibling
178+
`dist/bin/<plugin-name>-flight.mjs` react-server worker with four output
179+
modes: interactive TTY updates progress in place before the final document;
180+
piped output emits exactly one final Markdown document (no partial
181+
fallbacks); `--json` emits the canonical validated final value; `--ndjson`
182+
emits the sequence-numbered render-event stream (an Agent Bundle CLI/script
183+
dialect — never MCP JSON-RPC, never written to an MCP server's stdout).
184+
Diagnostics go to stderr; machine output owns stdout. Exit codes: 0 on
185+
success (or the validated result's integer `exitCode` under
186+
`config.exitCode: 'result'`), 1 on execution/render failure, 2 on usage or
187+
input-validation failure, 130/143 after SIGINT/SIGTERM. `--help`, `--json`,
188+
`--ndjson`, and `--version` are owned by the generated shell.
169189

170190
The argv projection of `inputSchema` is extracted statically — the module is
171191
parsed, never executed — from a bounded zod grammar: the top level is
172192
`z.object({ ... })` or `z.strictObject({ ... })` (optionally `.strict()`);
173193
each property chains from `z.string()`, `z.number()`, `z.boolean()`,
194+
`z.url()` (a string option validated as a URL at run time),
174195
`z.enum([...string literals])`, or `z.array(<string/number/enum element>)`;
175196
chains may add `.optional()`, `.default(<static literal>)`, and
176197
`.describe('<string literal>')`, plus validation-only refinements the
@@ -196,7 +217,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
196217
| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. |
197218
| `AB4805` | error | A route module exports `config` through a rejected declaration shape (`let`/`var`, destructuring, `export { config }`, a function or class, a missing initializer), or the extracted value is not an object. |
198219
| `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position. |
199-
| `AB4807` | error | A conventional `src/scripts/` route is a rendered-script module (`.tsx`/`.jsx`); rendered scripts are not supported yet. Rename it to `.ts`, prefix a path segment with `_` to keep it private, or declare it under `scripts` in config to opt into plain bundling. |
220+
| `AB4807` | retired | The stage-1 rendered-script gate. Rendered script routes ship through the Agent renderer pipeline since #102 stage 3; the code is never reused. |
200221
| `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. |
201222
| `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. |
202223
| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. |
@@ -205,7 +226,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
205226
| `AB4813` | error | The command graph collides: a route is both a command module and a command group, an alias collides with a sibling command, group, or alias, an alias is unsafe or duplicated, or an explicit `bin` entry claims the generated CLI executable's name. |
206227
| `AB4814` | error | A CLI route's `inputSchema` leaves the bounded argv grammar (the message names the offending construct and position), a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. |
207228
| `AB4815` | error | A CLI route does not satisfy the routed command contract: missing named `inputSchema`/`resultSchema` exports, a default export that is not an async function, or malformed `config.description`/`aliases`/`exitCode` fields. |
208-
| `AB4816` | error | A conventional `src/cli/**` route is a rendered-command module (`.tsx`/`.jsx`); rendered commands are not supported yet. Rename it to `.ts`, or prefix a path segment with `_` to keep it private. |
229+
| `AB4816` | retired | The stage-2 rendered-command gate. Rendered command routes render through the dispatcher since #102 stage 3; the code is never reused. |
209230

210231
## Development package build (`AB7103`)
211232

‎docs/entry-conventions.md‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
5959
| `src/mcp/<server-id>.ts` | Stdio entry for the declared MCP server `<server-id>` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly |
6060
| `src/mcp/<server>/{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.<server>` to `custom`, `command`, or `remote` |
6161
| `src/mcp/<server>/apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. | Use a custom server or prefix the file with `_` |
62-
| `src/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use. A `scripts` entry that references the file claims it. Rendered (`.tsx`) and nested modules are hard errors until later #102 stages (`AB4807`/`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
63-
| `src/cli/**/*.ts` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> library audit`. Rendered (`.tsx`) command routes are hard errors until #102 stage 3 (`AB4816`). | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` |
62+
| `src/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
63+
| `src/scripts/<name>.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/<name>.mjs` plus a `scripts/<name>-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
64+
| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` |
6465

6566
Conventions match `.ts` and `.tsx` files exactly.
6667

@@ -107,7 +108,7 @@ top-level failure path (stack to stderr, exit code 1). Self-executing modules
107108
(no `main` export) bundle directly, byte for byte — existing Scripts keep
108109
their behavior.
109110

110-
### The routed CLI shell (#102 stage 2)
111+
### The routed CLI shell (#102 stages 2-3)
111112

112113
A generated-mode `src/cli/**` surface compiles into one framework-generated
113114
executable instead of a hand-written `src/cli.ts` dispatcher. A plain command
@@ -151,6 +152,21 @@ already emit the canonical JSON document. Routed CLI projects need
151152
`@agent-bundle/runtime` as a dependency — the generated executable installs
152153
the request context through it.
153154

155+
A `.tsx` command route swaps the default function for an async default
156+
Server Component with the same `{ input, signal }` props and renders through
157+
the runtime dispatcher's public `stream()` against a sibling
158+
`dist/bin/<plugin-name>-flight.mjs` react-server worker (one warm worker per
159+
invocation; raw Flight bytes never reach the terminal). The four output
160+
modes: an interactive TTY updates progress in place and prints the final
161+
document as Markdown; piped output emits exactly one final Markdown document
162+
with no partial fallbacks; `--json` emits the canonical validated final
163+
value; `--ndjson` emits the sequence-numbered render-event stream — an
164+
Agent Bundle CLI/script output dialect, not MCP JSON-RPC, and never written
165+
as non-MCP bytes to an MCP server's stdout. Diagnostics stay on stderr;
166+
machine output owns stdout. Rendered scripts
167+
(`src/scripts/<name>.tsx`) share the same shell and output contract with
168+
`{ argv, signal }` component props and status-derived exit codes.
169+
154170
### The stdio MCP lifecycle shell
155171

156172
An MCP server entry that **default-exports a server factory** is served under

‎docs/framework-mode.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ Everything else is power-tier reference: custom/remote server modes and
6060
collision recovery are in [Entry conventions](entry-conventions.md); accepted
6161
static metadata, generated `.agent-bundle/routes.d.ts`, and diagnostics are in
6262
[Diagnostics](diagnostics.md). Handwritten `src/mcp/<server>.ts`,
63-
`defineOperation`, and `createRscMcpServer` remain supported escape hatches.
64-
The handwritten CLI compatibility path still serializes validated results and
65-
never renders JSX; routed CLI rendering belongs to #102 stage 3.
63+
`defineOperation`, and `createRscMcpServer` remain supported escape hatches;
64+
the handwritten `runRscCli` compatibility path still serializes validated
65+
results and never renders JSX. Routed `src/cli/**` commands and
66+
`src/scripts/**` scripts follow one sentence: `.tsx` renders through the
67+
Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts`
68+
is plain.

‎examples/audiobook-curator/agent-bundle.config.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@ export default defineConfig({
1818
version: '1.0.0',
1919
},
2020
runtime: { node: '22.19.0' },
21-
// `src/cli.ts` is the package bin by convention; declaring it as a script
22-
// also ships it inside every host artifact.
23-
scripts: {
24-
'audiobook-curator': './src/cli.ts',
25-
},
21+
// No `scripts` or `bin` fields needed: the routed `src/cli/` commands
22+
// compile into the package executable (dist/bin/audiobook-curator.js) by
23+
// convention (#102 stages 2-3).
2624
// No `skills` field needed: `skills/curate-audiobooks/SKILL.md` is
2725
// discovered by convention.
2826
targets: ['claude', 'codex'],
Lines changed: 12 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
/**
2+
* The operation-definition helper behind `src/operations/*.ts`: one shared
3+
* core (`id`, `inputSchema`, `handler`, `resultSchema`) that the generated
4+
* MCP routes and the routed `src/cli/` commands both consume. The manual
5+
* CLI projection (`cli.parse`/`usage`/`exitCode`) and its `runCliCommands`
6+
* dispatcher were retired by the #102 stage-3 migration — the framework
7+
* compiles `src/cli/**` routes into the executable instead.
8+
*/
9+
110
export interface CliCommandContext {
211
readonly signal: AbortSignal;
312
}
@@ -7,30 +16,12 @@ interface Schema<Output> {
716
parse(value: unknown): Output;
817
}
918

10-
type SchemaOutput<Value> = Value extends Schema<infer Output> ? Output : never;
11-
12-
interface CliProjection<Input, Result> {
13-
readonly exitCode?: (result: Result) => 0 | 1 | 2;
14-
readonly name: string;
15-
readonly parse: (args: readonly string[]) => Input;
16-
readonly summary: string;
17-
readonly usage: string;
18-
}
19-
20-
/**
21-
* Exported because the operation factories in `src/operations/` export
22-
* objects of `defineCliCommand(...)` results: declaration emit for the
23-
* package build must be able to name this type from those modules
24-
* (TS4023 otherwise fails `agent-bundle build`'s d.ts generation, even
25-
* though `tsc --noEmit` passes).
26-
*/
19+
/** Exported so consumer declaration emit can name the registry types (#174). */
2720
export interface CliCommandDefinition<
2821
InputSchema extends Schema<unknown>,
2922
ResultSchema extends Schema<unknown>,
30-
ParsedInput,
3123
HandlerInput,
3224
> {
33-
readonly cli: CliProjection<ParsedInput, SchemaOutput<ResultSchema>>;
3425
readonly handler: (input: HandlerInput, context: CliCommandContext) => unknown;
3526
readonly id: string;
3627
readonly inputSchema: InputSchema;
@@ -40,45 +31,7 @@ export interface CliCommandDefinition<
4031
export const defineCliCommand = <
4132
InputSchema extends Schema<unknown>,
4233
ResultSchema extends Schema<unknown>,
43-
ParsedInput,
4434
HandlerInput,
4535
>(
46-
definition: CliCommandDefinition<InputSchema, ResultSchema, ParsedInput, HandlerInput>,
47-
): CliCommandDefinition<InputSchema, ResultSchema, ParsedInput, HandlerInput> => Object.freeze(definition);
48-
49-
interface RuntimeCliCommand {
50-
readonly cli: CliProjection<unknown, unknown>;
51-
readonly handler: (input: unknown, context: CliCommandContext) => unknown;
52-
readonly inputSchema: { parse(value: unknown): unknown };
53-
readonly resultSchema: { parse(value: unknown): unknown };
54-
}
55-
56-
const runtimeCommands = (commands: readonly unknown[]): readonly RuntimeCliCommand[] =>
57-
commands as readonly RuntimeCliCommand[];
58-
59-
export const runCliCommands = async (
60-
definitions: readonly unknown[],
61-
argv: readonly string[],
62-
options: { readonly signal?: AbortSignal; readonly write?: (value: string) => void } = {},
63-
): Promise<0 | 1 | 2> => {
64-
const commands = runtimeCommands(definitions);
65-
const write = options.write ?? ((value: string) => process.stdout.write(value));
66-
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
67-
write(`${commands.map((command) => `${command.cli.usage}\n ${command.cli.summary}`).join('\n')}\n`);
68-
return 0;
69-
}
70-
const command = commands.find((candidate) => candidate.cli.name === argv[0]);
71-
if (command === undefined) throw new Error(`Unknown command: ${argv[0]}`);
72-
if (argv[1] === '--help' || argv[1] === '-h') {
73-
write(`${command.cli.usage}\n${command.cli.summary}\n`);
74-
return 0;
75-
}
76-
const signal = options.signal ?? new AbortController().signal;
77-
signal.throwIfAborted();
78-
const input = command.inputSchema.parse(command.cli.parse(argv.slice(1)));
79-
const handled = await command.handler(input, { signal });
80-
signal.throwIfAborted();
81-
const result = command.resultSchema.parse(handled);
82-
write(`${JSON.stringify(result)}\n`);
83-
return command.cli.exitCode?.(result) ?? 0;
84-
};
36+
definition: CliCommandDefinition<InputSchema, ResultSchema, HandlerInput>,
37+
): CliCommandDefinition<InputSchema, ResultSchema, HandlerInput> => Object.freeze(definition);

0 commit comments

Comments
 (0)