diff --git a/.changeset/honest-packed-entry-fixes.md b/.changeset/honest-packed-entry-fixes.md new file mode 100644 index 000000000..679289562 --- /dev/null +++ b/.changeset/honest-packed-entry-fixes.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Preserve provider and exit-code semantics across generated CLI workers, make bare prepack output non-overlapping, and harden packed installer and README validation. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index b115b1dc9..d53a037b2 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -24,8 +24,9 @@ node-consumable package build under `dist/` — the outputs `package.json` - When package outputs and at least one Claude, Codex, or Cursor host pack are built inside the project, the framework also emits one self-contained package-relative installer. It is `dist/bin/.js` when that name - is free, otherwise `dist/bin/-install.js`. Declare the matching - `package.json` `bin` value. Its grammar is + is free, otherwise `dist/bin/-install.js`; if both are occupied, + a numeric suffix (`-install-2`, `-install-3`, …) guarantees a free name. + Declare the matching `package.json` `bin` value. Its grammar is `install [--scope ] [--json]`; help lists only built hosts. The baked URL resolves the shipped artifact directory from `import.meta.url`, never the caller's working directory, and delegates to the same @@ -33,9 +34,11 @@ node-consumable package build under `dist/` — the outputs `package.json` - `agent-bundle prepack [--root ] [--output ] [--json]` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the exact package/artifact inventory, manifest hashes, package bin - targets, and release-version agreement. Use it as an npm `prepack` script; - `--ignore-scripts` prevents recursion and npm install never runs the host - installer. + targets, and release-version agreement. With no `--output`, prepack uses + configured `output.distPath` when present and otherwise writes artifacts to + `artifact/`, leaving the package build in `dist/`. Use it as an npm `prepack` + script; `--ignore-scripts` prevents recursion and npm install never runs the + host installer. - The package build runs for `agent-bundle build` (CLI, or `build({ packageOutputs: true })` through the API) and inside the `agent-bundle dev` rebuild loop (see “Dev-watch of the package build” @@ -78,7 +81,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | -| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes. | Prefix the file with `_` | +| `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. @@ -335,6 +338,11 @@ present. Help and `agent-bundle inspect --routes` expose the source server, tool, and confirmation policy, and collisions with custom command paths, groups, or aliases fail with `AB4813`. +A tool whose validated result carries an integer `exitCode` can declare +`config.exitCode: 'result'`; projection preserves that policy so represented +domain failures exit nonzero. Other projected tools retain the success-status +policy and exit zero only for a successful rendered document. + This is an in-house projection over the compiled route graph, per gate G7; it does not depend on MCPorter or introduce a second command model. MCPorter can still be pointed independently at the generated MCP server when a live-server diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx index c34856f28..8240f319c 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_audiobook.tsx @@ -13,6 +13,7 @@ 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.', + exitCode: 'result', }; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; diff --git a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx index 861eec11e..abefc5250 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/audit_library.tsx @@ -15,6 +15,7 @@ const operation = discoveryOperations(defaultDiscoveryOperations).libraryAudit; export const config = { annotations: { readOnlyHint: false }, description: 'Audit audiobook library metadata, duplicates, and multipart evidence without deletion advice.', + exitCode: 'result', }; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; 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 index f11911ce3..8d9839117 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/identify_audible_sample.tsx @@ -12,6 +12,7 @@ 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.', + exitCode: 'result', }; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx index 941bac277..0c141eb2c 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -13,6 +13,7 @@ const operation = discoveryOperations(defaultDiscoveryOperations).inventory; export const config = { annotations: { readOnlyHint: false }, description: 'Inventory source audio with retained per-file probe evidence.', + exitCode: 'result', }; export const inputSchema = z.object({ source: z.string().min(1).max(4096).describe('Source audio path to inventory.'), diff --git a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx index c14ab086e..75cfdbba8 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/search_audible.tsx @@ -12,6 +12,7 @@ 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.', + exitCode: 'result', }; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; 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 index 63e301ff0..c318708d5 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_audible_sample.tsx @@ -11,6 +11,7 @@ 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.', + exitCode: 'result', }; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; 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 index 8d2a91912..1e6564b4a 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/verify_with_whisper.tsx @@ -11,6 +11,7 @@ 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.', + exitCode: 'result', }; export const inputSchema = operation.inputSchema; export const resultSchema = operation.resultSchema; diff --git a/examples/audiobook-curator/tests/cli.test.ts b/examples/audiobook-curator/tests/cli.test.ts index 358374d7c..e92ae2159 100644 --- a/examples/audiobook-curator/tests/cli.test.ts +++ b/examples/audiobook-curator/tests/cli.test.ts @@ -56,6 +56,18 @@ describe('audiobook-curator routed CLI', () => { expect(projectedCommands).toHaveLength(16); expect(projectedCommands.every((command) => command.path[0] === 'curator' && command.rendered)).toBe(true); + expect(projectedCommands + .filter((command) => command.exitCode === 'result') + .map((command) => command.path.join(' ')) + .sort()).toEqual([ + 'curator audit_audiobook', + 'curator audit_library', + 'curator identify_audible_sample', + 'curator inventory_sources', + 'curator search_audible', + 'curator verify_audible_sample', + 'curator verify_with_whisper', + ]); // inspect [--max-files N] const inspect = byName.get('inspect')!; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 14193f021..335b273d8 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -90,6 +90,7 @@ export { serializeArtifactManifest, } from './build/manifest.ts'; import { composeBundlerInspection, type BundlerInspection } from './build/inspect-bundler.ts'; +import { defaultPackageArtifactDistPath } from './config/normalize.ts'; export type { BundlerInspection, BundlerInspectionEntry } from './build/inspect-bundler.ts'; import { validateArtifact, validateArtifactWithSnapshot } from './build/validate-artifact.ts'; import { freezeDiagnostics, hasErrors, DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; @@ -701,6 +702,7 @@ export const build = async (options: BuildOptions): Promise : [resolveOutput(root, options.output)]; const prepared = await new ProjectService({ ...options, + ...(options.packageOutputs === true ? { artifactDistPathDefault: defaultPackageArtifactDistPath } : {}), ...(outputRoots === undefined ? {} : { outputRoots }), root, }).prepare('build'); diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 7b20045b9..dd970006f 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -371,6 +371,7 @@ export const build = async (options: BuildOptions): Promise => { cwd: options.projectRoot, meta, outDir: target.root, + providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), ...tools, }, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 40bbfa8a1..cfb3626e6 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -160,6 +160,7 @@ export const compileEntries = async ( readonly cwd: string; readonly meta: AgentBundleMeta; readonly outDir: string; + readonly providers?: readonly CompiledProvider[]; readonly state?: NormalizedStateDefinition; readonly tools?: AgentBundleToolsConfig; }, @@ -174,6 +175,10 @@ export const compileEntries = async ( entries: await Promise.all(bundled.flatMap((entry) => { const { name, rendered, source, sourceInputs } = entry; if (rendered !== undefined) { + const workerSourceInputs = Object.freeze([...new Set([ + ...sourceInputs, + ...(options.providers ?? []).map((provider) => provider.source), + ])]); // A rendered script route (#102 stage 3): the entry projects the // dispatcher's render-event stream onto the CLI output contract and // a sibling react-server worker executes the component. @@ -198,8 +203,9 @@ export const compileEntries = async ( reactServer: true as const, rscManifest: true as const, source, - sourceInputs, + sourceInputs: workerSourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + ...(options.providers === undefined ? {} : { providers: options.providers }), routes: [{ config: emptyRouteConfig, id: rendered.routeId, diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index b71d2fb5d..10e923a14 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -117,11 +117,10 @@ export const generatedInstallBinEntrySource = (options: { readonly hosts: readonly ('claude' | 'codex' | 'cursor')[]; readonly name: string; }): string => [ - "import { fileURLToPath } from 'node:url';", `import { runGeneratedInstallProcess } from ${JSON.stringify(installEntryRuntimeSpecifier)};`, '', 'process.exitCode = await runGeneratedInstallProcess(process.argv.slice(2), Object.freeze({', - ` artifactRoot: fileURLToPath(new URL(${JSON.stringify(options.artifactRelativeUrl)}, import.meta.url)),`, + ` artifactRoot: new URL(${JSON.stringify(options.artifactRelativeUrl)}, import.meta.url),`, ` hosts: Object.freeze(${stableJson(options.hosts)}),`, ` name: ${JSON.stringify(options.name)},`, '}));', @@ -350,6 +349,7 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) }; export interface GeneratedRenderedRouteWorkerOptions { + readonly providers?: readonly CompiledProvider[]; readonly routes: readonly CompiledAgentRoute[]; readonly state?: NormalizedStateDefinition; } @@ -362,78 +362,109 @@ export interface GeneratedRenderedRouteWorkerOptions { */ export const generatedRenderedRouteWorkerSource = ( options: GeneratedRenderedRouteWorkerOptions, -): string => [ - "import { parentPort } from 'node:worker_threads';", - "import { createElement } from 'react';", - "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", - "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", - ...generatedStateImports(options.state, 'cwd'), - ...routeImports(options.routes), - '', - ...generatedStateOwner(options.state, 'cwd'), - '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', - 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', - "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", - '// Machine output owns the parent stdout; anything a route logs goes to stderr.', - 'process.stdout.write = process.stderr.write.bind(process.stderr);', - 'const routes = Object.freeze({', - ...options.routes.map((route, index) => - ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), - '});', - 'const requests = new Map();', - '', - 'const render = async (message) => {', - ' const route = routes[message.routeId];', - " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated rendered route must default-export an async function component.');", - ' const controller = new AbortController();', - ' requests.set(message.id, controller);', - ' try {', - ' const cwd = process.cwd();', - ...(options.state === undefined - ? [] - : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']), - ...(options.state === undefined ? [] : [' try {']), - ' await runAgentRequest({', - ' capabilities: {', - ' command: unavailable(),', - ' filesystem: unavailable(),', - ' network: unavailable(),', - " projectRoot: available({ root: cwd }, 'derived'),", - ' },', - " host: unavailable('unsupported-surface'),", - ' invocation: message.request,', - ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), - " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", - ' signal: controller.signal,', - ...(options.state === undefined ? [] : [' state: bindings.state,']), - " workspace: available({ root: cwd }, 'derived'),", - ' }, async () => {', - ' const flight = renderAgentFlight(createElement(route.module.default, { ...message.props, signal: controller.signal }), { signal: controller.signal });', - ' const reader = flight.getReader();', - ' while (true) {', - ' const next = await reader.read();', - ' if (next.done) break;', - ' const bytes = next.value;', - " parentPort.postMessage({ bytes, id: message.id, type: 'chunk' }, [bytes.buffer]);", - ' }', - ' });', - ...(options.state === undefined - ? [] - : [' } finally {', ' await bindings.close();', ' }']), - " parentPort.postMessage({ id: message.id, type: 'end' });", - ' } catch (error) {', - " parentPort.postMessage({ id: message.id, message: error instanceof Error ? error.message : String(error), type: 'error' });", - ' } finally {', - ' requests.delete(message.id);', - ' }', - '};', - '', - "parentPort.on('message', (message) => {", - " if (message.type === 'cancel') { requests.get(message.id)?.abort(); return; }", - " if (message.type === 'render') void render(message);", - '});', - '', -].join('\n'); +): string => { + const providers = orderedProviders(options.providers ?? []); + return [ + "import { parentPort } from 'node:worker_threads';", + "import { createElement } from 'react';", + "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", + "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';", + ...generatedStateImports(options.state, 'cwd'), + ...routeImports(options.routes), + ...providerImports(providers), + '', + ...generatedStateOwner(options.state, 'cwd'), + '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', + 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', + "if (parentPort === null) throw new Error('Generated render worker requires a parent port.');", + '// Machine output owns the parent stdout; anything a route logs goes to stderr.', + 'process.stdout.write = process.stderr.write.bind(process.stderr);', + 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', + ...(providers.length === 0 + ? [] + : [ + 'const providers = Object.freeze([', + ...providerRecords(providers), + ']);', + ]), + 'const routes = Object.freeze({', + ...options.routes.map((route, index) => + ` ${JSON.stringify(route.id)}: Object.freeze({ module: route${String(index)} }),`), + '});', + 'const requests = new Map();', + '', + 'const render = async (message) => {', + ' const route = routes[message.routeId];', + " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated rendered route must default-export an async function component.');", + ' const controller = new AbortController();', + ' requests.set(message.id, controller);', + ' processLifetime.hits += 1;', + ' try {', + ' const cwd = process.cwd();', + ...(options.state === undefined + ? [] + : [' const bindings = await runtimeState.requestBindings({ signal: controller.signal });']), + ...(options.state === undefined ? [] : [' try {']), + ...(providers.length === 0 + ? [] + : [ + ' const providerValues = { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } };', + ' for (const provider of providers) {', + ' if (typeof provider.module.default !== \'function\') {', + ' throw new TypeError(`Context provider "${provider.key}" (${provider.source}) must default-export a factory.`);', + ' }', + ' try {', + ' providerValues[provider.key] = await provider.module.default({ invocation: message.invocation, signal: controller.signal });', + ' } catch (error) {', + ' throw new Error(`Context provider "${provider.key}" (${provider.source}) failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });', + ' }', + ' }', + ]), + ' await runAgentRequest({', + ' capabilities: {', + ' command: unavailable(),', + ' filesystem: unavailable(),', + ' network: unavailable(),', + " projectRoot: available({ root: cwd }, 'derived'),", + ' },', + " host: unavailable('unsupported-surface'),", + ' invocation: message.request,', + ...(options.state === undefined ? [] : [' noticeLedger: bindings.noticeLedger,']), + " progress: { report: async (update) => { parentPort.postMessage({ id: message.id, type: 'progress', update }); } },", + ...(providers.length === 0 + ? [' providers: { processLifetime: { hits: processLifetime.hits, instanceId: processLifetime.instanceId, pid: processLifetime.pid } },'] + : [' providers: providerValues,']), + ' signal: controller.signal,', + ...(options.state === undefined ? [] : [' state: bindings.state,']), + " workspace: available({ root: cwd }, 'derived'),", + ' }, async () => {', + ' const flight = renderAgentFlight(createElement(route.module.default, { ...message.props, signal: controller.signal }), { signal: controller.signal });', + ' const reader = flight.getReader();', + ' while (true) {', + ' const next = await reader.read();', + ' if (next.done) break;', + ' const bytes = next.value;', + " parentPort.postMessage({ bytes, id: message.id, type: 'chunk' }, [bytes.buffer]);", + ' }', + ' });', + ...(options.state === undefined + ? [] + : [' } finally {', ' await bindings.close();', ' }']), + " parentPort.postMessage({ id: message.id, type: 'end' });", + ' } catch (error) {', + " parentPort.postMessage({ id: message.id, message: error instanceof Error ? error.message : String(error), type: 'error' });", + ' } finally {', + ' requests.delete(message.id);', + ' }', + '};', + '', + "parentPort.on('message', (message) => {", + " if (message.type === 'cancel') { requests.get(message.id)?.abort(); return; }", + " if (message.type === 'render') void render(message);", + '});', + '', + ].join('\n'); +}; export interface GeneratedRenderedScriptEntryOptions { readonly name: string; diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 12080625e..d2ed4cb5d 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -124,8 +124,8 @@ export const packInventoryDiagnostics = async (options: { ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), ...manifest.targets.flatMap((target) => installSurfaceRequirements(target.name).map((path) => `${artifactPrefix}/${target.name}/${path}`)), + 'README.md', ]); - if (await exists(join(projectRoot, 'README.md'))) expected.add('README.md'); const diagnostics: Diagnostic[] = []; const missing = [...expected].filter((path) => !packed.has(path)).sort((left, right) => left.localeCompare(right)); diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index f150b9bdc..5a149bcb2 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -124,6 +124,7 @@ export const planPackageEntries = async ( const sourceInputs = Object.freeze([...new Set([ bin.provenance.sourcePath, ...bin.generatedCli.routes.map((route) => route.source), + ...(model.providers ?? []).map((provider) => provider.source), ...(model.state === undefined ? [] : [model.state.source]), ])]); entries.push({ @@ -159,6 +160,7 @@ export const planPackageEntries = async ( source: bin.source, sourceInputs, virtualSource: generatedRenderedRouteWorkerSource({ + providers: model.providers ?? [], routes: renderedRoutes, ...(model.state === undefined ? {} : { state: model.state }), }), @@ -189,9 +191,16 @@ export const planPackageEntries = async ( options.artifactRoot !== undefined && options.packageOutputRoot !== undefined ) { - const name = packageBuild.bins.some((bin) => bin.name === model.metadata.name) - ? `${model.metadata.name}-install` - : model.metadata.name; + const occupiedNames = new Set(packageBuild.bins.map((bin) => bin.name)); + let name = model.metadata.name; + if (occupiedNames.has(name)) { + name = `${model.metadata.name}-install`; + let suffix = 2; + while (occupiedNames.has(name)) { + name = `${model.metadata.name}-install-${String(suffix)}`; + suffix += 1; + } + } const outputRelativePath = `bin/${name}.js`; const emittedBinDirectory = dirname(resolve(options.packageOutputRoot, outputRelativePath)); const relativeArtifact = relative(emittedBinDirectory, options.artifactRoot).replaceAll('\\', '/'); diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 799a2de1d..afa6cd372 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -182,6 +182,9 @@ export const packageBuildOutputDir = 'dist'; /** The default artifact output directory of `agent-bundle build`, relative to the project root. */ export const defaultArtifactDistPath = 'dist'; +/** The non-overlapping artifact default used when package outputs are built in the same operation. */ +export const defaultPackageArtifactDistPath = 'artifact'; + export type ArtifactDistPathIssue = 'path' | 'reserved' | 'shape'; const reservedArtifactDistPathSegments = new Set([ @@ -211,16 +214,19 @@ export const artifactDistPathIssue = (value: unknown): ArtifactDistPathIssue | u }; /** Returns the validated config path, falling back so downstream path resolution cannot throw on malformed input. */ -export const configuredArtifactDistPath = (config: AgentBundleConfig): string => { +export const configuredArtifactDistPath = ( + config: AgentBundleConfig, + fallback = defaultArtifactDistPath, +): string => { try { const output = config.output as unknown; - if (!isArtifactOutputConfig(output)) return defaultArtifactDistPath; + if (!isArtifactOutputConfig(output)) return fallback; const distPath = output.distPath; return artifactDistPathIssue(distPath) === undefined ? distPath as string - : defaultArtifactDistPath; + : fallback; } catch { - return defaultArtifactDistPath; + return fallback; } }; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 46fd517a8..93bc3f017 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -45,6 +45,7 @@ export interface ProjectServiceLogger { } export interface ProjectServiceOptions { + readonly artifactDistPathDefault?: string; readonly configPath?: string; readonly includeDevRuntime?: boolean; readonly logger?: ProjectServiceLogger; @@ -56,7 +57,7 @@ export interface ProjectServiceOptions { } export interface PreparedProject { - /** The project-relative artifact output directory: config `output.distPath` or the `dist` default. */ + /** The project-relative artifact output directory: config `output.distPath` or the operation's default. */ readonly artifactDistPath: string; readonly configPath: string; /** The validated development-only Agent API flag from the prepared configuration. */ @@ -721,7 +722,10 @@ export class ProjectService { targets: this.#options.targets, }); devAgentApiEnabled = agentApiEnabled(loaded.config); - artifactDistPath = configuredArtifactDistPath(loaded.config); + artifactDistPath = configuredArtifactDistPath( + loaded.config, + this.#options.artifactDistPathDefault, + ); const artifactOutputRoots = await resolveOutputRoots( requestedRoot, root, diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts index d33c2d6c8..8749239a3 100644 --- a/packages/agent-bundle/src/install-entry.ts +++ b/packages/agent-bundle/src/install-entry.ts @@ -1,4 +1,5 @@ import { lstat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { stableJson } from './core/digest.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; @@ -10,7 +11,7 @@ import { } from './install/install.ts'; export interface GeneratedInstallProcessOptions { - readonly artifactRoot: string; + readonly artifactRoot: string | URL; readonly hosts: readonly InstallHost[]; readonly name: string; } @@ -97,15 +98,18 @@ export const runGeneratedInstallProcess = async ( let parsed: ParsedInstallArguments | undefined; try { parsed = parseArguments(argv, options); - const metadata = await lstat(options.artifactRoot).catch(() => undefined); + const artifactRoot = options.artifactRoot instanceof URL + ? fileURLToPath(options.artifactRoot) + : options.artifactRoot; + const metadata = await lstat(artifactRoot).catch(() => undefined); if (metadata === undefined || !metadata.isDirectory()) { throw new Error( - `Package artifact root is missing at ${JSON.stringify(options.artifactRoot)}; ` + + `Package artifact root is missing at ${JSON.stringify(artifactRoot)}; ` + 'the package must ship its generated artifact directory.', ); } const result = await installBundle({ - from: options.artifactRoot, + from: artifactRoot, host: parsed.host, scope: parsed.scope, }); diff --git a/packages/agent-bundle/src/routes/cli-commands.ts b/packages/agent-bundle/src/routes/cli-commands.ts index 09c1e1b65..d2d40e4ca 100644 --- a/packages/agent-bundle/src/routes/cli-commands.ts +++ b/packages/agent-bundle/src/routes/cli-commands.ts @@ -313,7 +313,7 @@ export const compileMcpCliCommands = ( return { aliases: [], ...(typeof description === 'string' ? { description } : {}), - exitCode: 'zero' as const, + exitCode: route.config['exitCode'] === 'result' ? 'result' as const : 'zero' as const, mcp: { confirm, server, tool }, options: confirm ? [toolOption, confirmationOption] : [toolOption], path: [server, tool], diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index e9c35079a..9407d3fce 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -118,6 +118,8 @@ export interface ToolConfig { readonly _meta?: Readonly>; readonly annotations?: Readonly>; readonly description?: string; + /** Project a validated result's integer `exitCode` when this tool is exposed through the generated CLI. */ + readonly exitCode?: 'result'; readonly title?: string; } diff --git a/packages/agent-bundle/tests/cli-routes.test.ts b/packages/agent-bundle/tests/cli-routes.test.ts index 7babe2ad5..ee3c6cd54 100644 --- a/packages/agent-bundle/tests/cli-routes.test.ts +++ b/packages/agent-bundle/tests/cli-routes.test.ts @@ -397,6 +397,25 @@ describe('compiled command graph', () => { expect(Object.isFrozen(graph.cli!.commands![0]!.mcp)).toBe(true); }); + it('preserves a projected MCP tool result exit-code policy', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/alpha/tools/audit.tsx': toolModule( + "{ annotations: { readOnlyHint: true }, exitCode: 'result' }", + ), + }); + + const graph = await compileRouteGraph(root, fixtureConfig({ routes: { mcpCommands: true } })); + + expect(graph.diagnostics).toEqual([]); + expect(graph.cli?.commands).toEqual([ + expect.objectContaining({ + exitCode: 'result', + routeId: 'tool:alpha/audit', + }), + ]); + }); + it('selects projected tools with literal-star patterns and reports every unmatched pattern', async () => { const root = await createRoot(); await writeTree(root, { diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 822dceaa6..4181f49f8 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -341,7 +341,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( await writeFile(join(project.root, 'agent-bundle.config.ts'), 'this source must not be loaded\n'); const artifactValidation = await runCli(project.root, [ - 'validate', '--root', project.root, '--artifact', project.output, '--json', + 'validate', '--root', project.root, '--artifact', project.output, '--no-host-validation', '--json', ]); expect(artifactValidation).toEqual({ code: 0, @@ -350,7 +350,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( }); const humanValidation = await runCli(project.root, [ - 'validate', '--root', project.root, '--artifact', project.output, + 'validate', '--root', project.root, '--artifact', project.output, '--no-host-validation', ]); expect(humanValidation).toEqual({ code: 0, stderr: '', stdout: 'Validation succeeded\n' }); } finally { diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index ecf3d2af4..9a43f1d5b 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -95,6 +95,17 @@ describe('generated entry templates', () => { expect(generatedExecutableEntrySource({ entrySource: '/e.ts', exportName: 'default' })).toContain('entry["default"]'); }); + it('defers installer filesystem URL conversion to the guarded runtime', () => { + const source = entryShellModule.generatedInstallBinEntrySource({ + artifactRelativeUrl: '../../artifact/', + hosts: ['cursor'], + name: 'installer', + }); + + expect(source).toContain('artifactRoot: new URL("../../artifact/", import.meta.url)'); + expect(source).not.toContain('fileURLToPath'); + }); + it('routes a rejected progress report into the generated request failure path', async () => { const generated = generatedRenderedScriptEntrySource({ name: 'report', @@ -442,6 +453,41 @@ it('generates deterministic per-request provider execution in the shared Flight expect(source).toContain('providers: providerValues'); }); +it('mounts deterministic per-request providers in rendered route workers', () => { + const source = entryShellModule.generatedRenderedRouteWorkerSource({ + providers: [ + { + id: 'provider:zeta', + name: 'zeta', + provenance: { kind: 'conventional', relativePath: 'src/providers/zeta.ts' }, + source: '/project/src/providers/zeta.ts', + }, + { + id: 'provider:alpha-value', + name: 'alpha-value', + provenance: { kind: 'conventional', relativePath: 'src/providers/alpha-value.ts' }, + source: '/project/src/providers/alpha-value.ts', + }, + ], + routes: [{ + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + source: '/project/src/mcp/curator/tools/inspect.tsx', + }], + }); + + expect(source).toContain('import * as provider0 from "/project/src/providers/alpha-value.ts"'); + expect(source).toContain('import * as provider1 from "/project/src/providers/zeta.ts"'); + expect(source.indexOf('/project/src/providers/alpha-value.ts')).toBeLessThan( + source.indexOf('/project/src/providers/zeta.ts'), + ); + expect(source).toContain('await provider.module.default({ invocation: message.invocation, signal: controller.signal })'); + expect(source).toContain('providers: providerValues'); + expect(source).toContain('processLifetime'); +}); + it('conditionally emits generated state mounting without leaking sqlite into volatile or stateless entries', () => { const route = { config: {}, diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index 1748bf2d2..a45bcbd60 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -17,7 +17,7 @@ afterEach(async () => { }); const fixture = async (options: { - readonly bin?: false | string; + readonly bin?: false | readonly string[]; readonly target: 'cursor' | 'plugin' | 'portable'; }): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-installer-entry-')); @@ -36,7 +36,7 @@ const fixture = async (options: { ? [] : options.bin === false ? [' bin: false,'] - : [` bin: { ${JSON.stringify(options.bin)}: './src/cli.ts' },`]), + : [` bin: { ${options.bin.map((name) => `${JSON.stringify(name)}: './src/cli.ts'`).join(', ')} },`]), " lib: './src/index.ts',", " plugin: { name: 'installer-fixture' },", ` targets: [${JSON.stringify(options.target)}],`, @@ -68,7 +68,7 @@ const run = async ( }; it('builds a package-relative installer with fallback naming and built-host argv validation', async () => { - const root = await fixture({ bin: 'installer-fixture', target: 'cursor' }); + const root = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); const result = await build({ output: 'nested/non-default-host-packs', packageOutputs: true, @@ -116,6 +116,35 @@ it('builds a package-relative installer with fallback naming and built-host argv await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); }, 120_000); +it('chooses an unused installer name when both primary candidates are bins', async () => { + const root = await fixture({ + bin: ['installer-fixture', 'installer-fixture-install'], + target: 'cursor', + }); + const result = await build({ output: 'host-packs', packageOutputs: true, root }); + + expect(result.packageBuild?.files.map((file) => file.path)).toEqual(expect.arrayContaining([ + 'bin/installer-fixture.js', + 'bin/installer-fixture-install.js', + 'bin/installer-fixture-install-2.js', + ])); + const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install-2.js'), ['--help'], { cwd: tmpdir() }); + expect(help).toMatchObject({ code: 0, stderr: '' }); +}, 120_000); + +it('handles installer help when the project path contains a percent sign', async () => { + const originalRoot = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); + const root = `${originalRoot}%build`; + await rename(originalRoot, root); + roots.splice(roots.indexOf(originalRoot), 1, root); + + await build({ output: 'host-packs', packageOutputs: true, root }); + const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install.js'), ['--help'], { cwd: tmpdir() }); + + expect(help).toMatchObject({ code: 0, stderr: '' }); + expect(help.stdout).toContain('install [--scope ] [--json]'); +}, 120_000); + it('uses the plugin name when free and skips portable-only artifacts', async () => { const cursorRoot = await fixture({ bin: false, target: 'cursor' }); const cursor = await build({ output: 'host-packs', packageOutputs: true, root: cursorRoot }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 7fe4263a9..ad6fbb6f7 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -254,6 +254,15 @@ describe('framework-owned package build', () => { await expect(build({ output: 'dist/artifact', packageOutputs: true, root })).rejects.toThrow(/overlaps the package build output/u); }, 120_000); + it('uses a non-overlapping artifact default when package outputs are requested', async () => { + const root = await fixtureRoot(conventionFixture()); + await installTypescriptToolchain(root); + const result = await build({ packageOutputs: true, root }); + + expect(result.build.outputRoot).toBe(join(root, 'artifact')); + expect(result.packageBuild?.outputRoot).toBe(join(root, 'dist')); + }, 120_000); + it('keeps programmatic artifact builds free of package outputs', async () => { const root = await fixtureRoot(conventionFixture()); const result = await build({ output: 'artifact', root }); diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 07e573b61..78d139791 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -234,23 +234,20 @@ it('uses only an installed tarball after source deletion', async () => { readonly target: string; }[]; }; - expect(validationDocument.hostValidation).toHaveLength(1); - expect(validationDocument.hostValidation[0]).toMatchObject({ - host: 'claude', - target: 'claude', - }); - if (validationDocument.hostValidation[0]!.status === 'passed') { - expect(validationDocument.diagnostics).toEqual([]); - expect(validationDocument.hostValidation[0]!.diagnostics).toEqual([]); - } else { - expect(validationDocument.hostValidation[0]).toMatchObject({ - diagnostics: [{ code: 'AB6019', severity: 'info' }], - status: 'unavailable', - }); - expect(validationDocument.diagnostics).toEqual([ - expect.objectContaining({ code: 'AB6019', severity: 'info' }), - ]); + expect(validationDocument.hostValidation.map((report) => report.host).sort()).toEqual(['claude', 'codex']); + for (const host of ['claude', 'codex'] as const) { + const report = validationDocument.hostValidation.find((candidate) => candidate.host === host)!; + expect(report.target).toBe(host); + expect(report.diagnostics.every((diagnostic) => diagnostic.severity === 'info')).toBe(true); + if (host === 'claude' && report.status === 'passed') { + expect(report.diagnostics).toEqual([]); + } + if (report.status === 'unavailable') expect(report.diagnostics.length).toBeGreaterThan(0); } + const hostDiagnosticCodes = validationDocument.hostValidation + .flatMap((report) => report.diagnostics.map((diagnostic) => diagnostic.code)) + .sort(); + expect(validationDocument.diagnostics.map((diagnostic) => diagnostic.code).sort()).toEqual(hostDiagnosticCodes); const bundlePath = join(artifact, 'portable', 'scripts', 'bundle.mjs'); await expect(execFile(process.execPath, [ diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index 6e14d8dac..11ee6108c 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -64,9 +64,10 @@ beforeAll(async () => { fixturePackageVersion = packageDocument.version; delete packageDocument.private; packageDocument.bin = { [pluginName]: `./dist/bin/${pluginName}.js` }; - packageDocument.files = ['artifact', 'dist']; + packageDocument.files = ['artifact', 'dist', 'README.md']; await Promise.all([ writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), + writeFile(join(projectRoot, 'README.md'), '# Host install proof fixture\n'), writeFile(join(projectRoot, 'src', 'index.ts'), 'export const fixture = true;\n'), ]); const configPath = join(projectRoot, 'agent-bundle.config.ts'); diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 7a28d6aed..fde9ea568 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -40,6 +40,7 @@ beforeAll(async () => { 'export default {', ' bin: false,', " lib: './src/index.ts',", + " output: { distPath: 'host-packs' },", " plugin: { name: 'installer-fixture' },", " targets: ['cursor'],", '};', @@ -47,7 +48,7 @@ beforeAll(async () => { ].join('\n')), writeFile(join(projectRoot, 'src', 'index.ts'), 'export const value = 1;\n'), ]); - result = await prepack({ output: 'host-packs', root: projectRoot }); + result = await prepack({ root: projectRoot }); payloadPath = join(projectRoot, 'host-packs', 'cursor', 'INSTALL.md'); payloadBytes = await readFile(payloadPath, 'utf8'); }); @@ -111,6 +112,23 @@ it('reports missing allowlisted artifacts as AB7010', async () => { expect(await diagnostics(pack)).toContainEqual(expect.objectContaining({ code: 'AB7010' })); }); +it('requires README.md even when the source file is absent', async () => { + const readme = join(projectRoot, 'README.md'); + await rm(readme); + try { + const pack = { + ...result.pack, + files: result.pack.files.filter((file) => file.path !== 'README.md'), + }; + expect(await diagnostics(pack)).toContainEqual(expect.objectContaining({ + code: 'AB7010', + message: expect.stringContaining('"README.md"'), + })); + } finally { + await writeFile(readme, '# Installer fixture\n'); + } +}); + it('reports stale artifact hashes as AB7011', async () => { await writeFile(payloadPath, `${payloadBytes}stale\n`); try {