Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b9342ec
test(routes): cover CLI tool projections
ScriptedAlchemy Sep 5, 2026
5e67ad8
docs(routes): CLI surface projection of MCP tools, <tool>.cli.ts, AB4…
ScriptedAlchemy Sep 5, 2026
9e4096c
docs(zh): translate CLI tool projections (#596 lane M7)
ScriptedAlchemy Sep 5, 2026
1a6000b
test(596/M5): projection-pool and integration tests for CLI surface p…
ScriptedAlchemy Sep 5, 2026
be75166
feat(routes): compile MCP tool CLI surface projections (#596)
ScriptedAlchemy Sep 5, 2026
6edd1e6
feat(cli): execute tool surface projections
ScriptedAlchemy Sep 5, 2026
2e98f04
feat(routes): project CLI surface projections into the route manifest…
ScriptedAlchemy Sep 5, 2026
339d656
test(routes): pin the harness submit projection as an explicit comman…
ScriptedAlchemy Sep 5, 2026
e0ccd31
chore: changeset names PR #616
ScriptedAlchemy Sep 5, 2026
735e4db
fix(routes): record projection defaults apart from schema defaults; A…
ScriptedAlchemy Sep 5, 2026
6bffd09
fix(test): bundle CLI projection loaders
ScriptedAlchemy Sep 5, 2026
920441c
merge origin/main (#578 composite root, #601 app client)
ScriptedAlchemy Sep 5, 2026
0e54f54
fix(workbench): drop Routes-page projection UI; document CLI invocati…
ScriptedAlchemy Sep 5, 2026
541d5cd
fix(agent-bundle): normalize projected CLI invocation
ScriptedAlchemy Sep 5, 2026
fd38d3c
fix(routes): AB4841 for ambient, generator, async, and unfollowable r…
ScriptedAlchemy Sep 5, 2026
91594ee
Merge branch 'lane/596-g1' into feat/596-cli-surface-projection
ScriptedAlchemy Sep 5, 2026
dc7db53
Merge branch 'lane/596-g2' into feat/596-cli-surface-projection
ScriptedAlchemy Sep 5, 2026
92346d8
chore(596): deslop CLI surface projection branch
ScriptedAlchemy Sep 5, 2026
cba6860
chore(596): deslop (#616)
ScriptedAlchemy Sep 5, 2026
56a93f9
test(layout-build): bulk-projected tool observes invocation.kind cli …
ScriptedAlchemy Sep 5, 2026
da95684
Merge branch 'lane/596-deslop' into feat/596-cli-surface-projection
ScriptedAlchemy Sep 5, 2026
42a83fb
Merge remote-tracking branch 'origin/main' into feat/596-cli-surface-…
ScriptedAlchemy Sep 5, 2026
1272862
merge origin/feat/596-cli-surface-projection (owner deslop 92346d8dd)
ScriptedAlchemy Sep 5, 2026
8f035fe
fix(cli): strip --yes only on confirming projections; AB4841 for body…
ScriptedAlchemy Sep 5, 2026
13bb3ca
Merge remote-tracking branch 'origin/main' into feat/596-cli-surface-…
ScriptedAlchemy Sep 5, 2026
1297c46
merge origin/main (#618 event preflight gates); renumber CLI projecti…
ScriptedAlchemy Sep 5, 2026
0901ae9
test(cli): regression coverage for application-owned yes on non-confi…
ScriptedAlchemy Sep 5, 2026
5bf25ee
Merge remote-tracking branch 'origin/feat/596-cli-surface-projection'…
ScriptedAlchemy Sep 5, 2026
b63d5b9
Merge remote-tracking branch 'origin/main' into feat/596-cli-surface-…
ScriptedAlchemy Sep 5, 2026
3690b2a
merge origin/main (#620 web surface): projection sources and config p…
ScriptedAlchemy Sep 5, 2026
dc76050
Merge remote-tracking branch 'origin/main' into feat/596-cli-surface-…
ScriptedAlchemy Sep 5, 2026
96b1f15
merge origin/main (#623 compiler service)
ScriptedAlchemy Sep 5, 2026
0b55643
merge origin/main (#629 application explorer removes the Routes page,…
ScriptedAlchemy Sep 5, 2026
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
5 changes: 5 additions & 0 deletions .changeset/596-cli-surface-projection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Reserve `.cli.{ts,tsx}` under `src/mcp/**` for the opt-in CLI surface projection of a generated tool: a colocated `<tool>.cli.ts` exporting `CliProjectionConfig` (`agent-bundle/routes`) and an optional synchronous `mapInput` compiles to an idiomatic command (`inspect --routes` shows `cli.commands[].projection`) and excludes that tool from the bulk `routes.mcpCommands` projection. The bulk `routes.mcpCommands` projection now runs tools with `invocation.kind: 'cli'` (same as explicit projections; the generated MCP server still passes `kind: 'tool'`). Orphan or misplaced modules are `AB4843`, an invalid projection contract is `AB4844`, and a grammar that does not bind to the tool's contract is `AB4845` (#616).
42 changes: 30 additions & 12 deletions docs/diagnostics.md

Large diffs are not rendered by default.

76 changes: 68 additions & 8 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ export default async function inspect({ input, signal }: CliRouteProps<typeof in

The compiler statically projects `inputSchema` onto argv (the bounded grammar
and every policy rule are documented in
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4839-ab4940ab4942)), generates nested
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4842-ab4940ab4942)), 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
Expand All @@ -845,8 +845,9 @@ hops, the `.js` specifier mapping onto its `.ts`/`.tsx` source) is resolved
statically, parsed in the declaring module's scope under the same grammar,
and normalized once into a `RouteContract` shared by every route — CLI
command or MCP tool — that binds it; a reference the resolver cannot follow
is `AB4838` and a cyclic one `AB4839`, both documented in the same
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4839-ab4940ab4942)
is `AB4838` and a cyclic one `AB4839` (CLI routes, or a tool route with a
CLI projection), both documented in the same
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4842-ab4940ab4942)
section.

When the module's `inputSchema` rejects the parsed argv, the shell reports
Expand Down Expand Up @@ -962,16 +963,19 @@ export default defineConfig({
});
```

`true` selects every eligible tool. Object-form `include` defaults to every
`true` selects every eligible tool. A tool that already has a colocated
`<tool>.cli.{ts,tsx}` is not eligible — the explicit projection is the
command for that operation. Object-form `include` defaults to every
eligible tool when omitted; `exclude` removes matches afterward. Patterns
match the `<server>:<tool>` identity and support only literal text plus `*`
(zero or more characters). Every declared pattern must match at least one
eligible tool; `include: []` and misspellings fail with `AB4822`, whose
diagnostic lists the available identities. Excluding every selected tool is
legal.
diagnostic lists the available identities (and names the projection module
when the only matches were excluded by a `.cli.ts`). Excluding every
selected tool is legal.

Each projected tool runs as `<plugin-bin> <server> <tool>` with the protocol
tool name preserved verbatim. Its only input option is
Each bulk-projected tool runs as `<plugin-bin> <server> <tool>` with the
protocol tool name preserved verbatim. Its only input option is
`--input '<one JSON object>'`; omission supplies `{}`, while invalid JSON,
arrays, `null`, and scalars exit 2 before route execution. A tool is read-only
only when its static MCP annotations explicitly set `readOnlyHint: true`.
Expand Down Expand Up @@ -1006,6 +1010,62 @@ does not depend on MCPorter or introduce a second command model. MCPorter can
still be pointed independently at the generated MCP server when a live-server
client is desired.

The explicit form is a colocated `<tool>.cli.ts` (or `.cli.tsx`) projection
module beside `src/mcp/<server>/tools/<tool>.tsx` (or `.ts`). It is never a
route: discovery excludes `.cli.{ts,tsx}` before identity derivation, pairs
the file with the sibling tool, and does not list it on
`RouteContract.routes`. The suffix is reserved under `src/mcp/**`; prefix
`_` parks a file the same way as any other conventional module. An orphan
or a `.cli.*` under `resources/`, `prompts/`, or `apps/` is `AB4843`.

The module exports a static `config` that satisfies `CliProjectionConfig`
from `agent-bundle/routes` (the same extract grammar as a route `config`)
and, optionally, a synchronous `mapInput`:

- `command` — path segments; default `[tool]`; each must pass
`safeIdentitySegment`.
- `description` — help text; default: the tool's `config.description`.
- `positionals` — canonical keys consumed as bare arguments, in order
(same rules as a `src/cli` route).
- `flags` — keyed by the canonical key of the tool's
`RouteContract.input`. Each entry may set `name` (CLI spelling,
kebab-case, no leading dashes; default `kebab(key)`), `aliases` (extra
long-form spellings), `description` (overrides schema `.describe()`),
`default` (CLI-only, applied by the shell before `mapInput`), and
`required: false` (relax a canonical-required key; legal only when
`mapInput` is exported).
- `aliases` — command aliases (same rules as a `src/cli` route).
- `confirm` — default `!(tool config.annotations.readOnlyHint === true)`.
- `exitCode` — `'result'` or `'zero'`; default: the tool's
`config.exitCode ?? 'zero'`.

`mapInput` receives the parsed CLI input (canonical keys, after
projection defaults) and must return `z.input<typeof inputSchema>`. It
is recorded statically (`scanRouteModuleExports`) and loaded only by the
CLI bin; the MCP worker never sees the module. `mapInput` is a surface
adapter, not domain logic: it only reshapes or defaults argv into the
canonical input (renames, splitting lists, deriving a working directory).
Domain validation and behaviour stay in the operation — its
`inputSchema` refinements and its component. A mapper that recreates
command logic is the duplication the projection exists to remove. A
contract problem is `AB4844`; a grammar that does not bind to the tool's
contract is `AB4845`. Message shape:
`CLI projection <module> for tool:<server>/<tool>: <detail>.`

The explicit projection takes precedence over the bulk `mcpCommands`
projection: that tool is removed from the eligible set so one operation
never becomes two commands. The compiled command's `routeId` is the tool
id; at run time the tool runs with
`invocation.kind: 'cli'` and `operationId` equal to that tool id
(`tool:<server>/<tool>`), so a route can pick surface wording from
`agent().invocation.kind` while the operation stays the tool. A route
observes `kind: 'cli'` whenever it runs from the generated CLI
executable, whichever projection mechanism produced the command — the
bulk `--input` projection is a CLI surface too. The generated MCP
server still passes `kind: 'tool'`.
`inspect --routes` prints `cli.commands[].projection`
(`module`, `mapInput`, `defaults?`, `relaxed?`) and `options[].{key,option,aliases}`.

### The stdio MCP lifecycle shell

An MCP server entry that **default-exports a server factory** is served under
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const dedupe = <Value>(values: readonly Value[]): readonly Value[] => [...new Set(values)];
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const inputSchema = z.object({

export const resultSchema = z.object({
executions: z.number().int().positive(),
invocation: z.literal('tool'),
invocation: z.enum(['cli', 'tool']),
marker: z.string().nullable(),
operationId: z.string(),
}).strict();
Expand All @@ -28,7 +28,7 @@ export default async function MutationProbe({
const context = await agent();
const result = {
executions,
invocation: context.invocation.kind as 'tool',
invocation: context.invocation.kind as 'cli' | 'tool',
marker: input.marker ?? null,
operationId: context.invocation.operationId!,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { CliProjectionConfig } from 'agent-bundle/routes';
import type { z } from 'zod';

import { dedupe } from '../../../lib/submit-helpers.js';
import type { inputSchema } from './submit.js';

export const config = {
command: ['submit'],
confirm: false,
flags: {
cwd: { description: 'Working directory of the command (default: the current directory).', required: false },
laneKey: { name: 'lane' },
tags: { description: 'Tag attached to the request (repeatable; duplicates are dropped).', name: 'tag' },
},
positionals: ['argv'],
} satisfies CliProjectionConfig<typeof inputSchema>;

type CliInput = Omit<z.input<typeof inputSchema>, 'cwd'> & { readonly cwd?: string };

// Leading "!" tags exercise projection mapping failures.
export const mapInput = (input: CliInput): z.input<typeof inputSchema> => {
const tags = input.tags === undefined ? undefined : dedupe(input.tags);
const rejected = tags?.find((tag) => tag.startsWith('!'));
if (rejected !== undefined) {
throw new Error(`Tag ${JSON.stringify(rejected)} must not start with "!".`);
}
return {
...input,
cwd: input.cwd ?? process.cwd(),
...(tags === undefined ? {} : { tags: [...tags] }),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Agent, agent } from '@agent-bundle/runtime';
import { z } from 'zod';

export const config = {
annotations: { readOnlyHint: false },
description: 'Submits one command line as lane work and echoes the accepted request.',
title: 'Submit',
};

export const inputSchema = z.object({
argv: z.array(z.string()).min(1).describe('The command line to run.'),
cwd: z.string().min(1).describe('Working directory of the command.'),
laneKey: z.string().min(1).optional().describe('Lane the work is queued under.'),
tags: z.array(z.string()).optional().describe('Tags attached to the request.'),
});

export const resultSchema = z.object({
argv: z.array(z.string()).min(1),
cwd: z.string().min(1),
laneKey: z.string().optional(),
operation: z.literal('submit'),
tags: z.array(z.string()).optional(),
});

export default async function Submit({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const context = await agent();
const value = {
argv: input.argv,
cwd: input.cwd,
...(input.laneKey === undefined ? {} : { laneKey: input.laneKey }),
operation: 'submit' as const,
...(input.tags === undefined ? {} : { tags: input.tags }),
};
const { invocation, providers } = context;
return (
<Agent.Result value={value}>
<Agent.Text>{`submit: ${input.argv.join(' ')}`}</Agent.Text>
<Agent.Text>{`invocation: ${invocation.kind} ${invocation.operationId ?? '(no operation)'} ${invocation.surface ?? '(no surface)'}`}</Agent.Text>
<Agent.Text>{`provider: ${JSON.stringify(providers['libraryTooling'])}`}</Agent.Text>
</Agent.Result>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import type { AgentProviderContext } from 'agent-bundle';
export default async function libraryTooling({ invocation, signal }: AgentProviderContext) {
if (signal.aborted) throw new DOMException('aborted', 'AbortError');
const input = invocation.kind === 'tool' ? invocation.props.input : undefined;
if (typeof input === 'object' && input !== null && (input as { readonly failProvider?: unknown }).failProvider === true) {
const failProvider = typeof input === 'object'
&& input !== null
&& (input as { readonly failProvider?: unknown }).failProvider === true;
const failCliProvider = invocation.kind === 'cli'
&& invocation.props.args.includes('{"failProvider":true}');
if (failProvider || failCliProvider) {
throw new Error('ffprobe is not installed');
}
const surface = invocation.kind === 'tool'
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type {
AgentProviderFactory,
AppRouteConfig,
CanonicalAgentEvent,
CliProjectionConfig,
EventPreflight,
EventPreflightContext,
EventPreflightResult,
Expand Down Expand Up @@ -121,6 +122,7 @@ export type {
CapabilityState,
CompiledAgentRoute,
CompiledCliMode,
CompiledCliProjection,
CompiledCliSurface,
CompiledProvider,
CompiledRouteGraph,
Expand Down
27 changes: 19 additions & 8 deletions packages/agent-bundle/src/build/cli-bins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ const generatedCli = (bin: NormalizedBinEntry): NonNullable<NormalizedBinEntry['
return bin.generatedCli;
};

/** Every project source that can change a generated routed-CLI executable. */
export const cliBinSourceInputs = (
model: NormalizedPlugin,
bin: NormalizedBinEntry,
): readonly string[] => {
const cli = generatedCli(bin);
return Object.freeze([...new Set([
bin.provenance.sourcePath,
model.metadata.provenance.sourcePath,
...cli.routes.map((route) => route.source),
...Object.values(cli.projectionSources ?? {}),
...(model.layouts ?? []).map((layout) => layout.source),
...(model.providers ?? []).map((provider) => provider.source),
...(model.state === undefined ? [] : [model.state.source]),
])]);
};

export const planCompiledCliBins = (
model: NormalizedPlugin,
options: { readonly outDir: string; readonly target: string },
Expand All @@ -101,14 +118,7 @@ export const planCompiledCliBins = (
return Object.freeze(routedCliBins(model).map((bin): PlannedCliBin => {
const cli = generatedCli(bin);
const rendered = cli.commands.some((command) => command.rendered);
const sourceInputs = Object.freeze([...new Set([
bin.provenance.sourcePath,
model.metadata.provenance.sourcePath,
...cli.routes.map((route) => route.source),
...(model.layouts ?? []).map((layout) => layout.source),
...(model.providers ?? []).map((provider) => provider.source),
...(model.state === undefined ? [] : [model.state.source]),
])]);
const sourceInputs = cliBinSourceInputs(model, bin);
return Object.freeze({
bin,
id: bin.id,
Expand Down Expand Up @@ -169,6 +179,7 @@ export const cliBinRslibEntries = (
},
...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }),
providers: model.providers ?? [],
...(cli.projectionSources === undefined ? {} : { projectionSources: cli.projectionSources }),
routes: cli.routes,
...(model.state === undefined ? {} : { state: model.state }),
// Durable state anchors on the artifact root (the parent of `bin/`),
Expand Down
Loading
Loading