diff --git a/.changeset/flagship-route-authoring.md b/.changeset/flagship-route-authoring.md new file mode 100644 index 000000000..9750fe727 --- /dev/null +++ b/.changeset/flagship-route-authoring.md @@ -0,0 +1,5 @@ +--- +"create-agent-bundle": minor +--- + +Generate single-file MCP route modules with matching runtime dependencies. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 91bed376f..c12ab3be6 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -57,6 +57,8 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/cli.ts` | Package bin named after `plugin.name` (skipped when the name is not a safe output name). | `bin: false` | | `src/index.ts` | Library output with declarations. | `lib: false` | | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | +| `src/mcp//{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.` to `custom`, `command`, or `remote` | +| `src/mcp//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/.ts` | Plain script compiled to `scripts/.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 | Conventions match `.ts` and `.tsx` files exactly. @@ -73,7 +75,14 @@ See `docs/diagnostics.md` for each trigger and how to adopt or silence it. ## Generated entry shells -The framework provides the entry files consumers used to write by hand +A route-mode MCP surface emits a public lifecycle entry plus one warm internal +Flight worker. The entry owns `runAgentRequest`, session/actor binding, the +final Agent Document dispatcher, legal MCP projection, resource/prompt +registration, and compiled App resources. The worker exists solely to isolate +React's `react-server` condition and is reused until that MCP process closes; +raw Flight bytes never cross the public MCP wire. + +The framework also provides the entry files consumers used to write by hand (react-router's provided-entry trick). Every generated shell imports the consumer module by absolute path and is bundled through the same Rslib synthesis and invariant assertions as all generated executables. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 054a4ee71..391f53ac7 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -1,194 +1,65 @@ # Framework mode -Structure lives in `agent-bundle.config.ts` and file conventions. JSX renders. -That is the whole model (RFC #63); RFC #50's entry conventions are the sibling -contract for `bin`/`lib`/MCP entries. - -## What a newcomer must learn - -Three things: - -1. **One directory convention.** Every `skills//SKILL.md` ships as a - Skill. Add a folder and it ships — no declaration anywhere. -2. **One flat config file.** `agent-bundle.config.ts` declares the plugin - identity, targets, and anything a file cannot say for itself: +Agent Bundle has one newcomer model: + +1. **Files under conventional `src/` roots are the app.** For MCP, put one + module at `src/mcp//{tools,resources,prompts}/.tsx`; its path + is its identity. `skills//SKILL.md`, `src/scripts/.ts`, + `src/cli.ts`, and `src/index.ts` keep their existing conventions. +2. **One small flat config.** `agent-bundle.config.ts` holds project identity, + targets, and policy that no route file can own. +3. **JSX = rendering.** An executable route is one async default Server + Component. It does the work and returns `Agent.*`; there is no public + `execute`/`render` split. +4. **Opt in to context.** Call `await agent()` inside that component only when + host, session, actor, workspace, capability, or state context is needed. + +The complete conventional config is usually: ```ts -import { defineConfig } from 'agent-bundle'; +import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ - plugin: { description: '…', name: 'my-plugin', version: '0.1.0' }, + plugin: { description: 'Evidence-backed project tools.', name: 'my-plugin', version: '0.1.0' }, targets: ['portable', 'codex', 'claude'], }); ``` -3. **JSX = rendering.** React elements appear only where something is - rendered: MCP/hook results at runtime (`Mcp.Result`, `Hook.Text`), and - skill documents at build time (below). There are no structural JSX - elements — no ``, ``, or ``. - -Entry files follow the same convention-with-fallback trick: `src/cli.ts` is -the package bin, `src/index.ts` the library, `src/mcp/.ts` a -declared server's stdio entry — each applies when the file exists, and -explicit config always wins over a convention (`AB473x` nudges flag the -confusable shadowed states). See `docs/entry-conventions.md`. - -## Applications with operations (when you have a CLI or MCP server) - -`defineRscApplication` declares the runtime identity plus one typed operation -catalog; the conventional entries consume it: - -```ts -// src/application.ts -export const application = defineRscApplication({ - name: 'my-plugin', - operations: [status], - version: '0.1.0', -}); - -// src/cli.ts -export const main = (argv: readonly string[]) => runRscCli(application, argv); - -// src/mcp/runtime.ts -export default () => createRscMcpServer(application, 'runtime'); -``` - -The server's structural declaration (`mcp.servers.runtime: {}`) lives in the -config; the name passed to `createRscMcpServer` only selects which operations -to serve. - -## One operation, end to end - -An **operation** is a host-neutral use-case definition: one named unit of -work with a validated input, an implementation, a validated result, and a -result renderer. It is *not* a CLI command — the CLI command and the MCP -tool are optional projections declared alongside the shared core, and -either (or both) may be present. The `status` operation used above looks -like this in full, including the JSX: +A tool is one file: ```tsx -// src/operations/status.tsx -import { defineOperation } from '@agent-bundle/runtime/plugin'; -import { Mcp } from '@agent-bundle/runtime'; +// src/mcp/runtime/tools/status.tsx +import React from 'react'; +import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; +import { Agent, agent } from '@agent-bundle/runtime'; import { z } from 'zod'; -export const status = defineOperation({ - // Shared core — both projections funnel through these four fields. - id: 'status', - inputSchema: z.object({ verbose: z.boolean().optional() }).strict(), - execute: async () => ({ status: 'ready' as const }), - resultSchema: z.object({ status: z.literal('ready') }).strict(), - - // CLI projection — argv parsing, help text, exit codes. No JSX: the CLI - // prints the validated result as one line of JSON. - cli: { - name: 'status', - parse: (args) => (args.includes('--verbose') ? { verbose: true } : {}), - summary: 'Read runtime status.', - usage: 'status [--verbose]', - }, - - // MCP projection — tool metadata plus the result renderer. Only MCP - // consumes `render`, but it is a required field: a CLI-only operation - // still has to declare one. - mcp: { - description: 'Read runtime status.', - name: 'runtime_status', - readOnly: true, - server: 'runtime', - }, - render: (result) => ( - - {`Runtime is ${result.status}.`} - - ), -}); +export const config = { + annotations: { readOnlyHint: true }, + description: 'Read runtime status.', +} satisfies ToolConfig; +export const inputSchema = z.object({ verbose: z.boolean().optional() }).strict(); +export const resultSchema = z.object({ status: z.literal('ready') }).strict(); + +export default async function Status({ input, signal }: ToolRouteProps) { + if (signal.aborted) throw new DOMException('aborted', 'AbortError'); + if (input.verbose) await agent(); + const result = { status: 'ready' as const }; + return Runtime is ready.; +} ``` -Both projections run the identical pipeline — -`inputSchema.parse(input)` → `execute(input, { signal })` → -`resultSchema.parse(result)` — so inputs, implementation, and output -validation cannot drift between surfaces. Only the last step differs: - -- **CLI** (`runRscCli`): `cli.parse` receives the arguments after the - command name and produces the input; the validated result is written to - stdout as one line of JSON (`JSON.stringify`), and `cli.exitCode(result)` - (default `0`) is returned to the entry, which sets it as the process exit - code. The CLI never touches `render` and never renders JSX. -- **MCP** (`createRscMcpServer`): the tool handler calls `render(result)` - and `lowerMcpResult` synchronously lowers the returned React element tree - (`Mcp.Result`, `Mcp.Text`, `Mcp.Image`, `Mcp.Audio`, `Mcp.ResourceLink`, - `Mcp.EmbeddedResource`) into a plain MCP `CallToolResult` object. The - lowering is strict — `Mcp.Text` takes exactly one string child, hence the - template literal above. - -## Why `.tsx`, and current renderer status - -The operation model shown above still uses the **synchronous MCP result DSL**: -`render` returns ordinary React elements and `lowerMcpResult` walks that tree -to produce the `CallToolResult` the MCP SDK sends. That compatibility path does -not involve Flight and remains the operative MCP projection. - -Separately, `@agent-bundle/runtime` now exposes a final-only React-owned Flight -dispatcher for generated routes. An execution host supplies Flight bytes, the -dispatcher decodes intrinsic `Agent.*` elements into one immutable -`AgentDocument`, and cancellation follows the request `AbortSignal`. Streaming -Suspense replacement and public filesystem-route authoring are later stages. -Operations receive no implicit storage: persistent application state exists -only through the opt-in `@agent-bundle/runtime/state` kernel, which stateless -projects never import. - -Operation modules are `.tsx` for exactly one reason: the `render` callback -returns JSX. Everything else in an operation — schemas, argv parsing, MCP -metadata — is plain TypeScript, and modules with no runtime JSX (such as an -application module that only composes operation arrays) stay `.ts`. - -For a new reader, in one breath: - -1. **What is an operation?** A host-neutral use-case definition — id, input - schema, `execute`, result schema, `render` — with optional CLI and MCP - projections. -2. **Which parts are shared by CLI and MCP?** The core four: `id`, - `inputSchema`, `execute`, `resultSchema` (plus the validation pipeline - around them). -3. **Which projection consumes `render`?** Only MCP, though every operation - must declare one. The CLI serializes the validated result as JSON. -4. **Is Flight involved in this operation projection?** No. - `lowerMcpResult` remains synchronous. The separate generated-route path uses - the final-only `AgentRenderDispatcher` described above. -5. **Why are operation modules `.tsx`?** Only because `render` returns JSX. - -## Rendered skills (power tier, never required) - -A skill whose document is generated: put `SKILL.tsx` (or `SKILL.ts`) in the -skill directory instead of `SKILL.md`. The module default-exports a component -and exports a `frontmatter` record; the build renders the tree to Markdown -and emits the `SKILL.md` every host consumes. - -```tsx -// skills/deploy-checklist/SKILL.tsx -export const frontmatter = { - description: 'Deployment checklist.', - name: 'deploy-checklist', -}; - -export default () => ( - <> -

Deploy checklist

-

Verify each step in order.

- -); -``` - -The renderer supports a documented element subset (`h1`–`h6`, `p`, -`ul`/`ol`/`li`, `strong`, `em`, `code`, `pre`, `blockquote`, `a`, `hr`, -`br`, fragments) and rejects anything outside it by name — never a silent -approximation. Components may be async, and may import project code, so the -document can be computed from the same sources the plugin ships. A -hand-authored `SKILL.md` in the same directory always wins (`AB4735`). - -## Precedence, said once - -Config wins, conventions fill. Declaring `skills:` in config replaces the -directory convention entirely (`AB4734` flags any directory left uncovered); -the same rule governs `bin`, `lib`, and MCP server entries. +The compiler statically reads `config`, imports schemas and implementations +only into generated entries, installs `runAgentRequest`, and derives the real +MCP server from the route graph. Each call renders through a warm internal +Flight dispatcher and lowers the final Agent Document to legal MCP output. +Flight is an implementation transport inside the generated runtime, never a +public host wire protocol. + +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/.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. diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 9e730c0c0..3d9d1f0c4 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -8,8 +8,7 @@ pnpm example:audiobook A complete TypeScript recreation of the original `audiobook-curator`, built in framework mode: `agent-bundle.config.ts` plus file conventions declare the -structure, and one typed operation catalog produces a globally installable -CLI, one stdio MCP server, one Skill, and native Claude Code and Codex plugin +structure, and filesystem route modules produce one generated stdio MCP server, while a compatibility CLI remains globally installable, one Skill, and native Claude Code and Codex plugin artifacts. JSX appears only where something is rendered — the MCP result receipts. It has no hooks and does not call the old Python curator. @@ -48,57 +47,32 @@ script, and lifecycle-wrapped MCP server) plus the npm package build beneath `agent-bundle` and `@agent-bundle/runtime` exports with `workspace:*` dependencies. -## Operation model - -Every command is one `defineOperation` definition: a host-neutral use case — -`id`, input schema, `execute`, result schema, `render` — with two -projections declared beside it. The shared core runs identically on both -surfaces (`inputSchema.parse` → `execute` → `resultSchema.parse`); `cli` -adds argv parsing and exit codes, and `mcp` adds tool metadata. `render` is -a sibling of both, required on every operation but consumed only by the MCP -projection: the CLI prints each validated receipt as one line of JSON and -never renders JSX. - -The runtime JSX for every operation is `` in -[`src/result.tsx`](src/result.tsx), which wraps the receipt in the MCP -result DSL: - -```tsx -export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) => ( - - {summary(receipt)} - -); -``` +## Route model + +The MCP application is the route tree under `src/mcp/curator/`: fifteen tool +modules plus one resource and one prompt. Every executable route exports static +`config`, `inputSchema`, `resultSchema`, and one async default Server Component +that executes the domain operation and renders `Agent.*`. The compiler derives +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. -`lowerMcpResult` lowers that element tree synchronously into an MCP -`CallToolResult`. No React Server Components renderer or Flight transport is -involved anywhere in this example; operation modules are `.tsx` only because -`render` returns JSX, and `src/application.ts` stays `.ts` because it merely -composes the operation arrays. The end-to-end walkthrough is in -[Framework mode](../../docs/framework-mode.md). +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. ## Source layout -- `agent-bundle.config.ts` — the structure: plugin identity, targets, the CLI - script, and the MCP server (whose entry is the `src/mcp/curator.ts` - convention). The Skill needs no declaration at all: - `skills/curate-audiobooks/SKILL.md` ships by convention. -- `src/application.ts` — composition only: merges the feature modules' - defaults into one `defineRscApplication` operation catalog. -- `src/operations/` — the operation catalog, grouped by workflow stage: - `discovery` (inspect/inventory/library-audit/select), `audible` - (search/select/cache), `evidence` (acoustic/whisper), `media-mutation` - (apply-metadata/apply-chapters), and `output` (convert/prepare/audit), with - shared `cli-arguments.ts` and `schemas.ts`. -- Domain logic lives beside them in `src/` (`library.ts`, `audible.ts`, - `evidence.ts`, `conversion.ts`, `media-mutation.ts`, `integrity-audit.ts`, - `curator-core.ts`) over the shared `foundation.ts` and `media-process.ts` - primitives; `result.tsx` renders every receipt for MCP. -- `src/cli.ts` exports `main`; the framework's generated process envelope - turns it into both the bundled script artifact entry and the npm bin. - `src/mcp/curator.ts` default-exports a server factory served under the - framework's stdio lifecycle shell. No hand-written entry shims remain. +- `agent-bundle.config.ts` — plugin identity, selected targets, and the bundled + CLI script; MCP needs no declaration. +- `src/mcp/curator/tools/` — one single-file route per MCP tool. +- `src/mcp/curator/resources/catalog.tsx` and `prompts/curate.tsx` — the routed + resource and prompt proofs. +- `src/operations/` — CLI-only compatibility command data and shared schemas; MCP + metadata and server strings do not live here. +- Domain logic remains in `src/` over `foundation.ts` and `media-process.ts`; + `result.tsx` renders route receipts as Agent Documents. +- `src/cli.ts` and `src/index.ts` keep the package bin/library conventions. ## Complete workflow diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 177bab518..4fcedf9bb 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -2,14 +2,6 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ marketplace: true, - mcp: { - servers: { - // No `entry` needed: the conventional stdio entry `src/mcp/curator.ts` - // supplies it, and its default-exported factory runs under the - // framework lifecycle shell. - curator: {}, - }, - }, plugin: { description: 'Complete plan-first audiobook inventory, matching, conversion, repair, and integrity audit.', diff --git a/examples/audiobook-curator/docs/parity-ledger.md b/examples/audiobook-curator/docs/parity-ledger.md index 0da19ff1f..22295c124 100644 --- a/examples/audiobook-curator/docs/parity-ledger.md +++ b/examples/audiobook-curator/docs/parity-ledger.md @@ -17,7 +17,7 @@ adapter coverage. | Sources are immutable; mutation is plan-only without explicit `apply` | mutation foundation | real/synthetic before-and-after hashes | | No shell execution; bounded output; caller cancellation; no local media deadline | capability/process foundation | child-process tests | | Natural ordering, Unicode-safe identity, safe filenames without apostrophes | domain text foundation | ported pure tests | -| Claude and Codex derive Skill, script, and MCP from one config plus conventions | `agent-bundle.config.ts`, `src/application.ts` | artifact and installed-host tests | +| Claude and Codex derive Skill, script, and MCP from one config plus conventions | `agent-bundle.config.ts`, `src/mcp/curator/` route tree | artifact and installed-host tests | ## Operations diff --git a/examples/audiobook-curator/src/application.ts b/examples/audiobook-curator/src/application.ts deleted file mode 100644 index bcff1392e..000000000 --- a/examples/audiobook-curator/src/application.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * The audiobook-curator application: runtime identity plus the operation - * catalog. Structure — targets, the Skill, the CLI script, the MCP server — - * lives in `agent-bundle.config.ts` and file conventions; the operations - * themselves live in feature modules under `./operations/`, and this file - * only merges their defaults. There is deliberately no JSX here: the only - * runtime JSX is each operation's `render`, which delegates to - * `` in `./result.tsx` for the MCP projection. - */ -import { defineRscApplication } from '@agent-bundle/runtime/plugin'; - -import { - audibleOperations, - defaultAudibleOperations, - type AudibleOperations, -} from './operations/audible.tsx'; -import { - defaultDiscoveryOperations, - discoveryOperations, - type DiscoveryOperations, -} from './operations/discovery.tsx'; -import { - defaultEvidenceOperations, - evidenceOperations, - type EvidenceOperations, -} from './operations/evidence.tsx'; -import { - defaultMediaMutationOperations, - mediaMutationOperations, - type MediaMutationOperations, -} from './operations/media-mutation.tsx'; -import { - defaultOutputOperations, - outputOperations, - type OutputOperations, -} from './operations/output.tsx'; - -/** - * Injection surface for tests and embedders: every operation executor can be - * replaced while the CLI/MCP projections and schemas stay identical. - */ -export type AudiobookCuratorOperations = - & AudibleOperations - & DiscoveryOperations - & EvidenceOperations - & MediaMutationOperations - & OutputOperations; - -const operationDefinitions = (operations: Required) => Object.freeze([ - ...evidenceOperations(operations), - ...mediaMutationOperations(operations), - ...audibleOperations(operations), - ...discoveryOperations(operations), - ...outputOperations(operations), -]); - -export const createAudiobookCuratorApplication = ( - options: { readonly operations?: AudiobookCuratorOperations } = {}, -) => defineRscApplication({ - description: 'Complete plan-first audiobook inventory, matching, conversion, repair, and integrity audit.', - name: 'audiobook-curator', - operations: operationDefinitions({ - ...defaultAudibleOperations, - ...defaultDiscoveryOperations, - ...defaultEvidenceOperations, - ...defaultMediaMutationOperations, - ...defaultOutputOperations, - ...options.operations, - }), - version: '1.0.0', -}); - -export const audiobookCuratorApplication = createAudiobookCuratorApplication(); diff --git a/examples/audiobook-curator/src/cli-command.ts b/examples/audiobook-curator/src/cli-command.ts new file mode 100644 index 000000000..3a2812e0f --- /dev/null +++ b/examples/audiobook-curator/src/cli-command.ts @@ -0,0 +1,74 @@ +export interface CliCommandContext { + readonly signal: AbortSignal; +} + +interface Schema { + readonly _output: Output; + parse(value: unknown): Output; +} + +type SchemaOutput = Value extends Schema ? Output : never; + +interface CliProjection { + readonly exitCode?: (result: Result) => 0 | 1 | 2; + readonly name: string; + readonly parse: (args: readonly string[]) => Input; + readonly summary: string; + readonly usage: string; +} + +interface CliCommandDefinition< + InputSchema extends Schema, + ResultSchema extends Schema, + ParsedInput, + HandlerInput, +> { + readonly cli: CliProjection>; + readonly handler: (input: HandlerInput, context: CliCommandContext) => unknown; + readonly id: string; + readonly inputSchema: InputSchema; + readonly resultSchema: ResultSchema; +} + +export const defineCliCommand = < + InputSchema extends Schema, + ResultSchema extends Schema, + ParsedInput, + HandlerInput, +>( + definition: CliCommandDefinition, +): CliCommandDefinition => Object.freeze(definition); + +interface RuntimeCliCommand { + readonly cli: CliProjection; + 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; + const input = command.inputSchema.parse(command.cli.parse(argv.slice(1))); + const result = command.resultSchema.parse(await command.handler(input, { signal })); + write(`${JSON.stringify(result)}\n`); + return command.cli.exitCode?.(result) ?? 0; +}; diff --git a/examples/audiobook-curator/src/cli.ts b/examples/audiobook-curator/src/cli.ts index ed946869f..3e3d4b07f 100644 --- a/examples/audiobook-curator/src/cli.ts +++ b/examples/audiobook-curator/src/cli.ts @@ -1,11 +1,35 @@ -import { runRscCli } from '@agent-bundle/runtime/plugin'; - import { - createAudiobookCuratorApplication, - type AudiobookCuratorOperations, -} from './application.js'; + audibleOperations, + defaultAudibleOperations, + type AudibleOperations, +} from './operations/audible.js'; +import { + defaultDiscoveryOperations, + discoveryOperations, + type DiscoveryOperations, +} from './operations/discovery.js'; +import { + defaultEvidenceOperations, + evidenceOperations, + type EvidenceOperations, +} from './operations/evidence.js'; +import { + defaultMediaMutationOperations, + mediaMutationOperations, + type MediaMutationOperations, +} from './operations/media-mutation.js'; +import { + defaultOutputOperations, + outputOperations, + type OutputOperations, +} from './operations/output.js'; +import { runCliCommands } from './cli-command.js'; -export type CuratorOperations = AudiobookCuratorOperations; +export type CuratorOperations = AudibleOperations + & DiscoveryOperations + & EvidenceOperations + & MediaMutationOperations + & OutputOperations; export interface CliOptions { readonly operations?: CuratorOperations; @@ -16,14 +40,27 @@ export interface CliOptions { export const runCli = ( argv: readonly string[], options: CliOptions = {}, -): Promise<0 | 1 | 2> => runRscCli( - createAudiobookCuratorApplication({ ...(options.operations === undefined ? {} : { operations: options.operations }) }), - argv, - { +): Promise<0 | 1 | 2> => { + const operations = { + ...defaultAudibleOperations, + ...defaultDiscoveryOperations, + ...defaultEvidenceOperations, + ...defaultMediaMutationOperations, + ...defaultOutputOperations, + ...(options.operations ?? {}), + }; + const commands = Object.values({ + ...evidenceOperations(operations), + ...mediaMutationOperations(operations), + ...audibleOperations(operations), + ...discoveryOperations(operations), + ...outputOperations(operations), + }); + return runCliCommands(commands, argv, { ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.write === undefined ? {} : { write: options.write }), - }, -); + }); +}; export const main = async (argv: readonly string[]): Promise => { try { diff --git a/examples/audiobook-curator/src/index.ts b/examples/audiobook-curator/src/index.ts index 68df09572..e43886f97 100644 --- a/examples/audiobook-curator/src/index.ts +++ b/examples/audiobook-curator/src/index.ts @@ -43,8 +43,6 @@ export type { MetadataInput, MetadataReceipt, } from './media-mutation.js'; -export { audiobookCuratorApplication, createAudiobookCuratorApplication } from './application.js'; -export type { AudiobookCuratorOperations } from './application.js'; export { alacChunkCounts, chapterMappingIssues, diff --git a/examples/audiobook-curator/src/mcp/curator.ts b/examples/audiobook-curator/src/mcp/curator.ts deleted file mode 100644 index 7d4d33e8b..000000000 --- a/examples/audiobook-curator/src/mcp/curator.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createRscMcpServer } from '@agent-bundle/runtime/plugin'; - -import { audiobookCuratorApplication } from '../application.js'; - -export const createAudiobookCuratorServer = () => createRscMcpServer(audiobookCuratorApplication, 'curator'); - -/** - * Default-exported server factory at the conventional `src/mcp/curator.ts` - * entry: `agent-bundle build` detects it and wraps it in the framework stdio - * lifecycle shell (console-to-stderr guard, SIGINT/SIGTERM handling, - * stdin-EOF exit, bounded shutdown, heartbeat). - */ -export default createAudiobookCuratorServer; diff --git a/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx b/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx new file mode 100644 index 000000000..3b3c295c6 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/prompts/curate.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { PromptConfig, ToolRouteProps } from 'agent-bundle'; +import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + description: 'Start an evidence-first audiobook curation review.', +} satisfies PromptConfig; +export const inputSchema = z.object({ root: z.string().min(1) }).strict(); +export const resultSchema = z.object({ + messages: z.array(z.object({ + content: z.object({ text: z.string(), type: z.literal('text') }).strict(), + role: z.literal('user'), + }).strict()), +}).strict(); + +export default async function Curate({ input }: ToolRouteProps) { + const result = { + messages: [{ + content: { text: `Inspect ${input.root}, retain evidence, and require review before mutation.`, type: 'text' as const }, + role: 'user' as const, + }], + }; + return ( + + Evidence-first curation prompt ready. + + ); +} diff --git a/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx b/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx new file mode 100644 index 000000000..ae3861267 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/resources/catalog.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { ResourceConfig, ToolRouteProps } from 'agent-bundle'; +import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + description: 'Read the audiobook curator workflow catalog.', + mimeType: 'application/json', + uri: 'audiobook-curator://catalog', +} satisfies ResourceConfig; +export const inputSchema = z.object({ uri: z.string() }).strict(); +export const resultSchema = z.object({ + contents: z.array(z.object({ mimeType: z.literal('application/json'), text: z.string(), uri: z.string() }).strict()), +}).strict(); + +export default async function Catalog({ input }: ToolRouteProps) { + const result = { + contents: [{ + mimeType: 'application/json' as const, + text: JSON.stringify({ stages: ['discover', 'identify', 'curate', 'verify'] }), + uri: input.uri, + }], + }; + return ( + + Audiobook curator catalog ready. + + ); +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx new file mode 100644 index 000000000..e7b232fce --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_chapters.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; + +const operation = mediaMutationOperations(defaultMediaMutationOperations).applyChapters; + +export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan or explicitly apply verified chapter rows while preserving all non-chapter media state."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx new file mode 100644 index 000000000..b2a406ce1 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/apply_audiobook_metadata.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultMediaMutationOperations, mediaMutationOperations } from '../../../operations/media-mutation.js'; + +const operation = mediaMutationOperations(defaultMediaMutationOperations).applyMetadata; + +export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan or explicitly apply verified catalog metadata and artwork while preserving every audio stream."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx new file mode 100644 index 000000000..f245d0ad8 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; + +const operation = outputOperations(defaultOutputOperations).audit; + +export const config = {"annotations":{"readOnlyHint":false},"description":"Validate chapter structure, optional conversion mapping, file/audio hashes, probe facts, and optional full decode."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx new file mode 100644 index 000000000..973668882 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; + +export const config = {"annotations":{"readOnlyHint":false},"description":"Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx new file mode 100644 index 000000000..041c95683 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/cache_audible_edition.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; + +const operation = audibleOperations(defaultAudibleOperations).audibleCache; + +export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Cache a reviewed Audible edition and retained source evidence."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx new file mode 100644 index 000000000..e2895df81 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/convert_audiobook.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; + +const operation = outputOperations(defaultOutputOperations).convert; + +export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan or explicitly apply a verified FFmpeg or Audiobook Forge conversion while preserving sources."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx new file mode 100644 index 000000000..0051e83f6 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; + +const operation = evidenceOperations(defaultEvidenceOperations).acousticIdentify; + +export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Try ranked Audible candidates, retaining skips/errors and stopping at the first acoustic match by default."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx new file mode 100644 index 000000000..998feaa92 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/inspect_sources.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).inspect; + +export const config = {"annotations":{"readOnlyHint":true},"description":"Inspect a bounded directory tree and report supported audiobook media without changing it."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx new file mode 100644 index 000000000..a0c88515c --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).inventory; + +export const config = {"annotations":{"readOnlyHint":false},"description":"Inventory source audio with retained per-file probe evidence."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx new file mode 100644 index 000000000..50d7fa99a --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/prepare_audiobook.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultOutputOperations, outputOperations } from '../../../operations/output.js'; + +const operation = outputOperations(defaultOutputOperations).prepare; + +export const config = {"annotations":{"destructiveHint":true,"readOnlyHint":false},"description":"Plan an M4B output, or apply the plan only when apply is explicitly true."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx new file mode 100644 index 000000000..99b324d14 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; + +const operation = audibleOperations(defaultAudibleOperations).audibleSearch; + +export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Search Audible regions and return ranked identity evidence requiring human review."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx new file mode 100644 index 000000000..bc892de11 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_audible_edition.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultAudibleOperations, audibleOperations } from '../../../operations/audible.js'; + +const operation = audibleOperations(defaultAudibleOperations).audibleSelect; + +export const config = {"annotations":{"readOnlyHint":false},"description":"Record an explicit human-reviewed Audible edition choice from a candidate report."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx new file mode 100644 index 000000000..0f1cb7e79 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/select_sources.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; + +const operation = discoveryOperations(defaultDiscoveryOperations).select; + +export const config = {"annotations":{"readOnlyHint":false},"description":"Select strongest source encodings while retaining alternates and duration review evidence."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx new file mode 100644 index 000000000..fbc07f1e5 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; + +const operation = evidenceOperations(defaultEvidenceOperations).acousticVerify; + +export const config = {"annotations":{"openWorldHint":true,"readOnlyHint":false},"description":"Compare a bounded Audible sample with local audio through an optional Audiolocate Python capability."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx new file mode 100644 index 000000000..feddd8788 --- /dev/null +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import type { ToolRouteProps } from 'agent-bundle'; + +import { CuratorResult, type CuratorReceipt } from '../../../result.js'; +import { defaultEvidenceOperations, evidenceOperations } from '../../../operations/evidence.js'; + +const operation = evidenceOperations(defaultEvidenceOperations).whisperVerify; + +export const config = {"annotations":{"readOnlyHint":false},"description":"Extract and transcribe distributed PCM windows for human language, story, and narrator review."}; +export const inputSchema = operation.inputSchema; +export const resultSchema = operation.resultSchema; + +export default async function Route({ input, signal }: ToolRouteProps) { + const receipt = await operation.handler(input, { signal }) as CuratorReceipt; + return ; +} diff --git a/examples/audiobook-curator/src/operations/audible.tsx b/examples/audiobook-curator/src/operations/audible.ts similarity index 85% rename from examples/audiobook-curator/src/operations/audible.tsx rename to examples/audiobook-curator/src/operations/audible.ts index c78502c8c..a58bc070e 100644 --- a/examples/audiobook-curator/src/operations/audible.tsx +++ b/examples/audiobook-curator/src/operations/audible.ts @@ -3,8 +3,7 @@ * and `audible-cache`, backed by `../audible.ts`. Ranking is evidence only; * `audible-select` records the required human edition choice. */ -import { defineOperation, type RscOperationContext } from '@agent-bundle/runtime/plugin'; -import React from 'react'; +import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { @@ -19,7 +18,6 @@ import { type AudibleSelectionReceipt, } from '../audible.ts'; import { readJson, writeReceipt } from '../foundation.ts'; -import { CuratorResult } from '../result.tsx'; import { assertOptions, numberOption, @@ -32,11 +30,11 @@ import { import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } from './schemas.ts'; export interface AudibleOperations { - readonly audibleCache?: (input: AudibleCacheInput, options: RscOperationContext) => Promise; - readonly audibleSearch?: (input: AudibleSearchInput, options: RscOperationContext) => Promise; + readonly audibleCache?: (input: AudibleCacheInput, options: CliCommandContext) => Promise; + readonly audibleSearch?: (input: AudibleSearchInput, options: CliCommandContext) => Promise; readonly audibleSelect?: ( input: { readonly candidate: number; readonly candidates: string; readonly note?: string; readonly receipt?: string }, - options: RscOperationContext, + options: CliCommandContext, ) => Promise; } @@ -78,8 +76,8 @@ const audibleRegionList = (value: string): readonly AudibleRegion[] => value.spl return candidate as AudibleRegion; }); -export const audibleOperations = (operations: Required) => [ - defineOperation({ +export const audibleOperations = (operations: Required) => ({ + audibleSearch: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'audible-search', @@ -102,7 +100,7 @@ export const audibleOperations = (operations: Required) => [ summary: 'Search and rank Audible identity candidates across reviewed regions.', usage: 'audible-search --title TITLE --report FILE [--author AUTHOR] [--narrator NARRATOR] [--duration SECONDS] [--regions LIST]', }, - execute: operations.audibleSearch, + handler: operations.audibleSearch, id: 'audible-search', inputSchema: z.object({ attempts: z.number().int().min(1).max(10).optional(), author: z.string().min(1).max(512).optional(), @@ -110,11 +108,9 @@ export const audibleOperations = (operations: Required) => [ narrator: z.string().min(1).max(512).optional(), regions: z.array(audibleRegionSchema).min(1).max(10).optional(), report: pathSchema.optional(), title: z.string().min(1).max(1024), }).strict(), - mcp: { description: 'Search Audible regions and return ranked identity evidence requiring human review.', name: 'search_audible', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , resultSchema: audibleSearchResultSchema, }), - defineOperation({ + audibleSelect: defineCliCommand({ cli: { name: 'audible-select', parse: (args) => { @@ -131,14 +127,12 @@ export const audibleOperations = (operations: Required) => [ summary: 'Record one explicit human-reviewed Audible edition choice.', usage: 'audible-select --candidates FILE --candidate N --receipt FILE [--note NOTE]', }, - execute: operations.audibleSelect, + handler: operations.audibleSelect, id: 'audible-select', inputSchema: z.object({ candidate: z.number().int().min(1).max(500), candidates: pathSchema, note: z.string().max(4096).optional(), receipt: pathSchema.optional() }).strict(), - mcp: { description: 'Record an explicit human-reviewed Audible edition choice from a candidate report.', name: 'select_audible_edition', readOnly: false, server: 'curator' }, - render: (receipt) => , resultSchema: audibleSelectResultSchema, }), - defineOperation({ + audibleCache: defineCliCommand({ cli: { name: 'audible-cache', parse: (args) => { @@ -156,14 +150,12 @@ export const audibleOperations = (operations: Required) => [ summary: 'Cache one reviewed Audible product, chapters, artwork, and source URLs.', usage: 'audible-cache --asin ASIN --region REGION --cache-dir DIR --receipt FILE', }, - execute: operations.audibleCache, + handler: operations.audibleCache, id: 'audible-cache', inputSchema: z.object({ asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), cacheDirectory: pathSchema, receipt: pathSchema.optional(), region: audibleRegionSchema.optional(), }).strict(), - mcp: { description: 'Cache a reviewed Audible edition and retained source evidence.', name: 'cache_audible_edition', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , resultSchema: audibleCacheResultSchema, }), -]; +}); diff --git a/examples/audiobook-curator/src/operations/discovery.tsx b/examples/audiobook-curator/src/operations/discovery.ts similarity index 79% rename from examples/audiobook-curator/src/operations/discovery.tsx rename to examples/audiobook-curator/src/operations/discovery.ts index 82cf0c0b3..0b267480d 100644 --- a/examples/audiobook-curator/src/operations/discovery.tsx +++ b/examples/audiobook-curator/src/operations/discovery.ts @@ -3,8 +3,7 @@ * `library-audit`, and `select`, backed by `../curator-core.ts` and * `../library.ts`. All four retain evidence and never mutate media. */ -import { defineOperation, type RscOperationContext } from '@agent-bundle/runtime/plugin'; -import React from 'react'; +import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { inspectSources, type InspectionReceipt } from '../curator-core.ts'; @@ -17,7 +16,6 @@ import { type LibraryAuditReceipt, type SelectionReceipt, } from '../library.ts'; -import { CuratorResult } from '../result.tsx'; import { assertOptions, numberOption, @@ -31,19 +29,19 @@ import { parityReceiptSchema, pathSchema, probeShape } from './schemas.ts'; export interface DiscoveryOperations { readonly inspect: ( input: { readonly maxFiles?: number; readonly root: string }, - options: RscOperationContext, + options: CliCommandContext, ) => Promise; readonly inventory?: ( input: { readonly report?: string; readonly source: string; readonly strict?: boolean }, - options: RscOperationContext, + options: CliCommandContext, ) => Promise; readonly libraryAudit?: ( input: { readonly concurrency?: number; readonly report?: string; readonly sources: readonly string[]; readonly strict?: boolean }, - options: RscOperationContext, + options: CliCommandContext, ) => Promise; readonly select?: ( input: { readonly inventory: string; readonly report?: string }, - options: RscOperationContext, + options: CliCommandContext, ) => Promise; } @@ -87,8 +85,8 @@ export const defaultDiscoveryOperations: Required = { }, }; -export const discoveryOperations = (operations: Required) => [ - defineOperation({ +export const discoveryOperations = (operations: Required) => ({ + inspect: defineCliCommand({ cli: { name: 'inspect', parse: (args) => { @@ -102,19 +100,12 @@ export const discoveryOperations = (operations: Required) = summary: 'Inspect a bounded audiobook source tree without changing it.', usage: 'inspect [--max-files N] ', }, - execute: operations.inspect, + handler: operations.inspect, id: 'inspect', inputSchema: inspectInputSchema, - mcp: { - description: 'Inspect a bounded directory tree and report supported audiobook media without changing it.', - name: 'inspect_sources', - readOnly: true, - server: 'curator', - }, - render: (receipt) => , resultSchema: inspectResultSchema, }), - defineOperation({ + inventory: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'inventory', @@ -130,19 +121,12 @@ export const discoveryOperations = (operations: Required) = summary: 'Probe source audio without changing it.', usage: 'inventory --report FILE [--strict]', }, - execute: operations.inventory, + handler: operations.inventory, id: 'inventory', inputSchema: z.object({ report: pathSchema.optional(), source: pathSchema, strict: z.boolean().optional() }).strict(), - mcp: { - description: 'Inventory source audio with retained per-file probe evidence.', - name: 'inventory_sources', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , resultSchema: inventoryResultSchema, }), - defineOperation({ + libraryAudit: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'library-audit', @@ -161,7 +145,7 @@ export const discoveryOperations = (operations: Required) = summary: 'Audit metadata, artwork, chapters, duplicate candidates, and multipart groups.', usage: 'library-audit --report FILE [--concurrency N] [--strict]', }, - execute: operations.libraryAudit, + handler: operations.libraryAudit, id: 'library-audit', inputSchema: z.object({ concurrency: z.number().int().min(1).max(8).optional(), @@ -169,16 +153,9 @@ export const discoveryOperations = (operations: Required) = sources: z.array(pathSchema).min(1).max(64), strict: z.boolean().optional(), }).strict(), - mcp: { - description: 'Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice.', - name: 'audit_library', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , resultSchema: libraryResultSchema, }), - defineOperation({ + select: defineCliCommand({ cli: { name: 'select', parse: (args) => { @@ -193,16 +170,9 @@ export const discoveryOperations = (operations: Required) = summary: 'Choose the strongest source among normalized collisions.', usage: 'select --inventory FILE --report FILE', }, - execute: operations.select, + handler: operations.select, id: 'select', inputSchema: z.object({ inventory: pathSchema, report: pathSchema.optional() }).strict(), - mcp: { - description: 'Select strongest source encodings while retaining alternates and duration review evidence.', - name: 'select_sources', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , resultSchema: selectionResultSchema, }), -]; +}); diff --git a/examples/audiobook-curator/src/operations/evidence.tsx b/examples/audiobook-curator/src/operations/evidence.ts similarity index 85% rename from examples/audiobook-curator/src/operations/evidence.tsx rename to examples/audiobook-curator/src/operations/evidence.ts index 9d0d76cfb..f185e389f 100644 --- a/examples/audiobook-curator/src/operations/evidence.tsx +++ b/examples/audiobook-curator/src/operations/evidence.ts @@ -2,8 +2,7 @@ * Acoustic and transcript identity-evidence operations: `acoustic-verify`, * `acoustic-identify`, and `whisper-verify`, backed by `../evidence.ts`. */ -import { defineOperation, type RscOperationContext } from '@agent-bundle/runtime/plugin'; -import React from 'react'; +import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { @@ -17,7 +16,6 @@ import { type WhisperReceipt, } from '../evidence.ts'; import { readJson } from '../foundation.ts'; -import { CuratorResult } from '../result.tsx'; import { assertOptions, numberOption, @@ -32,10 +30,10 @@ import { audibleRegions, audibleRegionSchema, parityReceiptSchema, pathSchema } export interface EvidenceOperations { readonly acousticIdentify?: ( input: { readonly all?: boolean; readonly attempts?: number; readonly candidates: string; readonly chunkSeconds?: number; readonly file: string; readonly receipt?: string; readonly top?: number; readonly verbose?: boolean }, - options: RscOperationContext, + options: CliCommandContext, ) => Promise; - readonly acousticVerify?: (input: AcousticVerifyInput, options: RscOperationContext) => Promise; - readonly whisperVerify?: (input: WhisperInput, options: RscOperationContext) => Promise; + readonly acousticVerify?: (input: AcousticVerifyInput, options: CliCommandContext) => Promise; + readonly whisperVerify?: (input: WhisperInput, options: CliCommandContext) => Promise; } export const defaultEvidenceOperations: Required = { @@ -56,8 +54,8 @@ const acousticResultSchema = parityReceiptSchema('audiolocate') const acousticIdentifyResultSchema = parityReceiptSchema('acoustic-identify'); const whisperResultSchema = parityReceiptSchema('whisper-identity'); -export const evidenceOperations = (operations: Required) => [ - defineOperation({ +export const evidenceOperations = (operations: Required) => ({ + acousticVerify: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'acoustic-verify', @@ -80,18 +78,16 @@ export const evidenceOperations = (operations: Required) => summary: 'Compare one bounded Audible sample with local audio through optional Audiolocate.', usage: 'acoustic-verify --file FILE --asin ASIN --region REGION --receipt FILE [--audiolocate-python PATH]', }, - execute: operations.acousticVerify, + handler: operations.acousticVerify, id: 'acoustic-verify', inputSchema: z.object({ asin: z.string().min(1).max(64), attempts: z.number().int().min(1).max(10).optional(), audiolocatePython: pathSchema.optional(), chunkSeconds: z.number().int().min(1).max(86_400).optional(), file: pathSchema, receipt: pathSchema.optional(), region: audibleRegionSchema.optional(), sampleUrl: z.url().optional(), verbose: z.boolean().optional(), }).strict(), - mcp: { description: 'Compare a bounded Audible sample with local audio through an optional Audiolocate Python capability.', name: 'verify_audible_sample', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , resultSchema: acousticResultSchema, }), - defineOperation({ + acousticIdentify: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'acoustic-identify', @@ -113,18 +109,16 @@ export const evidenceOperations = (operations: Required) => summary: 'Try score-ranked, deduplicated Audible candidates and retain per-candidate evidence.', usage: 'acoustic-identify --file FILE --candidates FILE --receipt FILE [--top N] [--all]', }, - execute: operations.acousticIdentify, + handler: operations.acousticIdentify, id: 'acoustic-identify', inputSchema: z.object({ all: z.boolean().optional(), attempts: z.number().int().min(1).max(10).optional(), candidates: pathSchema, chunkSeconds: z.number().int().min(1).max(86_400).optional(), file: pathSchema, receipt: pathSchema.optional(), top: z.number().int().min(1).max(10).optional(), verbose: z.boolean().optional(), }).strict(), - mcp: { description: 'Try ranked Audible candidates, retaining skips/errors and stopping at the first acoustic match by default.', name: 'identify_audible_sample', openWorld: true, readOnly: false, server: 'curator' }, - render: (receipt) => , resultSchema: acousticIdentifyResultSchema, }), - defineOperation({ + whisperVerify: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'whisper-verify', @@ -149,7 +143,7 @@ export const evidenceOperations = (operations: Required) => summary: 'Transcribe distributed audiobook windows for human language and identity review.', usage: 'whisper-verify --file FILE --model FILE --receipt FILE [--language CODE] [--max-windows N]', }, - execute: operations.whisperVerify, + handler: operations.whisperVerify, id: 'whisper-verify', inputSchema: z.object({ author: z.string().max(512).optional(), file: pathSchema, language: z.string().min(1).max(64).optional(), @@ -157,8 +151,6 @@ export const evidenceOperations = (operations: Required) => model: pathSchema, receipt: pathSchema.optional(), threads: z.number().int().min(1).max(256).optional(), title: z.string().max(1024).optional(), whisperCli: pathSchema.optional(), windowSeconds: z.number().int().min(1).max(3600).optional(), }).strict(), - mcp: { description: 'Extract and transcribe distributed PCM windows for human language, story, and narrator review.', name: 'verify_with_whisper', readOnly: false, server: 'curator' }, - render: (receipt) => , resultSchema: whisperResultSchema, }), -]; +}); diff --git a/examples/audiobook-curator/src/operations/media-mutation.tsx b/examples/audiobook-curator/src/operations/media-mutation.ts similarity index 78% rename from examples/audiobook-curator/src/operations/media-mutation.tsx rename to examples/audiobook-curator/src/operations/media-mutation.ts index 1df7510db..de70604cf 100644 --- a/examples/audiobook-curator/src/operations/media-mutation.tsx +++ b/examples/audiobook-curator/src/operations/media-mutation.ts @@ -2,8 +2,7 @@ * Plan-first derived-media repair operations: `apply-metadata` and * `apply-chapters`, backed by `../media-mutation.ts`. */ -import { defineOperation, type RscOperationContext } from '@agent-bundle/runtime/plugin'; -import React from 'react'; +import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { @@ -14,7 +13,6 @@ import { type MetadataInput, type MetadataReceipt, } from '../media-mutation.ts'; -import { CuratorResult } from '../result.tsx'; import { assertOptions, optionValue, @@ -25,8 +23,8 @@ import { import { parityReceiptSchema, pathSchema } from './schemas.ts'; export interface MediaMutationOperations { - readonly applyChapters?: (input: ChapterInput, options: RscOperationContext) => Promise; - readonly applyMetadata?: (input: MetadataInput, options: RscOperationContext) => Promise; + readonly applyChapters?: (input: ChapterInput, options: CliCommandContext) => Promise; + readonly applyMetadata?: (input: MetadataInput, options: CliCommandContext) => Promise; } export const defaultMediaMutationOperations: Required = { @@ -37,8 +35,8 @@ export const defaultMediaMutationOperations: Required = const metadataResultSchema = parityReceiptSchema('apply-metadata'); const chaptersResultSchema = parityReceiptSchema('apply-chapters'); -export const mediaMutationOperations = (operations: Required) => [ - defineOperation({ +export const mediaMutationOperations = (operations: Required) => ({ + applyMetadata: defineCliCommand({ cli: { name: 'apply-metadata', parse: (args) => { @@ -61,18 +59,16 @@ export const mediaMutationOperations = (operations: Required , resultSchema: metadataResultSchema, }), - defineOperation({ + applyChapters: defineCliCommand({ cli: { name: 'apply-chapters', parse: (args) => { @@ -89,11 +85,9 @@ export const mediaMutationOperations = (operations: Required , resultSchema: chaptersResultSchema, }), -]; +}); diff --git a/examples/audiobook-curator/src/operations/output.tsx b/examples/audiobook-curator/src/operations/output.ts similarity index 81% rename from examples/audiobook-curator/src/operations/output.tsx rename to examples/audiobook-curator/src/operations/output.ts index 5837ea7d1..39fe049df 100644 --- a/examples/audiobook-curator/src/operations/output.tsx +++ b/examples/audiobook-curator/src/operations/output.ts @@ -4,8 +4,7 @@ * `../integrity-audit.ts`. Conversion and preparation plan by default and * mutate only a derived destination; the audit never mutates. */ -import { defineOperation, type RscOperationContext } from '@agent-bundle/runtime/plugin'; -import React from 'react'; +import { defineCliCommand, type CliCommandContext } from '../cli-command.js'; import { z } from 'zod'; import { convertAudiobook, type ConvertInput, type ConvertReceipt } from '../conversion.ts'; @@ -15,7 +14,6 @@ import { type IntegrityAuditInput, type IntegrityAuditReceipt, } from '../integrity-audit.ts'; -import { CuratorResult } from '../result.tsx'; import { assertOptions, numberOption, @@ -29,9 +27,9 @@ import { import { parityReceiptSchema, pathSchema, probeSchema } from './schemas.ts'; export interface OutputOperations { - readonly audit: (input: IntegrityAuditInput, options: RscOperationContext) => Promise; - readonly convert?: (input: ConvertInput, options: RscOperationContext) => Promise; - readonly prepare: (input: PrepareInput, options: RscOperationContext) => Promise; + readonly audit: (input: IntegrityAuditInput, options: CliCommandContext) => Promise; + readonly convert?: (input: ConvertInput, options: CliCommandContext) => Promise; + readonly prepare: (input: PrepareInput, options: CliCommandContext) => Promise; } export const defaultOutputOperations: Required = { @@ -56,8 +54,8 @@ const prepareResultSchema = z.object({ source: pathSchema, }).strict(); -export const outputOperations = (operations: Required) => [ - defineOperation({ +export const outputOperations = (operations: Required) => ({ + convert: defineCliCommand({ cli: { name: 'convert', parse: (args) => { @@ -90,7 +88,7 @@ export const outputOperations = (operations: Required) => [ summary: 'Plan or apply a verified conversion to one chaptered M4B.', usage: 'convert --selection FILE --output PATH --receipt FILE --title TITLE --author AUTHOR [--apply] [--overwrite]', }, - execute: operations.convert, + handler: operations.convert, id: 'convert', inputSchema: z.object({ apply: z.boolean().optional(), artwork: pathSchema.optional(), audioBitrate: z.string().min(2).max(32).optional(), @@ -100,17 +98,9 @@ export const outputOperations = (operations: Required) => [ narrator: z.string().min(1).max(512).optional(), output: pathSchema, overwrite: z.boolean().optional(), receipt: pathSchema.optional(), selection: pathSchema, title: z.string().min(1).max(1024), year: z.string().min(1).max(64).optional(), }).strict(), - mcp: { - description: 'Plan or explicitly apply a verified FFmpeg or Audiobook Forge conversion while preserving sources.', - destructive: true, - name: 'convert_audiobook', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , resultSchema: convertResultSchema, }), - defineOperation({ + prepare: defineCliCommand({ cli: { name: 'prepare', parse: (args) => { @@ -128,20 +118,12 @@ export const outputOperations = (operations: Required) => [ summary: 'Plan an M4B output or apply the plan when explicitly requested.', usage: 'prepare [--apply] [--name FILE] --output DIR ', }, - execute: operations.prepare, + handler: operations.prepare, id: 'prepare', inputSchema: prepareInputSchema, - mcp: { - description: 'Plan an M4B output, or apply the plan only when apply is explicitly true.', - destructive: true, - name: 'prepare_audiobook', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , resultSchema: prepareResultSchema, }), - defineOperation({ + audit: defineCliCommand({ cli: { exitCode: (receipt) => receipt.exitCode, name: 'audit', @@ -159,16 +141,9 @@ export const outputOperations = (operations: Required) => [ summary: 'Validate metadata, chapters, source mapping, hashes, and optional complete decode.', usage: 'audit --file FILE --receipt FILE [--conversion-receipt FILE] [--full-decode]', }, - execute: operations.audit, + handler: operations.audit, id: 'audit', inputSchema: z.object({ conversionReceipt: pathSchema.optional(), file: pathSchema, fullDecode: z.boolean().optional(), receipt: pathSchema.optional() }).strict(), - mcp: { - description: 'Validate chapter structure, optional conversion mapping, file/audio hashes, probe facts, and optional full decode.', - name: 'audit_audiobook', - readOnly: false, - server: 'curator', - }, - render: (receipt) => , resultSchema: auditResultSchema, }), -]; +}); diff --git a/examples/audiobook-curator/src/result.tsx b/examples/audiobook-curator/src/result.tsx index 7ff92f47c..63f674916 100644 --- a/examples/audiobook-curator/src/result.tsx +++ b/examples/audiobook-curator/src/result.tsx @@ -1,12 +1,5 @@ -/** - * The one place runtime JSX lives: every operation's `render` wraps its - * receipt in ``, and the MCP projection lowers that element - * tree synchronously into a `CallToolResult` via `lowerMcpResult`. This is - * the MCP result DSL, not React Server Components — no renderer or Flight - * transport is involved, and the CLI projection never calls it (it prints - * the validated receipt as JSON instead). - */ -import { Mcp } from '@agent-bundle/runtime'; +/** Shared Agent Document rendering for filesystem tool routes. */ +import { Agent, type JsonValue } from '@agent-bundle/runtime'; import React from 'react'; import type { AudibleCacheReceipt, AudibleSearchReceipt, AudibleSelectionReceipt } from './audible.ts'; @@ -78,7 +71,7 @@ const summary = (receipt: CuratorReceipt): string => { }; export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) => ( - - {summary(receipt)} - + + {summary(receipt)} + ); diff --git a/examples/audiobook-curator/tests/application.test.ts b/examples/audiobook-curator/tests/application.test.ts index fceec4aa1..ae2e0a29c 100644 --- a/examples/audiobook-curator/tests/application.test.ts +++ b/examples/audiobook-curator/tests/application.test.ts @@ -1,159 +1,51 @@ -import { lowerMcpResult } from '@agent-bundle/runtime'; import { describe, expect, it } from '@rstest/core'; import maybeFactoryConfig from '../agent-bundle.config.ts'; -import { - createAudiobookCuratorApplication, - type AudiobookCuratorOperations, -} from '../src/application.js'; +import { compileRouteGraph } from 'agent-bundle/api'; import { runCli } from '../src/cli.js'; -// defineConfig also admits factories; this example's config is a static object. if (typeof maybeFactoryConfig === 'function') throw new Error('expected a static config object'); const config = maybeFactoryConfig; - -const operations = (): AudiobookCuratorOperations => ({ - audit: async (input) => ({ - audioSha256: 'b'.repeat(64), - bytes: 12, - chapterIssues: [], - chapters: [], - exitCode: 0, - file: input.file, - fullDecode: input.fullDecode === true ? 'verified' : 'not-requested', - generatedAt: '2026-08-26T00:00:00.000Z', - mutation: false, - operation: 'audit', - probe: { - bytes: 12, - chapters: 0, - codec: 'aac', - durationSeconds: 12, - extension: '.m4b', - path: input.file, - relativePath: 'book.m4b', - sampleRate: 44_100, - tags: {}, - }, - sha256: 'a'.repeat(64), - sourceChapterMapping: { issues: [], status: 'not-requested' }, - status: 'verified', - }), - convert: async (input) => ({ - apply: input.apply ?? false, - audioMode: 'AAC transcode', - embeddedMetadata: { album: input.title }, - engine: 'ffmpeg', - expectedChapterCount: 1, - expectedChapters: [], - expectedDurationSeconds: 1, - filenamePolicy: 'safe', - generatedAt: '2026-08-26T00:00:00.000Z', - inputs: ['/book.mp3'], - jobs: 1, - mutation: input.apply ?? false, - operation: 'convert', - output: input.output, - sourcesPreserved: true, - status: input.apply === true ? 'converted-verified' : 'planned', - }), - inspect: async (input) => ({ files: [], operation: 'inspect', root: input.root, totalBytes: 0 }), - prepare: async (input) => ({ - applied: input.apply ?? false, - operation: 'prepare', - output: `${input.outputRoot}/book.m4b`, - probe: { codec: 'mp3', durationSeconds: 12, format: 'mp3', tags: {} }, - source: input.source, - }), -}); - -describe('audiobook curator RSC application', () => { - it('declares structure in config and owns CLI commands and MCP tools in the application', () => { +const root = new URL('..', import.meta.url).pathname; + +const toolNames = [ + 'apply_audiobook_chapters', + 'apply_audiobook_metadata', + 'audit_audiobook', + 'audit_library', + 'cache_audible_edition', + 'convert_audiobook', + 'identify_audible_sample', + 'inspect_sources', + 'inventory_sources', + 'prepare_audiobook', + 'search_audible', + 'select_audible_edition', + 'select_sources', + 'verify_audible_sample', + 'verify_with_whisper', +]; + +describe('audiobook curator filesystem application', () => { + it('derives the complete MCP server from route modules and no server config', async () => { expect(config.targets).toEqual(['claude', 'codex']); + expect(config.mcp).toBeUndefined(); expect(Object.keys(config.scripts ?? {})).toEqual(['audiobook-curator']); - expect(Object.keys(config.mcp?.servers ?? {})).toEqual(['curator']); - // No skills entry: skills/curate-audiobooks/SKILL.md ships by convention. expect(config.skills).toBeUndefined(); - const application = createAudiobookCuratorApplication({ operations: operations() }); - expect(application.name).toBe('audiobook-curator'); - expect(application.operations.map((operation) => operation.cli?.name)).toEqual([ - 'acoustic-verify', - 'acoustic-identify', - 'whisper-verify', - 'apply-metadata', - 'apply-chapters', - 'audible-search', - 'audible-select', - 'audible-cache', - 'inspect', - 'inventory', - 'library-audit', - 'select', - 'convert', - 'prepare', - 'audit', - ]); - expect(application.operations.map((operation) => operation.mcp?.name)).toEqual([ - 'verify_audible_sample', - 'identify_audible_sample', - 'verify_with_whisper', - 'apply_audiobook_metadata', - 'apply_audiobook_chapters', - 'search_audible', - 'select_audible_edition', - 'cache_audible_edition', - 'inspect_sources', - 'inventory_sources', - 'audit_library', - 'select_sources', - 'convert_audiobook', - 'prepare_audiobook', - 'audit_audiobook', - ]); - }); - - it('shares typed execution and cancellation between adapters', async () => { - let signal: AbortSignal | undefined; - const fixture = operations(); - const controller = new AbortController(); - const application = createAudiobookCuratorApplication({ - operations: { - ...fixture, - prepare: async (input, options) => { - signal = options.signal; - return fixture.prepare(input, options); - }, - }, - }); - const prepare = application.operations.find((operation) => operation.id === 'prepare')!; - const result = await prepare.execute({ apply: true, outputRoot: '/curated', source: '/library/book.mp3' }, { - signal: controller.signal, - }); - - expect(result).toMatchObject({ applied: true, operation: 'prepare' }); - expect(signal).toBe(controller.signal); + const graph = await compileRouteGraph(root, config); + expect(graph.diagnostics).toEqual([]); + expect(graph.servers).toHaveLength(1); + expect(graph.servers[0]).toMatchObject({ id: 'mcp:curator', mode: 'generated', name: 'curator' }); + expect(graph.servers[0]!.routes.filter((route) => route.kind === 'tool').map((route) => route.id.slice(route.id.lastIndexOf('/') + 1))).toEqual(toolNames); + expect(graph.servers[0]!.routes.filter((route) => route.kind === 'resource').map((route) => route.id)).toEqual(['resource:curator/catalog']); + expect(graph.servers[0]!.routes.filter((route) => route.kind === 'prompt').map((route) => route.id)).toEqual(['prompt:curator/curate']); }); - it('renders text and detached structured receipts through the public RSC lowerer', async () => { - const application = createAudiobookCuratorApplication({ operations: operations() }); - const inspect = application.operations.find((operation) => operation.mcp?.name === 'inspect_sources')!; - const receipt = await inspect.execute({ root: '/library' }, { signal: new AbortController().signal }); - - expect(lowerMcpResult(inspect.render(receipt))).toEqual({ - content: [{ text: 'Inspected 0 audio files (0 bytes).', type: 'text' }], - structuredContent: { files: [], operation: 'inspect', root: '/library', totalBytes: 0 }, - }); - }); - - it('provides root and command help through the installed CLI adapter', async () => { + it('keeps the handwritten CLI compatibility path non-rendering through stage 3', async () => { const output: string[] = []; - await expect(runCli(['--help'], { operations: operations(), write: (value) => output.push(value) })).resolves.toBe(0); + await expect(runCli(['--help'], { write: (value) => output.push(value) })).resolves.toBe(0); expect(output.join('')).toContain('inspect [--max-files N] '); expect(output.join('')).toContain('prepare [--apply] [--name FILE] --output DIR '); - - output.length = 0; - await expect(runCli(['audit', '--help'], { operations: operations(), write: (value) => output.push(value) })).resolves.toBe(0); - expect(output.join('')).toContain('Validate metadata, chapters'); }); }); diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index baa7bafd7..ed8146ebb 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -1,9 +1,11 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { expect, it } from '@rstest/core'; import { build, inspect, invokeMcp, listHooks, listMcp, runEvals, simulateHook, validate } from '../src/api.ts'; @@ -281,3 +283,50 @@ it('derives the Audiobook Curator release identity from package.json as the one expect(projectVersionLabel(inspection.projectContext)).toBe('1.0.0'); expect(inspection.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); }); + + +it('serves the routed Audiobook Curator artifact through a real MCP client', { retry: 2, timeout: 60_000 }, async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'audiobook-routed-artifact-')); + const root = join(fixtureRoot, 'project'); + await cp(join(examplesRoot, 'audiobook-curator'), root, { + filter: (source) => !['.agent-bundle', 'artifact', 'dist', 'node_modules'].includes(source.slice(source.lastIndexOf('/') + 1)), + recursive: true, + }); + const fixtureTsconfig = await readFile(join(root, 'tsconfig.json'), 'utf8'); + await writeFile(join(root, 'tsconfig.json'), fixtureTsconfig.replace('../../tsconfig.json', join(process.cwd(), 'tsconfig.json'))); + await symlink(join(examplesRoot, 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + const output = join(root, 'artifact'); + let client: Client | undefined; + try { + const compiled = await build({ output, root, targets: ['claude'] }); + await rm(join(root, 'src'), { force: true, recursive: true }); + const server = compiled.model.mcpServers.find((candidate) => candidate.name === 'curator'); + expect(server?.generatedRoutes).toHaveLength(17); + const entry = join(output, 'claude', server!.args![0]!); + client = new Client({ name: 'audiobook-route-contract', version: '1.0.0' }); + await client.connect(new StdioClientTransport({ args: [entry], command: process.execPath, stderr: 'pipe' })); + + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain('inspect_sources'); + const inspectResult = await client.callTool({ arguments: { root }, name: 'inspect_sources' }); + expect(inspectResult).toMatchObject({ + content: [{ type: 'text' }], + structuredContent: { operation: 'inspect', root }, + }); + await expect(client.listResources()).resolves.toMatchObject({ + resources: [expect.objectContaining({ uri: 'audiobook-curator://catalog' })], + }); + await expect(client.readResource({ uri: 'audiobook-curator://catalog' })).resolves.toMatchObject({ + contents: [expect.objectContaining({ mimeType: 'application/json', uri: 'audiobook-curator://catalog' })], + }); + await expect(client.listPrompts()).resolves.toMatchObject({ + prompts: [expect.objectContaining({ name: 'curate' })], + }); + await expect(client.getPrompt({ arguments: { root }, name: 'curate' })).resolves.toMatchObject({ + messages: [{ content: { type: 'text' }, role: 'user' }], + }); + } finally { + await client?.close(); + await rm(fixtureRoot, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index 9099655a6..c4a4e7d54 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -21,7 +21,7 @@ export interface SharedPack { readonly tarball: string; } -export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle'; +export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle' | 'runtime'; /** * NODE_PATH-free environment with per-command npm cache and tmp roots under @@ -78,7 +78,7 @@ const packOnce = async (packageName: SharedPackPackage): Promise => rmSync(destination, { force: true, recursive: true }); }); const { stdout } = await execFile('npm', ['pack', '--json', '--pack-destination', destination], { - cwd: join(workspaceRoot, 'packages', packageName), + cwd: join(workspaceRoot, 'packages', packageName === 'runtime' ? 'rsc-runtime' : packageName), env: { ...installedEnvironment(), NODE_ENV: 'production' }, }); const [packOutput] = JSON.parse(stdout) as [SharedPackOutput]; diff --git a/packages/create-agent-bundle/src/framework.ts b/packages/create-agent-bundle/src/framework.ts index 981031cdd..d7ac8a471 100644 --- a/packages/create-agent-bundle/src/framework.ts +++ b/packages/create-agent-bundle/src/framework.ts @@ -9,6 +9,16 @@ export const previewPackageSpec = (packageName: PreviewPackageName, sha: string) export const previewFrameworkSpec = (sha: string): string => previewPackageSpec('agent-bundle', sha); +/** Derives the paired runtime package from the selected framework build. */ +export const runtimeSpecForFramework = (frameworkSpec: string): string => { + const preview = /^(https:\/\/pkg\.pr\.new\/ScriptedAlchemy\/agent-bundle\/)agent-bundle@([0-9a-f]{7,40})$/u.exec(frameworkSpec); + if (preview !== null) return `${preview[1]}@agent-bundle/runtime@${preview[2]}`; + if (frameworkSpec.startsWith('file:')) { + return frameworkSpec.replace(/agent-bundle-([^/]+\.tgz)$/u, 'agent-bundle-runtime-$1'); + } + return frameworkSpec; +}; + /** * Resolve the dependency spec the scaffolded project pins `agent-bundle` to. * diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index ecd6d961e..dfdedffa7 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { UsageError, type TargetName } from './options.ts'; +import { runtimeSpecForFramework } from './framework.ts'; /** * The literal project name every template is written under. Templates stay @@ -60,7 +61,10 @@ const rewriteManifest = (contents: string, request: ScaffoldRequest): string => for (const section of [manifest.dependencies, manifest.devDependencies]) { if (section === undefined) continue; for (const [dependency, range] of Object.entries(section)) { - if (range === 'workspace:*') section[dependency] = request.frameworkSpec; + if (range !== 'workspace:*') continue; + section[dependency] = dependency === '@agent-bundle/runtime' + ? runtimeSpecForFramework(request.frameworkSpec) + : request.frameworkSpec; } } return `${JSON.stringify(manifest, null, 2)}\n`; diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md index 2fe6edb90..116f8508c 100644 --- a/packages/create-agent-bundle/templates/mcp-server/README.md +++ b/packages/create-agent-bundle/templates/mcp-server/README.md @@ -1,37 +1,25 @@ # my-agent-plugin -A stdio MCP server plugin built with [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). -The server entry is the convention `src/mcp/status.ts`: it default-exports a -server factory, and the build wraps it in the framework stdio lifecycle shell -(console-to-stderr guard, signal handling, stdin-EOF exit, heartbeat) — no -hand-rolled bootstrap. +A convention-driven MCP server built with [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). +The single route `src/mcp/status/tools/report-status.tsx` owns its schemas, +static protocol metadata, execution, and `Agent.*` rendering. Its path creates +the `status` server; no handwritten server factory or server config is needed. ## Commands ```sh -npm run dev # local workbench with live rebuilds -npm run build # write host artifacts to artifact/ -npm run check # validate + build + typecheck + test - -# after a build: run, list, or invoke the server from the artifact -npx agent-bundle mcp run --server status --target portable --artifact artifact +npm run dev +npm run build +npm run check npx agent-bundle mcp list --server status --target portable --artifact artifact ``` ## Layout -- `agent-bundle.config.ts` — declares the `status` server and one script. -- `src/mcp/status.ts` — the conventional stdio entry (a factory export is the - whole file). -- `src/scripts/check-status.ts` — an artifact script; its `main` export gets - the generated process envelope. -- `src/status.ts` — shared domain logic, covered by `tests/`. - -## The agent-bundle dependency +- `agent-bundle.config.ts` — plugin identity, targets, and project policy. +- `src/mcp/status/tools/report-status.tsx` — the complete MCP tool route. +- `src/scripts/check-status.ts` — an artifact script with a generated process envelope. +- `src/status.ts` — shared domain logic covered by `tests/`. -agent-bundle has no npm release yet; this project pins a -[pkg.pr.new](https://pkg.pr.new) preview tarball of it. To move to a newer -preview (or a real release once one exists), change the `agent-bundle` entry -in `devDependencies` — see -[Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md) -for the URL forms. +The scaffold pins matching `agent-bundle` and `@agent-bundle/runtime` builds. +For preview URL forms, see [Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md). diff --git a/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts b/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts index e57231df3..0bafe8bc3 100644 --- a/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts @@ -1,14 +1,6 @@ import { defineConfig } from 'agent-bundle'; export default defineConfig({ - mcp: { - servers: { - // No `entry` needed: the conventional stdio entry `src/mcp/status.ts` - // supplies it, and its default-exported factory runs under the - // framework lifecycle shell. - status: {}, - }, - }, plugin: { description: 'A stdio MCP server plugin scaffolded from the mcp-server template.', name: 'my-agent-plugin', diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json index f2bc4dabf..7fe54fe91 100644 --- a/packages/create-agent-bundle/templates/mcp-server/package_json +++ b/packages/create-agent-bundle/templates/mcp-server/package_json @@ -16,11 +16,15 @@ "validate": "agent-bundle validate --json" }, "devDependencies": { - "@modelcontextprotocol/server": "2.0.0", "@rstest/core": "0.11.10", "@types/node": "26.4.0", "agent-bundle": "workspace:*", "typescript": "7.0.2", + "@types/react": "19.2.18" + }, + "dependencies": { + "@agent-bundle/runtime": "workspace:*", + "react": "19.2.8", "zod": "4.4.3" } } diff --git a/packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts b/packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts deleted file mode 100644 index da2210d7f..000000000 --- a/packages/create-agent-bundle/templates/mcp-server/src/mcp/status.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/server'; -import { z } from 'zod'; - -import { reportStatus } from '../status.js'; - -export const createStatusServer = (): McpServer => { - const server = new McpServer({ name: 'my-agent-plugin', version: '0.1.0' }); - - server.registerTool('report-status', { - description: 'Report the readiness of one service.', - inputSchema: z.object({ service: z.string().min(1) }), - }, async ({ service }) => { - const report = reportStatus(service); - return { - content: [{ text: report.summary, type: 'text' }], - structuredContent: { ...report }, - }; - }); - - return server; -}; - -/** - * Default-exported server factory: `agent-bundle build` detects it and wraps - * this entry in the framework stdio lifecycle shell (console-to-stderr guard, - * SIGINT/SIGTERM handling, stdin-EOF exit, bounded shutdown, heartbeat). - */ -export default createStatusServer; diff --git a/packages/create-agent-bundle/templates/mcp-server/src/mcp/status/tools/report-status.tsx b/packages/create-agent-bundle/templates/mcp-server/src/mcp/status/tools/report-status.tsx new file mode 100644 index 000000000..5d3b3ef99 --- /dev/null +++ b/packages/create-agent-bundle/templates/mcp-server/src/mcp/status/tools/report-status.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; +import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +import { reportStatus } from '../../../status.js'; + +export const config = { + description: 'Report the readiness of one service.', + annotations: { readOnlyHint: true }, +} satisfies ToolConfig; +export const inputSchema = z.object({ service: z.string().min(1) }).strict(); +export const resultSchema = z.object({ + service: z.string(), + status: z.enum(['healthy', 'unknown']), + summary: z.string(), +}).strict(); + +export default async function ReportStatus({ input }: ToolRouteProps) { + const report = reportStatus(input.service); + return ( + + {report.summary} + + ); +} diff --git a/packages/create-agent-bundle/templates/mcp-server/tsconfig.json b/packages/create-agent-bundle/templates/mcp-server/tsconfig.json index d94375c56..5263248bf 100644 --- a/packages/create-agent-bundle/templates/mcp-server/tsconfig.json +++ b/packages/create-agent-bundle/templates/mcp-server/tsconfig.json @@ -11,11 +11,13 @@ "types": [ "node" ], - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "jsx": "react-jsx" }, "include": [ "agent-bundle.config.ts", "src/**/*.ts", - "tests/**/*.ts" + "tests/**/*.ts", + "src/**/*.tsx" ] } diff --git a/packages/create-agent-bundle/tests/framework.test.ts b/packages/create-agent-bundle/tests/framework.test.ts index c3d01660d..de4203348 100644 --- a/packages/create-agent-bundle/tests/framework.test.ts +++ b/packages/create-agent-bundle/tests/framework.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@rstest/core'; -import { previewPackageSpec, resolveFrameworkSpec } from '../src/framework.ts'; +import { previewPackageSpec, resolveFrameworkSpec, runtimeSpecForFramework } from '../src/framework.ts'; import { UsageError } from '../src/options.ts'; describe('previewPackageSpec', () => { @@ -29,3 +29,12 @@ describe('resolveFrameworkSpec', () => { expect(() => resolveFrameworkSpec('0.0.0', undefined)).toThrow('--framework-version'); }); }); + +describe('runtimeSpecForFramework', () => { + it('pairs preview and local tarball framework specs with the runtime package', () => { + expect(runtimeSpecForFramework('https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@da5df1d')) + .toBe('https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@da5df1d'); + expect(runtimeSpecForFramework('file:/tmp/agent-bundle-0.1.0.tgz')) + .toBe('file:/tmp/agent-bundle-runtime-0.1.0.tgz'); + }); +}); diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index bca0bb747..f7d9363fc 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -51,7 +51,7 @@ describe('scaffold', () => { 'README.md', 'agent-bundle.config.ts', 'package.json', - 'src/mcp/status.ts', + 'src/mcp/status/tools/report-status.tsx', 'src/scripts/check-status.ts', 'src/status.ts', 'tests/status.test.ts', @@ -93,11 +93,15 @@ describe('scaffold', () => { } const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { readonly bin: Record; + readonly dependencies?: Record; readonly devDependencies: Record; readonly name: string; }; expect(manifest.name).toBe('@scope/status-plugin'); expect(manifest.devDependencies['agent-bundle']).toBe('file:/tmp/agent-bundle-0.0.0.tgz'); + if (files.includes('src/mcp/status/tools/report-status.tsx')) { + expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe('file:/tmp/agent-bundle-runtime-0.0.0.tgz'); + } expect(manifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); expect(config).toContain("name: 'status-plugin'"); diff --git a/packages/create-agent-bundle/tests/support/scaffold-fixture.ts b/packages/create-agent-bundle/tests/support/scaffold-fixture.ts index 0ebcce5a8..ca04c5cf3 100644 --- a/packages/create-agent-bundle/tests/support/scaffold-fixture.ts +++ b/packages/create-agent-bundle/tests/support/scaffold-fixture.ts @@ -1,7 +1,7 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { expect } from '@rstest/core'; @@ -28,10 +28,16 @@ interface PackedFixture { */ const packFixture = async (): Promise => { const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-e2e-')); - const [{ tarball: frameworkTarball }, { tarball: scaffolderTarball }] = await Promise.all([ + const [{ tarball: frameworkTarball }, { tarball: scaffolderTarball }, { tarball: runtimeTarball }] = await Promise.all([ sharedPackedTarball('agent-bundle'), sharedPackedTarball('create-agent-bundle'), + sharedPackedTarball('runtime'), ]); + const pairedRuntimeTarball = join( + dirname(frameworkTarball), + basename(frameworkTarball).replace(/^agent-bundle-/u, 'agent-bundle-runtime-'), + ); + await copyFile(runtimeTarball, pairedRuntimeTarball); const runnerRoot = join(root, 'runner'); await mkdir(runnerRoot, { recursive: true }); diff --git a/scripts/run-packed-tests.mjs b/scripts/run-packed-tests.mjs index 78a0e05b3..8648ac8ed 100644 --- a/scripts/run-packed-tests.mjs +++ b/scripts/run-packed-tests.mjs @@ -38,9 +38,13 @@ if (buildExitCode !== 0) process.exit(buildExitCode); const packDirectory = await mkdtemp(join(tmpdir(), 'agent-bundle-shared-pack-')); try { - await Promise.all(['agent-bundle', 'create-agent-bundle'].map(async (packageName) => { + await Promise.all([ + ['agent-bundle', 'agent-bundle'], + ['create-agent-bundle', 'create-agent-bundle'], + ['runtime', 'rsc-runtime'], + ].map(async ([packageName, directory]) => { const { stdout } = await execFile('npm', ['pack', '--json', '--pack-destination', packDirectory], { - cwd: join(repositoryRoot, 'packages', packageName), + cwd: join(repositoryRoot, 'packages', directory), env: { ...environment, NODE_ENV: 'production' }, }); const [packOutput] = JSON.parse(stdout);