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
7 changes: 4 additions & 3 deletions examples/audiobook-curator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ the `curator` server, lifecycle entry, warm Flight worker, and MCP registrations
there is no `src/application.ts`, operation-array registry, handwritten
`src/mcp/curator.ts`, or per-operation server selector.

The existing handwritten CLI remains a compatibility escape hatch until routed
CLI rendering in #102 stage 3. It uses the same domain helpers but still prints
validated JSON directly and never renders JSX.
The routed CLI under `src/cli/` shares the generated command graph with all
fifteen MCP tools projected as `audiobook-curator curator <tool>`. Projected
tools accept one optional `--input '<JSON object>'`; tools explicitly annotated
read-only run directly, while every mutation-capable tool requires `--yes`.

## Source layout

Expand Down
2 changes: 2 additions & 0 deletions examples/audiobook-curator/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export default defineConfig({
// status, and the `agent-bundle/meta` constant this plugin imports.
},
runtime: { node: '22.19.0' },
// #102 stage 4 adopts the in-house G7 projection for every curator tool.
routes: { mcpCommands: true },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve receipt exit codes in projected commands

When a projected operation reports a domain failure in its receipt rather than throwing, this blanket opt-in turns that failure into process success: compileMcpCliCommands hardcodes projected commands to exitCode: 'zero', while CuratorResult always emits Agent.Result. For example, curator inventory_sources --yes --input '{"source":"…","strict":true}' --json returns exit 0 when probing errors make src/library.ts set the receipt's exitCode to 1, unlike the existing inventory command and contrary to the strict contract. Exclude receipt-driven tools from projection or preserve their result exit-code policy so audit, search, and verification failures cannot be mistaken for success.

AGENTS.md reference: AGENTS.md:L10-L13

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5317ce619: projected tools now preserve an explicit config.exitCode: "result" policy, and the seven audiobook receipt-driven tools declare it. This is semantically honest without forcing arbitrary MCP result schemas to contain exitCode; non-receipt tools retain status-based zero-on-success behavior. Merged via #319.

// 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).
Expand Down
15 changes: 10 additions & 5 deletions examples/audiobook-curator/tests/application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ describe('audiobook curator filesystem application', () => {
expect(graph.servers[0]!.routes.filter((route) => route.kind === 'prompt').map((route) => route.id)).toEqual(['prompt:curator/curate']);
});

it('derives the complete routed CLI from src/cli/ route modules and no cli config', async () => {
it('derives the complete routed CLI and projected MCP toolset', async () => {
const graph = await compileRouteGraph(root, config);
expect(graph.cli).toMatchObject({ mode: 'generated' });
expect(graph.cli!.commands).toHaveLength(15);
// Exactly one command renders through the dispatcher; the other
// fourteen keep the plain one-JSON-line contract byte for byte.
expect(graph.cli!.commands!.filter((command) => command.rendered).map((command) => command.path.join(' ')))
expect(graph.cli!.commands).toHaveLength(30);
const customCommands = graph.cli!.commands!.filter((command) => command.mcp === undefined);
const projectedCommands = graph.cli!.commands!.filter((command) => command.mcp !== undefined);
expect(customCommands).toHaveLength(15);
expect(projectedCommands.map((command) => command.path.join(' '))).toEqual(
toolNames.map((tool) => `curator ${tool}`),
);
expect(customCommands.filter((command) => command.rendered).map((command) => command.path.join(' ')))
.toEqual(['library-audit']);
expect(projectedCommands.every((command) => command.rendered)).toBe(true);
});
});
11 changes: 8 additions & 3 deletions examples/audiobook-curator/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ afterEach(async () => {
* one-line JSON receipts.
*/
describe('audiobook-curator routed CLI', () => {
it('compiles the fifteen migrated commands with the pre-migration argv surface', async () => {
it('compiles the migrated commands and projected MCP toolset in one graph', async () => {
const graph = await compileRouteGraph(root, config);
expect(graph.diagnostics).toEqual([]);
expect(graph.cli?.mode).toBe('generated');
const commands = graph.cli!.commands!;
const byName = new Map(commands.map((command) => [command.path.join(' '), command]));
const customCommands = commands.filter((command) => command.mcp === undefined);
const projectedCommands = commands.filter((command) => command.mcp !== undefined);
const byName = new Map(customCommands.map((command) => [command.path.join(' '), command]));

expect([...byName.keys()].sort()).toEqual([
'acoustic-identify',
Expand All @@ -50,6 +52,9 @@ describe('audiobook-curator routed CLI', () => {
'select',
'whisper-verify',
]);
expect(projectedCommands).toHaveLength(15);
expect(projectedCommands.every((command) =>
command.path[0] === 'curator' && command.rendered)).toBe(true);

// inspect [--max-files N] <root>
const inspect = byName.get('inspect')!;
Expand Down Expand Up @@ -111,7 +116,7 @@ describe('audiobook-curator routed CLI', () => {
expect(byName.get('audible-cache')!.options.some((option) => option.option === 'cache-dir')).toBe(true);

// The result exit-code policy rides exactly the commands that declared it.
expect(commands.filter((command) => command.exitCode === 'result').map((command) => command.path.join(' ')).sort()).toEqual([
expect(customCommands.filter((command) => command.exitCode === 'result').map((command) => command.path.join(' ')).sort()).toEqual([
'acoustic-identify', 'acoustic-verify', 'audible-search', 'audit', 'inventory', 'library-audit', 'whisper-verify',
]);
});
Expand Down
94 changes: 94 additions & 0 deletions examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import inspectRoute, {
} from '../../src/cli/inspect.ts';
import { resultSchema as inventoryResultSchema } from '../../src/cli/inventory.ts';
import { resultSchema as libraryAuditResultSchema } from '../../src/cli/library-audit.tsx';
import { inputSchema as convertAudiobookInputSchema } from '../../src/mcp/curator/tools/convert_audiobook.tsx';
import { resultSchema as inspectSourcesResultSchema } from '../../src/mcp/curator/tools/inspect_sources.tsx';

const directories: string[] = [];

Expand Down Expand Up @@ -194,6 +196,98 @@ describe('audiobook-curator at the CLI dispatch proof level', () => {
});
});

describe('projected MCP commands', () => {
it('runs read-only inspect_sources without --yes and emits its schema-validated JSON receipt', async () => {
const { library } = await temporaryLibrary();
const run = await invokeCli([
'curator',
'inspect_sources',
'--input',
JSON.stringify({ root: library }),
'--json',
]);
const receipt = inspectSourcesResultSchema.parse(cliJson(run));

expect(run.exitCode).toBe(0);
expect(run.stderr).toBe('');
expect(run.routeId).toBe('tool:curator/inspect_sources');
expect(receipt).toMatchObject({
files: [],
operation: 'inspect',
root: library,
totalBytes: 0,
});
expect(run.value).toEqual(receipt);
});

it('fails convert_audiobook closed without --yes and reaches domain validation with it', async () => {
const { directory } = await temporaryLibrary();
const selection = join(directory, 'selection.json');
await writeFile(selection, JSON.stringify({ selections: [] }));
const input = convertAudiobookInputSchema.parse({
author: 'Example Author',
output: join(directory, 'output'),
selection,
title: 'Example Title',
});
const argv = ['curator', 'convert_audiobook', '--input', JSON.stringify(input)];

const denied = await invokeCli(argv);
expect(denied.exitCode).toBe(2);
expect(denied.stdout).toBe('');
expect(denied.stderr).toContain('--yes');
expect(denied.value).toBeUndefined();

// An empty selection reaches domain validation before any media probe or binary.
const allowed = await invokeCli([...argv, '--yes', '--json']);
expect(allowed.exitCode).toBe(1);
expect(allowed.stdout).toBe('');
expect(allowed.stderr).toContain('Selection contains no audio files.');
expect(allowed.stderr).not.toContain('requires --yes');
expect(allowed.value).toBeUndefined();
});

it('maps invalid JSON and tool inputSchema rejection to usage exit 2', async () => {
const invalidJson = await invokeCli(['curator', 'inspect_sources', '--input', '{']);
expect(invalidJson.exitCode).toBe(2);
expect(invalidJson.stdout).toBe('');
expect(invalidJson.stderr).toContain('valid JSON object');
expect(invalidJson.value).toBeUndefined();

const rejected = await invokeCli([
'curator',
'inspect_sources',
'--input',
'{"root":""}',
]);
expect(rejected.exitCode).toBe(2);
expect(rejected.stdout).toBe('');
expect(rejected.stderr).toContain('root');
expect(rejected.stderr).toContain("Run 'audiobook-curator curator inspect_sources --help' for usage.");
expect(rejected.value).toBeUndefined();
});

it('merges the curator group with custom commands and explains projected provenance', async () => {
const [rootHelp, curatorHelp, mutationHelp] = await Promise.all([
invokeCli(['--help']),
invokeCli(['curator', '--help']),
invokeCli(['curator', 'convert_audiobook', '--help']),
]);

expect(rootHelp.exitCode).toBe(0);
expect(rootHelp.stderr).toBe('');
expect(rootHelp.stdout).toMatch(/^ {2}curator <command>(?: |$)/mu);
expect(rootHelp.stdout).toMatch(/^ {2}inspect(?: |$)/mu);
expect(curatorHelp.exitCode).toBe(0);
expect(curatorHelp.stderr).toBe('');
expect(curatorHelp.stdout).toMatch(/^ {2}inspect_sources(?: |$)/mu);
expect(mutationHelp.exitCode).toBe(0);
expect(mutationHelp.stderr).toBe('');
expect(mutationHelp.stdout).toContain('MCP tool: curator:convert_audiobook');
expect(mutationHelp.stdout).toContain('Mutation-capable; requires --yes.');
});
});

describe('the rendered library-audit command', () => {
it('emits exactly one final Markdown document when stdout is piped', async () => {
const { report, run } = await invokeLibraryAudit();
Expand Down
Loading