diff --git a/.changeset/725-receipt-paths-optional.md b/.changeset/725-receipt-paths-optional.md new file mode 100644 index 000000000..22deaefad --- /dev/null +++ b/.changeset/725-receipt-paths-optional.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Document the `audiobook-curator` example's CLI migration accurately: since the `.cli.ts` projections replaced the `src/cli/` tree (#734), `inventory --report` and `convert --receipt` are optional, as they are on the tools, instead of required; a command run without one writes no report or receipt file, and exit codes, `--apply` gating, and error output are unchanged. (#738) diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 9dbf472c1..03f2f405f 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -166,7 +166,12 @@ result-schema-validated JSON value followed by a newline: the canonical final `Agent.Result` value, never the Markdown presentation or an intermediate Suspense fallback, and byte for byte the `structuredContent` of the tool call. `--report` and `--receipt` are optional on the command line exactly as they are -on the tool; a command that gets one still writes the receipt file. +on the tool; a command that gets one still writes the receipt file +when it succeeds. This is a +behavior change from the retired `src/cli/` tree, where `inventory --report` +and `convert --receipt` were required: both commands now run without a receipt +path and write no report or receipt file, and their exit codes, `--apply` gating, and error +output are unchanged either way. Each tool module declares its `inputSchema` as an inline zod literal, because the argv projection is compiled statically from that literal; it is the only diff --git a/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts b/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts index 24a9a267d..a598ef9f2 100644 --- a/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts +++ b/examples/audiobook-curator/tests/route-unit/cli-dispatch.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -10,6 +10,7 @@ import { inputSchema as convertAudiobookInputSchema } from '../../src/mcp/curato import { inputSchema as inspectInputSchema, resultSchema as inspectResultSchema } from '../../src/mcp/curator/tools/inspect_sources.tsx'; import { resultSchema as inventoryResultSchema } from '../../src/mcp/curator/tools/inventory_sources.tsx'; import { resultSchema as audibleSearchResultSchema } from '../../src/mcp/curator/tools/search_audible.tsx'; +import { resultSchema as audibleSelectResultSchema } from '../../src/mcp/curator/tools/select_audible_edition.tsx'; import { discoveryOperations } from '../../src/operations/discovery.ts'; const directories: string[] = []; @@ -148,6 +149,22 @@ describe('audiobook-curator at the CLI dispatch proof level', () => { expect(inventoryResultSchema.parse(JSON.parse(await readFile(report, 'utf8')))).toEqual(receipt); }); + it('runs inventory without --report, as the tool allows, and writes no report file', async () => { + // Migration note (#734): the retired `src/cli/inventory.tsx` required + // `--report`; the projected command shares the tool's optional field. + const { directory, library } = await temporaryLibrary(); + const run = await invokeCli(['inventory', library, '--strict', '--json']); + expect(await readdir(directory)).toEqual(['library']); + const receipt = inventoryResultSchema.parse(cliJson(run)); + const tool = await invokeMcpTool('inventory_sources', { input: { source: library, strict: true } }); + + expect(run.exitCode).toBe(0); + expect(receipt).toMatchObject({ exitCode: 0, operation: 'inventory', summary: { errors: 0, files: 0 } }); + expect(run.value).toEqual(receipt); + expect(tool.isError).toBe(false); + expect(withoutGeneratedAt(tool.structuredContent)).toEqual(withoutGeneratedAt(receipt)); + }); + it('uses a failing inventory receipt exit code as the process exit code without ffprobe', async () => { const { directory, library, report } = await temporaryLibrary(); await writeFile(join(library, 'broken.mp3'), 'not audio'); @@ -358,6 +375,15 @@ describe('audiobook-curator at the CLI dispatch proof level', () => { expect(planned.stderr).not.toContain('--yes'); expect(planned.value).toBeUndefined(); + // Migration note (#734): the retired `src/cli/convert.tsx` required + // `--receipt`; the projected command shares the tool's optional field. + // With or without it, the failure is the same and no receipt is written. + const receipt = join(directory, 'convert-receipt.json'); + const withReceipt = await invokeCli([...argv, '--receipt', receipt, '--json']); + expect(withReceipt.exitCode).toBe(1); + expect(withReceipt.stderr).toBe(planned.stderr); + await expect(readFile(receipt, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + // The projection declares confirm: false, so --yes is not an option here. const confirmed = await invokeCli([...argv, '--yes']); expect(confirmed.exitCode).toBe(2); @@ -370,6 +396,60 @@ describe('audiobook-curator at the CLI dispatch proof level', () => { }); }); + describe('receipt paths are optional on the projected commands', () => { + const candidateReport = async (): Promise<{ readonly candidates: string; readonly directory: string }> => { + const { directory } = await temporaryLibrary(); + const candidates = join(directory, 'candidates.json'); + await writeFile(candidates, JSON.stringify({ + candidates: [{ + asin: 'B0CURATOR01', + authors: [{ name: 'Ada Author' }], + evidence: { authorMatch: true, languageMatch: true, narratorMatch: true, score: 100, strictIdentityMatch: true, titleMatch: true, unabridged: true }, + narrators: [{ name: 'Nora Narrator' }], + region: 'us', + title: 'The Selected Edition', + }], + errors: [], + exitCode: 0, + generatedAt: '2026-09-02T18:00:00.000Z', + humanReviewRequired: true, + mutation: false, + operation: 'audible-search', + query: { title: 'The Selected Edition' }, + reviewNote: 'Choose the matching edition.', + })); + return { candidates, directory }; + }; + + it('records an Audible selection with and without --receipt and writes the file only when asked', async () => { + const { candidates, directory } = await candidateReport(); + const receiptPath = join(directory, 'selection.json'); + const argv = ['audible-select', '--candidate', '1', '--candidates', candidates, '--json']; + + const without = await invokeCli(argv); + expect(await readdir(directory)).toEqual(['candidates.json', 'library']); + const withReceipt = await invokeCli([...argv, '--receipt', receiptPath]); + const tool = await invokeMcpTool('select_audible_edition', { input: { candidate: 1, candidates } }); + + for (const run of [without, withReceipt]) { + expect(run.exitCode).toBe(0); + expect(run.stderr).toBe(''); + expect(run.routeId).toBe('tool:curator/select_audible_edition'); + expect(audibleSelectResultSchema.parse(cliJson(run))).toMatchObject({ + candidateNumber: 1, + humanReviewed: true, + mutation: false, + operation: 'audible-select', + selected: { asin: 'B0CURATOR01' }, + }); + } + expect(withoutGeneratedAt(cliJson(withReceipt))).toEqual(withoutGeneratedAt(cliJson(without))); + expect(withoutGeneratedAt(tool.structuredContent)).toEqual(withoutGeneratedAt(cliJson(without))); + expect(audibleSelectResultSchema.parse(JSON.parse(await readFile(receiptPath, 'utf8')))).toEqual(withReceipt.value); + expect((await readdir(directory)).filter((name) => name.endsWith('.json')).sort()).toEqual(['candidates.json', 'selection.json']); + }); + }); + describe('the rendered library-audit command', () => { it('emits exactly one final Markdown document when stdout is piped', async () => { const { report, run } = await invokeLibraryAudit(); diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 9ab4ac66f..6c8c449f7 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -76,7 +76,10 @@ emits one result-schema-validated JSON value followed by a newline: the canonica `Agent.Result` value, never the Markdown presentation and never an intermediate Suspense fallback, and byte for byte the `structuredContent` of the tool call. `--report` and `--receipt` are optional on the command line exactly as they are on the tool; a command that gets one still -writes the receipt file. +writes the receipt file when it succeeds. This is a behavior change from the retired `src/cli/` tree, where +`inventory --report` and `convert --receipt` were required: both commands now run without a +receipt path and write no report or receipt file, and their exit codes, `--apply` gating, and error output are +unchanged either way. ## Working in the workspace diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index a9db9d63b..3f3737f68 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -61,7 +61,9 @@ description: '有声书策展器示例:一个由路由模块、请求上下文 最终的 Markdown 文档——与 MCP 客户端作为文本内容收到的是同一份标题与报告。`--json` 选择机器输出, 并输出一个经结果 schema 校验的 JSON 值加一个换行:这是最终的规范 `Agent.Result` 值,绝不是 Markdown 表现,也绝不是中间的 Suspense 回退,并且与工具调用的 `structuredContent` 逐字节一致。`--report` 与 -`--receipt` 在命令行上和在工具上一样是可选的;传入了的命令仍会写出收据文件。 +`--receipt` 在命令行上和在工具上一样是可选的;传入了的命令在成功时仍会写出收据文件。这相对于已移除的 +`src/cli/` 树是一处行为变化:那里的 `inventory --report` 与 `convert --receipt` 是必填的;现在这两条命令 +不带收据路径也能运行且不写报告或收据文件,而退出码、`--apply` 把关与错误输出在两种情况下都不变。 ## 在工作区中开发