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
19 changes: 19 additions & 0 deletions .changeset/routed-cli-stage2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"agent-bundle": minor
---

Compile `src/cli/**` routes into a routed CLI (#102 stage 2). Conventional
command routes now compile into one collision-checked command graph —
path nesting is identity (`src/cli/library/audit.ts` runs as
`<bin> library audit`), the static `config` export supplies description,
aliases, positionals, and the exit-code policy, and a bounded, documented
zod grammar projects each route's `inputSchema` onto argv (options,
positionals, arrays, defaults) with named `AB4814` diagnostics for
constructs outside it. The graph feeds the existing package-build pipeline
as one generated Rslib executable named after the plugin, superseding the
`src/cli.ts` bin convention for that project; commands run inside the typed
Agent request context, write one canonical JSON line to stdout, accept
`--json`, and map exit codes deterministically (0/1/2, 130/143 on signals).
Command-tree and alias collisions, contract violations, and rendered
(`.tsx`) command routes fail source validation with the new
`AB4813`–`AB4816` diagnostics instead of building silently.
41 changes: 40 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ simply not been built yet is a validation **warning** that only
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |

## Route graph (`AB4800`–`AB4812`)
## Route graph (`AB4800`–`AB4816`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -152,6 +152,41 @@ explicit `scripts` entries (#102 stage 1): a plain module directly under
artifact with `provenance.kind: 'conventional'`. Script routes that pipeline
cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions.

Conventional `src/cli/**` routes compile into one collision-checked command
graph (#102 stage 2): the file path below the CLI root is the command
nesting (`src/cli/library/audit.ts` runs as `<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
`inputSchema` and `resultSchema` zod schemas plus one async default function
receiving `{ input, signal }`; the command runs inside the typed Agent
request context, writes one canonical JSON line to stdout, and exits 0 (or
the validated result's integer `exitCode` under `config.exitCode: 'result'`),
1 on execution failure, 2 on usage or input-validation failure, 130/143
after SIGINT/SIGTERM. `--help`, `--json`, and `--version` are owned by the
generated shell.

The argv projection of `inputSchema` is extracted statically — the module is
parsed, never executed — from a bounded zod grammar: the top level is
`z.object({ ... })` or `z.strictObject({ ... })` (optionally `.strict()`);
each property chains from `z.string()`, `z.number()`, `z.boolean()`,
`z.enum([...string literals])`, or `z.array(<string/number/enum element>)`;
chains may add `.optional()`, `.default(<static literal>)`, and
`.describe('<string literal>')`, plus validation-only refinements the
projection accepts without interpreting (strings: `min`/`max`/`length`/
`regex`/`startsWith`/`endsWith`/`includes`; numbers: `int`/`min`/`max`/`gt`/
`gte`/`lt`/`lte`/`positive`/`nonnegative`/`negative`/`nonpositive`/`finite`/
`safe`/`multipleOf`/`step`; arrays: `min`/`max`/`length`/`nonempty`) because
the module's real zod schema still validates every input at run time. Keys
project onto kebab-case options (`maxFiles` becomes `--max-files`); booleans
are flags and must carry `.optional()` or `.default(...)`;
`config.positionals` names the keys consumed as bare arguments in order,
where only the trailing positional may be a `z.array(...)` (variadic).
Anything outside that grammar — identifier references (including shared
schema constants), unions, nested objects, transforms, coercions — raises
`AB4814` naming the offending construct.

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB4800` | error | An MCP server has both discovered route modules under `src/mcp/<id>/` and an existing entry claim (the conventional `src/mcp/<id>.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.<id>` mode. |
Expand All @@ -167,6 +202,10 @@ cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions.
| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. |
| `AB4811` | error | A generated MCP route exports `execute` or `render`; route mode accepts only the async default Server Component contract. |
| `AB4812` | error | A generated MCP App route has no non-empty static `config.resourceUri`. |
| `AB4813` | error | The command graph collides: a route is both a command module and a command group, an alias collides with a sibling command, group, or alias, an alias is unsafe or duplicated, or an explicit `bin` entry claims the generated CLI executable's name. |
| `AB4814` | error | A CLI route's `inputSchema` leaves the bounded argv grammar (the message names the offending construct and position), a key projects onto a reserved or duplicate option name, a required boolean has no flag expression, or `config.positionals` violates the positional policy. |
| `AB4815` | error | A CLI route does not satisfy the routed command contract: missing named `inputSchema`/`resultSchema` exports, a default export that is not an async function, or malformed `config.description`/`aliases`/`exitCode` fields. |
| `AB4816` | error | A conventional `src/cli/**` route is a rendered-command module (`.tsx`/`.jsx`); rendered commands are not supported yet. Rename it to `.ts`, or prefix a path segment with `_` to keep it private. |

## Development package build (`AB7103`)

Expand Down
45 changes: 45 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `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 `_` |

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

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

### The routed CLI shell (#102 stage 2)

A generated-mode `src/cli/**` surface compiles into one framework-generated
executable instead of a hand-written `src/cli.ts` dispatcher. A plain command
route is one module:

```ts
// src/cli/inspect.ts — the whole command a consumer writes
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

export const config = {
description: 'Inspect a bounded source tree without changing it.',
positionals: ['root'],
} satisfies CliRouteConfig;
export const inputSchema = z.object({
maxFiles: z.number().int().min(1).max(256).optional(),
root: z.string().min(1),
}).strict();
export const resultSchema = z.object({ /* ... */ }).strict();

export default async function inspect({ input, signal }: CliRouteProps<typeof inputSchema>) {
// ... do the work ...
return result;
}
```

The compiler statically projects `inputSchema` onto argv (the bounded grammar
and every policy rule are documented in
[Diagnostics](diagnostics.md#route-graph-ab4800ab4816)), generates nested
help (`--help` at every level, `--version` at the root), and emits
`dist/bin/<plugin-name>.js` with the shebang and executable bit through the
same Rslib synthesis as every other bin. At run time the shell resolves the
command path, parses and coerces argv, validates through the module's own
zod schemas, executes the default function inside the typed Agent request
context (`invocation.kind: 'cli'`), writes one canonical JSON line to
stdout, and maps exit codes deterministically (0 success or the result's
`exitCode` under `config.exitCode: 'result'`; 1 execution failure; 2 usage
or input failure; 130/143 on SIGINT/SIGTERM, which reach the route's
`AbortSignal`). `--json` is accepted on every command; plain commands
already emit the canonical JSON document. Routed CLI projects need
`@agent-bundle/runtime` as a dependency — the generated executable installs
the request context through it.

### The stdio MCP lifecycle shell

An MCP server entry that **default-exports a server factory** is served under
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"types": "./dist/api.d.ts",
"import": "./dist/api.js"
},
"./cli-entry": {
"types": "./dist/cli-entry.d.ts",
"import": "./dist/cli-entry.js"
},
"./config": {
"types": "./dist/config/index.d.ts",
"import": "./dist/config.js"
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default defineConfig({
entry: {
api: './src/api.ts',
cli: './src/cli.ts',
'cli-entry': './src/cli-entry.ts',
config: './src/config/index.ts',
eval: './src/eval/index.ts',
index: './src/index.ts',
Expand Down
85 changes: 84 additions & 1 deletion packages/agent-bundle/src/build/entry-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

import { stableJson } from '../core/digest.ts';
import type { CompiledAgentRoute } from '../routes/types.ts';
import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts';

/**
* Generated-entry templates: the framework-provided entry files consumers
Expand Down Expand Up @@ -73,6 +73,89 @@ export const generatedExecutableEntrySource = (options: {
].join('\n');


export const cliEntryRuntimeSpecifier = 'agent-bundle/cli-entry';

/**
* The on-disk location of the `agent-bundle/cli-entry` runtime module,
* aliased into generated CLI executables exactly like the mcp-entry
* lifecycle so emitted bins stay self-contained.
*/
export const cliEntryRuntimePath = (): string => {
for (const candidate of [
new URL('./cli-entry.js', import.meta.url),
new URL('../cli-entry.ts', import.meta.url),
]) {
const path = fileURLToPath(candidate);
if (existsSync(path)) return path;
}
throw new Error('Unable to locate the agent-bundle/cli-entry runtime module for generated CLI executables.');
};

export interface GeneratedCliBinEntryOptions {
readonly commands: readonly CompiledCliCommand[];
readonly plugin: { readonly description?: string; readonly name: string; readonly version: string };
readonly routes: readonly CompiledAgentRoute[];
}

/**
* The generated routed-CLI executable (#102 stage 2): the compiled command
* graph rides the bundle as data, the cli-entry shell owns argv parsing,
* help, exit codes, and signals, and every command executes inside the typed
* Agent request context. Input validation failures are usage failures
* (`CliInputError`, exit 2); the route module's zod schemas stay the
* runtime validation boundary.
*/
export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions): string => {
const commandRoutes = options.routes.filter((route) =>
options.commands.some((command) => command.routeId === route.id));
return [
`import { CliInputError, runGeneratedCliProcess } from ${JSON.stringify(cliEntryRuntimeSpecifier)};`,
"import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';",
...routeImports(commandRoutes),
'',
'const routes = Object.freeze({',
...commandRoutes.map((route, index) =>
` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`),
'});',
'',
`const commands = Object.freeze(${stableJson(options.commands)});`,
'',
'const execute = async (command, input, context) => {',
' const route = routes[command.routeId];',
" if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated CLI route must default-export an async function.');",
' let parsed;',
' try {',
' parsed = route.module.inputSchema.parse(input);',
' } catch (error) {',
' throw new CliInputError(error instanceof Error ? error.message : String(error));',
' }',
' const cwd = process.cwd();',
' const result = await runAgentRequest({',
' capabilities: {',
' command: unavailable(),',
' filesystem: unavailable(),',
' network: unavailable(),',
" projectRoot: available({ root: cwd }, 'derived'),",
' },',
" host: unavailable('unsupported-surface'),",
" invocation: { kind: 'cli', operationId: command.routeId, surface: command.path.join(' ') },",
' signal: context.signal,',
" workspace: available({ root: cwd }, 'derived'),",
' }, async () => route.module.default({ input: parsed, signal: context.signal }));',
' return route.module.resultSchema.parse(result);',
'};',
'',
'await runGeneratedCliProcess({',
' commands,',
...(options.plugin.description === undefined ? [] : [` description: ${JSON.stringify(options.plugin.description)},`]),
' execute,',
` name: ${JSON.stringify(options.plugin.name)},`,
` version: ${JSON.stringify(options.plugin.version)},`,
'});',
'',
].join('\n');
};

export interface GeneratedRouteMcpEntryOptions {
readonly plugin: { readonly name: string; readonly version: string };
readonly routes: readonly CompiledAgentRoute[];
Expand Down
38 changes: 37 additions & 1 deletion packages/agent-bundle/src/build/package-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'
import { assertInside } from '../core/paths.ts';
import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts';
import { scanEntryExports } from './entry-exports.ts';
import { generatedExecutableEntrySource } from './entry-shell.ts';
import {
cliEntryRuntimePath,
cliEntryRuntimeSpecifier,
generatedCliBinEntrySource,
generatedExecutableEntrySource,
} from './entry-shell.ts';
import { buildWithRslib, type RslibEntry } from './rslib.ts';

/**
Expand Down Expand Up @@ -96,6 +101,33 @@ export const planPackageEntries = async (
if (packageBuild === undefined) return Object.freeze([]);
const entries: PlannedPackageEntry[] = [];
for (const bin of packageBuild.bins) {
if (bin.generatedCli !== undefined) {
// A routed-CLI bin compiles the framework-generated command program;
// the cli-entry runtime shell is aliased in so the emitted executable
// stays self-contained, exactly like generated stdio MCP entries.
entries.push({
aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() },
banner: binShebang,
executable: true,
name: `bin-${bin.name}`,
outputRelativePath: `bin/${bin.name}.js`,
source: bin.source,
sourceInputs: Object.freeze([...new Set([
bin.provenance.sourcePath,
...bin.generatedCli.routes.map((route) => route.source),
])]),
virtualSource: generatedCliBinEntrySource({
commands: bin.generatedCli.commands,
plugin: {
...(model.metadata.description === undefined ? {} : { description: model.metadata.description }),
name: model.metadata.name,
version: model.metadata.version,
},
routes: bin.generatedCli.routes,
}),
});
continue;
}
// A bin entry exporting `main` (or a default function) receives the
// generated process envelope; a self-executing module bundles directly.
const exports = await scanEntryExports(bin.source);
Expand Down Expand Up @@ -160,9 +192,13 @@ export const buildPackageOutputs = async (options: {
await mkdir(stageParent, { recursive: true });
const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`));
try {
const cliRuntimeShell = entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined)
? cliEntryRuntimePath()
: undefined;
const evidence = await buildWithRslib({
cwd: projectRoot,
entries,
...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [cliRuntimeShell] }),
logLevel: 'error',
outputRoot: stageRoot,
...(options.tools === undefined ? {} : { tools: options.tools }),
Expand Down
Loading
Loading