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
9 changes: 9 additions & 0 deletions .changeset/rendered-cli-docs-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@agent-bundle/runtime": patch
---

Narrow the documented CLI claim to the `runRscCli` compatibility path: it
still serializes the validated result as one JSON line and never invokes
`render`, while routed `src/cli/**` `.tsx` commands render through the Agent
renderer's dispatcher (#102 stage 3). Documentation and pin-test wording
only; no runtime behavior changes.
20 changes: 20 additions & 0 deletions .changeset/rendered-cli-stage3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"agent-bundle": minor
---

Render `.tsx` CLI commands and scripts through the Agent renderer (#102
stage 3). A `src/cli/<command>.tsx` route's async default Server Component
now renders through the runtime dispatcher's public stream against a sibling
react-server worker with four output modes: interactive TTY progress updated
in place, exactly one final Markdown document when piped (no partial
fallbacks), `--json` for the canonical validated final value, and `--ndjson`
for the sequence-numbered render-event stream (a CLI/script dialect, never
written to an MCP stdout). Diagnostics stay on stderr; machine output owns
stdout; exit codes stay deterministic (status- or result-policy-derived,
130/143 on signals reaching the route's `AbortSignal`). Conventional
`src/scripts/<name>.tsx` routes ship the same way with `{ argv, signal }`
component props — lifting the stage-1 `AB4807` gate — while plain `.ts`
scripts and commands keep ordinary Node semantics and never enter the
renderer. The stage-2 `AB4816` gate is retired; the route-unit test harness
now passes rendered CLI/script routes the same props the generated
executables do.
45 changes: 33 additions & 12 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,28 +149,49 @@ last-good file, while a successful route-free preparation removes it.
Conventional `src/scripts/` routes ship through the same pipeline as
explicit `scripts` entries (#102 stage 1): a plain module directly under
`src/scripts/` compiles to `scripts/<name>.mjs` in every selected target
artifact with `provenance.kind: 'conventional'`. Script routes that pipeline
cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions.
artifact with `provenance.kind: 'conventional'`. A rendered module
(`src/scripts/<name>.tsx`/`.jsx`, #102 stage 3) compiles to the same
`scripts/<name>.mjs` plus a sibling `scripts/<name>-flight.mjs` react-server
worker: its async default component receives `{ argv, signal }` and renders
through the Agent renderer with the full CLI output contract (`--json`,
`--ndjson`, interactive TTY progress, piped Markdown); the framework dialect
reserves exactly `--json` and `--ndjson`, every other argument passes
through as `argv`, and the exit code derives from the final document status
(0 on `success`, 1 otherwise). Explicit `scripts` config entries keep
ordinary Node semantics regardless of extension — config always wins, and
only the conventional route contract opts into rendering. Script routes
neither pipeline can ship are hard errors (`AB4808`/`AB4809`), never silent
omissions.

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

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

## Development package build (`AB7103`)

Expand Down
22 changes: 19 additions & 3 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/mcp/<server-id>.ts` | Stdio entry for the declared MCP server `<server-id>` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly |
| `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` |
| `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 `_` |
| `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 |
| `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 `_` |
| `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 |
| `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 |
| `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 `_` |

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

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

### The routed CLI shell (#102 stage 2)
### The routed CLI shell (#102 stages 2-3)

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

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

### The stdio MCP lifecycle shell

An MCP server entry that **default-exports a server factory** is served under
Expand Down
9 changes: 6 additions & 3 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Everything else is power-tier reference: custom/remote server modes and
collision recovery are in [Entry conventions](entry-conventions.md); accepted
static metadata, generated `.agent-bundle/routes.d.ts`, and diagnostics are in
[Diagnostics](diagnostics.md). Handwritten `src/mcp/<server>.ts`,
`defineOperation`, and `createRscMcpServer` remain supported escape hatches.
The handwritten CLI compatibility path still serializes validated results and
never renders JSX; routed CLI rendering belongs to #102 stage 3.
`defineOperation`, and `createRscMcpServer` remain supported escape hatches;
the handwritten `runRscCli` compatibility path still serializes validated
results and never renders JSX. Routed `src/cli/**` commands and
`src/scripts/**` scripts follow one sentence: `.tsx` renders through the
Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts`
is plain.
8 changes: 3 additions & 5 deletions examples/audiobook-curator/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,9 @@ export default defineConfig({
version: '1.0.0',
},
runtime: { node: '22.19.0' },
// `src/cli.ts` is the package bin by convention; declaring it as a script
// also ships it inside every host artifact.
scripts: {
'audiobook-curator': './src/cli.ts',
},
// No `scripts` or `bin` fields needed: the routed `src/cli/` commands
// compile into the package executable (dist/bin/audiobook-curator.js) by
// convention (#102 stages 2-3).
// No `skills` field needed: `skills/curate-audiobooks/SKILL.md` is
// discovered by convention.
targets: ['claude', 'codex'],
Expand Down
71 changes: 12 additions & 59 deletions examples/audiobook-curator/src/cli-command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
/**
* The operation-definition helper behind `src/operations/*.ts`: one shared
* core (`id`, `inputSchema`, `handler`, `resultSchema`) that the generated
* MCP routes and the routed `src/cli/` commands both consume. The manual
* CLI projection (`cli.parse`/`usage`/`exitCode`) and its `runCliCommands`
* dispatcher were retired by the #102 stage-3 migration — the framework
* compiles `src/cli/**` routes into the executable instead.
*/

export interface CliCommandContext {
readonly signal: AbortSignal;
}
Expand All @@ -7,30 +16,12 @@ interface Schema<Output> {
parse(value: unknown): Output;
}

type SchemaOutput<Value> = Value extends Schema<infer Output> ? Output : never;

interface CliProjection<Input, Result> {
readonly exitCode?: (result: Result) => 0 | 1 | 2;
readonly name: string;
readonly parse: (args: readonly string[]) => Input;
readonly summary: string;
readonly usage: string;
}

/**
* Exported because the operation factories in `src/operations/` export
* objects of `defineCliCommand(...)` results: declaration emit for the
* package build must be able to name this type from those modules
* (TS4023 otherwise fails `agent-bundle build`'s d.ts generation, even
* though `tsc --noEmit` passes).
*/
/** Exported so consumer declaration emit can name the registry types (#174). */
export interface CliCommandDefinition<
InputSchema extends Schema<unknown>,
ResultSchema extends Schema<unknown>,
ParsedInput,
HandlerInput,
> {
readonly cli: CliProjection<ParsedInput, SchemaOutput<ResultSchema>>;
readonly handler: (input: HandlerInput, context: CliCommandContext) => unknown;
readonly id: string;
readonly inputSchema: InputSchema;
Expand All @@ -40,45 +31,7 @@ export interface CliCommandDefinition<
export const defineCliCommand = <
InputSchema extends Schema<unknown>,
ResultSchema extends Schema<unknown>,
ParsedInput,
HandlerInput,
>(
definition: CliCommandDefinition<InputSchema, ResultSchema, ParsedInput, HandlerInput>,
): CliCommandDefinition<InputSchema, ResultSchema, ParsedInput, HandlerInput> => Object.freeze(definition);

interface RuntimeCliCommand {
readonly cli: CliProjection<unknown, unknown>;
readonly handler: (input: unknown, context: CliCommandContext) => unknown;
readonly inputSchema: { parse(value: unknown): unknown };
readonly resultSchema: { parse(value: unknown): unknown };
}

const runtimeCommands = (commands: readonly unknown[]): readonly RuntimeCliCommand[] =>
commands as readonly RuntimeCliCommand[];

export const runCliCommands = async (
definitions: readonly unknown[],
argv: readonly string[],
options: { readonly signal?: AbortSignal; readonly write?: (value: string) => void } = {},
): Promise<0 | 1 | 2> => {
const commands = runtimeCommands(definitions);
const write = options.write ?? ((value: string) => process.stdout.write(value));
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
write(`${commands.map((command) => `${command.cli.usage}\n ${command.cli.summary}`).join('\n')}\n`);
return 0;
}
const command = commands.find((candidate) => candidate.cli.name === argv[0]);
if (command === undefined) throw new Error(`Unknown command: ${argv[0]}`);
if (argv[1] === '--help' || argv[1] === '-h') {
write(`${command.cli.usage}\n${command.cli.summary}\n`);
return 0;
}
const signal = options.signal ?? new AbortController().signal;
signal.throwIfAborted();
const input = command.inputSchema.parse(command.cli.parse(argv.slice(1)));
const handled = await command.handler(input, { signal });
signal.throwIfAborted();
const result = command.resultSchema.parse(handled);
write(`${JSON.stringify(result)}\n`);
return command.cli.exitCode?.(result) ?? 0;
};
definition: CliCommandDefinition<InputSchema, ResultSchema, HandlerInput>,
): CliCommandDefinition<InputSchema, ResultSchema, HandlerInput> => Object.freeze(definition);
Loading
Loading