diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md new file mode 100644 index 000000000..05e5e3de0 --- /dev/null +++ b/.changeset/555-composite-plugin-root.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Emit one composite plugin root from `agent-bundle build`: every selected target is projected into the output directory itself instead of one `//` directory per target, and `plugin` is no longer a target (`targets: ['plugin']` / `--target plugin` report `AB4100`). Claude Code owns the conventional `hooks/hooks.json` and `.mcp.json`; Codex beside it reads `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json` through manifest pointers; Cursor beside another host reads `hooks/hooks-cursor.json`; the portable Agent Plugins pack beside other hosts is the root's `portable/` view with `portable/mcp/` shims onto the shared servers; a hook selecting Claude Code and Codex compiles to one host-detecting wrapper. Skills shared by several hosts must select all of them (`AB4104`), two projections writing different bytes to one path fail the build (`AB4105`), a target set that mixes an advanced adapter with a built-in host cannot share a root (`AB4106`), and `validate` and `inspect` judge the same root the build does. Codex's `agents/openai.yaml` skill sidecar is still written beside the shared `SKILL.md`. The artifact manifest lists the real hosts with root-relative paths, the hook index lists one entry per hook and host, `INSTALL.md` carries a section per selected host, `install`/`doctor`/`validate --artifact --host-validation` read the root and probe nothing nested under it, and `TargetRegistry.root(targets)` exposes the root's contracts to adapters and tooling (#569) diff --git a/README.md b/README.md index b27a07c28..92c56467f 100644 --- a/README.md +++ b/README.md @@ -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' } } }, @@ -51,15 +51,15 @@ npx agent-bundle build --root . --output dist # write installable artifacts 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. +Every build emits one plugin root — here `dist/` — that every selected target installs from. `targets: ['claude', 'codex', 'cursor']` places the `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests at that root over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories (Codex reads its hooks and MCP documents under `.codex-plugin/`, Cursor its hooks from `hooks/hooks-cursor.json`, so nothing collides); a multi-host root also carries a generated `AGENTS.md` explaining how to install it into each host. The public targets are `claude`, `codex`, `cursor`, and `portable`; beside other hosts the portable Agent Plugins pack is the root's `portable/` directory. 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. -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` target 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` target: `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`. 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. @@ -73,12 +73,12 @@ The same config also owns the npm package build — no second bundler config, bi - `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 +When validating a built root that projects `claude`, 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 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 dist 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 5c39402ff..36387c203 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 — `plugin` is no longer a target name; duplicate IDs and outputs) and composite plugin root composition (`AB4104`: a declaration selects some but not all of the hosts that share one root directory; `AB4105`: two host projections emitted different bytes for one root path; `AB4106`: a target set no root can hold — an advanced adapter beside a built-in host; 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. | @@ -50,7 +50,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 a built root that projects `claude` when `--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 @@ -87,7 +87,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` roots `unavailable` without spawning again. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | @@ -512,6 +512,31 @@ that claims the same path. See “The routed CLI shell” in | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | +## Composite plugin root composition (`AB4104`–`AB4106`) + +`agent-bundle build` emits one plugin root for every selected target (#555): +host manifests in their own directories over shared `skills/`, `scripts/`, +`mcp/`, `bin/`, and `assets/` content, with the portable (Agent Plugins) +projection namespaced under `portable/` beside other hosts. Where two hosts +would read one conventional document the composition relocates the one a host +reads through an explicit manifest pointer (Codex beside Claude Code reads +`.codex-plugin/hooks.json` and `.codex-plugin/mcp.json`; Cursor beside another +host reads `hooks/hooks-cursor.json`). Where it cannot isolate a declaration +it refuses instead of widening the declaration's host scope. `AB4104` and +`AB4105` come from the assembler for a root that projects two or more hosts, +name no `target` because the whole root is at issue, and are never emitted for +a single-host root; `validate` and `inspect` plan the same root the build +does, so they report them too. `AB4106` is judged on the normalized model, +before any planning: only the built-in hosts (`claude`, `codex`, `cursor`, +`portable`) know how to share a root, so an advanced registry's own adapter is +built alone, one target per `--output`. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4104` | error | A declaration whose emitted file every projected host discovers conventionally selects some but not all of those hosts, so one root cannot hide it from the rest: a skill selects a strict subset of the hosts that share the root's `skills/` directory (the `portable/` view carries its own copy, so only the hosts reading the root's `skills/` must agree), or a command selects `cursor` in a root that also projects `claude` (`commands/` carries Claude Code frontmatter there, and Cursor reads plain Markdown prompts from the same directory). Select every projected host (or none of them) on the declaration, or build the hosts that differ into a separate `--output`. | +| `AB4105` | error | Two host projections of the root emitted different bytes for the same root-relative path — a generated document, a copied file, or a hook wrapper. Identical bytes merge silently and pool their source inputs; differing bytes are refused rather than letting one host overwrite another's file. Build the hosts that differ into a separate `--output`. | +| `AB4106` | error | The project selects two or more targets and one of them is not a built-in host — an adapter registered on an advanced `TargetRegistry` — so no plugin root can project the whole set. Named on that target, with its config provenance. Build the adapter's target alone into its own `--output`, and the built-in hosts into another. | + ## Config beside a route-generated MCP server (`AB4340`) A `mcp.servers.` block for a server the route graph compiles in @@ -1219,7 +1244,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..6173945e9 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -177,9 +177,9 @@ Each cross-request notice route is selected from the target host's pinned `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); a root projecting several hosts wires only the +routes every projected host advertises (the intersection), and a target 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 @@ -1293,8 +1293,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 artifact output root (chosen per build) appears as `` — +one plugin root for every selected target (#555) — 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. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 31f4ba1de..9afbb681a 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -573,9 +573,9 @@ 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 +`agent-bundle build` emits one plugin root that every selected target installs +from (#555); it includes one `INSTALL.md` generated with the real plugin and +marketplace names and a section per selected host. Claude and Codex bundles include local marketplace manifests and install through their public plugin CLIs; Cursor bundles use the documented `~/.cursor/plugins/local/` location because Cursor exposes marketplace management but no non-interactive plugin install verb. @@ -616,13 +616,13 @@ 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 +A root that selects `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 @@ -649,11 +649,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 diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index f1cf85556..7a5acbf1f 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -40,9 +40,9 @@ 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 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..bd6a42e2f 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -13,8 +13,9 @@ 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 into a root that projects `portable`, into the shared `scripts/` +every host of that root reads), 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..f194a3be0 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 into a root that projects + // portable — and, like every script, into the shared scripts/ directory + // every host of that root reads. scripts: { 'detect-risk': { entry: './src/scripts/detect-risk.ts', diff --git a/examples/host-test/README.md b/examples/host-test/README.md index 59c509dd5..5c46430e0 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` (one root serves every host) 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..dd2806e35 100644 --- a/examples/host-test/scripts/probe.mjs +++ b/examples/host-test/scripts/probe.mjs @@ -62,7 +62,8 @@ 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), + // One composite root serves every selected host (agent-bundle #555). + 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..6f1f5aa23 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 serves every selected host (agent-bundle #555). + 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..c8f5922c0 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -105,8 +105,11 @@ const artifactDigest = async (root: string): Promise { await runPackageHosts(); - const claudeRoot = join(pluginsRoot, 'claude'); - const codexRoot = join(pluginsRoot, 'codex'); + // One plugin root serves every selected host (#555): Claude Code owns the + // conventional documents, Codex beside it reads its own through manifest + // pointers under .codex-plugin/. + const claudeRoot = pluginsRoot; + const codexRoot = pluginsRoot; const claudeManifest = await readJson<{ name: string; version: string }>(join(claudeRoot, '.claude-plugin/plugin.json')); const codexManifest = await readJson<{ interface: unknown; @@ -117,20 +120,20 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy 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')); + const codexMcp = await readJson<{ mcpServers: Record }>(join(codexRoot, '.codex-plugin/mcp.json')); const claudeHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( join(claudeRoot, 'hooks/hooks.json'), ); const codexHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(codexRoot, 'hooks/hooks.json'), + join(codexRoot, '.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', @@ -152,12 +155,11 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy 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); + 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 root of [pluginsRoot]) { for (const asset of assets) { await access(join(root, 'runtime', asset)); } @@ -171,7 +173,7 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy } // 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']) { + for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.codex-plugin/mcp.json', '.codex-plugin/hooks.json']) { await access(join(codexRoot, relative)); } }); @@ -192,7 +194,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 +220,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 +258,12 @@ 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'); + // Claude Code reads the conventional document; Codex beside it reads its + // own under .codex-plugin/ (#555). Both name the one shared wrapper. + 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'); 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..0b601e857 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,11 @@ 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(); - } + // One plugin root serves Claude Code and Codex (#555): the runtime payload + // is packaged once at `dist/plugins/runtime`, not per host. + 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..21b3e4d44 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.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 682e80187..bd8307933 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -133,8 +133,8 @@ 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` +When Claude Code is on `PATH`, artifact validation runs its validator over a built root that +projects `claude`. 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: @@ -155,34 +155,35 @@ CI should use strict validation: agent-bundle validate --artifact dist --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 dist 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: +A build emits one plugin root (`artifact/` by default) that every selected +target installs from; it contains a generated `INSTALL.md` with a section per +selected host and commands that use the bundle's real plugin and marketplace +names. Claude and Codex always include local marketplace manifests, so their +public CLIs can install the emitted root 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 selects `cursor` or `portable` 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 ``` @@ -208,8 +209,10 @@ 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` takes the built root; beside +other hosts the portable Agent Plugins pack is its `portable/` directory, which +`install cursor --from artifact/portable` (or `node artifact/install.mjs`) +copies into Cursor's local plugins. ### Reinstall after a same-version rebuild diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index f709550bd..b34da6df3 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -297,22 +297,6 @@ export const capabilityBooleanView = ( 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': @@ -371,46 +355,6 @@ export const mergeCapabilityEvidence = ( }); }; -/** - * 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, diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 20da19567..697a665b5 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -40,6 +40,7 @@ import { planHooks, readStandardNativeHookCommands, validatedNativeHookDocument, + type HookPlanRootOptions, type TargetHookContract, } from './hook-contract.ts'; import schemaProvenance from './schemas/claude/PROVENANCE.json' with { type: 'json' }; @@ -428,7 +429,9 @@ const hookContract = Object.freeze({ }, readNativeCommands: readStandardNativeHookCommands, wrapperPath: (hook: NormalizedPlugin['hooks'][number]) => `hooks/${hook.name}.mjs`, - wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), + // A wrapper one composite root serves to Claude Code and Codex alike (#555) + // carries the host-detecting Universal body; byte-identical from both sides. + wrapperSource: (entry) => nativeHookWrapperSource(entry, (entry.hosts?.length ?? 1) > 1 ? 'Universal' : 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ adapterRevision: '1.28.0', @@ -3090,6 +3093,12 @@ export const planClaudeMonitors = ( }; export interface ClaudeArtifactPlanOptions { + /** + * Set when this plan is the Claude projection of a root composed with other + * hosts (#555): the composite owns the install surface and names the event + * endpoint every wrapper and generated MCP entry of that root shares. + */ + readonly composite?: HookPlanRootOptions; /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ readonly targetName?: string; } @@ -3145,7 +3154,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, hookContract, undefined, options.composite ?? {}); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); @@ -3235,7 +3244,7 @@ export const planClaudeArtifacts = ( pluginRelativePath: claudeArtifactPaths.plugin, targetName, }); - return withInstallSurface(Object.freeze({ + const plan = Object.freeze({ ...basePlan, entries: sortedEntries([ ...basePlan.entries, @@ -3244,7 +3253,8 @@ export const planClaudeArtifacts = ( ...workflows.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), - }), model, targetName === 'plugin' ? 'plugin' : 'claude'); + }); + return options.composite === undefined ? withInstallSurface(plan, model, 'claude') : plan; }; 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..1df73d133 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -38,6 +38,7 @@ import { planHooks, readStandardNativeHookCommands, validatedNativeHookDocument, + type HookPlanRootOptions, type TargetHookContract, } from './hook-contract.ts'; import appSchema from './schemas/codex/app.schema.json' with { type: 'json' }; @@ -175,7 +176,9 @@ const hookContract = Object.freeze({ }, readNativeCommands: readStandardNativeHookCommands, wrapperPath: (hook: NormalizedPlugin['hooks'][number]) => `hooks/${hook.name}.mjs`, - wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'), + // See the Claude contract: a wrapper shared with Claude Code in one composite + // root (#555) carries the Universal body so both sides emit identical bytes. + wrapperSource: (entry) => nativeHookWrapperSource(entry, (entry.hosts?.length ?? 1) > 1 ? 'Universal' : 'Codex'), } satisfies TargetHookContract); const metadata = Object.freeze({ adapterRevision: '1.13.0', @@ -1103,6 +1106,18 @@ const planMcpServer = ( }; export interface CodexArtifactPlanOptions { + /** + * Set when this plan is the Codex projection of a root composed with other + * hosts (#555): the composite owns the install surface and names the event + * endpoint every wrapper and generated MCP entry of that root shares. + */ + readonly composite?: HookPlanRootOptions; + /** + * Artifact-relative path for the Codex hooks document. A composite root that + * also hosts Claude Code relocates it (Claude Code owns the conventional + * `hooks/hooks.json`); Codex reads the manifest pointer instead of the default. + */ + readonly hooksRelativePath?: string; /** 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. */ @@ -1117,6 +1132,7 @@ export const planCodexArtifacts = ( ): TargetArtifactPlan => { const targetName = options.targetName ?? codexName; const mcpRelativePath = options.mcpRelativePath ?? codexArtifactPaths.mcp; + const hooksRelativePath = options.hooksRelativePath ?? hookContract.manifestPath; const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName); const diagnostics: Diagnostic[] = []; const servers: Record> = Object.create(null) as Record>; @@ -1131,7 +1147,7 @@ export const planCodexArtifacts = ( 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, hookContract, undefined, options.composite ?? {}); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); @@ -1189,7 +1205,7 @@ export const planCodexArtifacts = ( interface: interfacePlan.value, ...(appsValid ? { apps: `./${codexArtifactPaths.apps}` } : {}), ...(mcp === undefined ? {} : { mcpServers: `./${mcpRelativePath}` }), - ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), + ...(hookDocument === undefined ? {} : { hooks: `./${hooksRelativePath}` }), name: model.metadata.name, skills: './skills/', version: model.metadata.version, @@ -1235,7 +1251,7 @@ export const planCodexArtifacts = ( hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, - hookManifestPath: hookContract.manifestPath, + hookManifestPath: hooksRelativePath, isSelected, marketplace, marketplaceRelativePath: codexArtifactPaths.marketplace, @@ -1255,7 +1271,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 options.composite === undefined ? withInstallSurface(plan, model, 'codex') : plan; }; export const codexAdapter: TargetAdapter = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/composite-hosts.ts b/packages/agent-bundle/src/adapters/composite-hosts.ts new file mode 100644 index 000000000..73533aa36 --- /dev/null +++ b/packages/agent-bundle/src/adapters/composite-hosts.ts @@ -0,0 +1,27 @@ +/** + * The built-in hosts that compose into one plugin root (#555), and the + * identity such a root takes. A leaf module: `config/validate.ts` judges a + * target set's composability before any adapter is planned, and + * `adapters/composite.ts` builds the root from the same names. + */ +export const compositeHostNames = Object.freeze(['claude', 'codex', 'cursor', 'portable'] as const); +export type CompositeHost = (typeof compositeHostNames)[number]; + +const hostOrder: Readonly> = Object.freeze({ claude: 0, codex: 1, cursor: 2, portable: 3 }); + +export const isCompositeHost = (name: string): name is CompositeHost => + (compositeHostNames as readonly string[]).includes(name); + +/** The selected host projections, deduplicated, in the composite's canonical order. */ +export const sortedCompositeHosts = (names: readonly string[]): readonly CompositeHost[] => + Object.freeze([...new Set(names.filter(isCompositeHost))].sort((left, right) => hostOrder[left] - hostOrder[right])); + +/** + * The identity a composite root's generated code shares between its hook + * wrappers and MCP entries (the event endpoint name). Never a host name, so it + * cannot be mistaken for a projection; a single-host root uses the host name. + */ +export const compositeTargetName = (names: readonly string[]): string => { + const hosts = sortedCompositeHosts(names); + return hosts.length === 1 ? hosts[0]! : hosts.join('+'); +}; diff --git a/packages/agent-bundle/src/adapters/composite.ts b/packages/agent-bundle/src/adapters/composite.ts new file mode 100644 index 000000000..a97630534 --- /dev/null +++ b/packages/agent-bundle/src/adapters/composite.ts @@ -0,0 +1,771 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; +import { stableJson } from '../core/digest.ts'; +import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../core/types.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { + compositeHostNames, + compositeTargetName, + isCompositeHost, + sortedCompositeHosts, + type CompositeHost, +} from './composite-hosts.ts'; +import { intersectNoticeDeliveryAdvertisements, unionCapabilityStates } from './capability-state.ts'; +import type { CapabilityState } from '../core/capabilities.ts'; +import { claudeAdapter, claudeArtifactPaths, planClaudeArtifacts } from './claude.ts'; +import { codexAdapter, codexArtifactPaths, codexPluginDocumentValidator, planCodexArtifacts } from './codex.ts'; +import { + createCursorHookContract, + cursorAdapter, + cursorArtifactPaths, + cursorHooksValidator, + cursorManifest, + cursorMarketplaceValidator, + cursorMcpValidator, + cursorPluginNameError, + cursorPluginValidator, + cursorVariables, + emptyCursorHooksDocument, + isValidCursorPluginName, + planCursorManifestMetadata, + planCursorMarketplace, + planCursorMcpServer, +} from './cursor.ts'; +import { pluginLogoCopyEntry } from './plugin-logo.ts'; +import { planHooks, type HookPlanRootOptions, type TargetHookContract } from './hook-contract.ts'; +import { portableAdapter } from './portable.ts'; +import { + commandWriteEntries, + ruleWriteEntries, + sortedEntries, + sourceInputs, + standardArtifactLayout, + validateJsonSchemaDocument, + type TargetAdapter, + type TargetAdapterMetadata, + type TargetArtifactEntry, + type TargetArtifactLayout, + type TargetArtifactPlan, + type TargetArtifactValidationContract, + type TargetHookEntry, +} from './types.ts'; +import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts'; +import { localMcpOutputName } from '../build/entries.ts'; +import { withInstallSurface, type BuiltInTarget } from '../install/surface.ts'; + +/** + * A root composed of several host projections (#555). + * + * Agent Bundle emits exactly one plugin root. Selecting one target stages that + * host's plan at the root unchanged; selecting several composes their plans + * into the same root: shared `skills/`, `scripts/`, `mcp/`, `mcp-apps/`, + * `bin/`, and `assets/` are emitted once, and each host keeps its own manifest + * directory and host documents. Where two hosts read one conventional file the + * composition either relocates the document a host reads through an explicit + * manifest pointer, or refuses with a diagnostic; it never widens a + * declaration's host scope silently. + * + * - Claude Code owns the conventional slots it discovers on its own + * (`hooks/hooks.json`, `.mcp.json`, `commands/`). Codex reads its hooks and + * MCP documents through `.codex-plugin/plugin.json` pointers, so beside + * Claude they relocate to `.codex-plugin/hooks.json` and + * `.codex-plugin/mcp.json`. Cursor's hook schema is incompatible with the + * Claude/Codex one, so beside another host its document is + * `hooks/hooks-cursor.json` over dedicated `hooks/.cursor.mjs` + * wrappers; its root `mcp.json` collides with nothing. + * - A hook selecting Claude Code and Codex compiles to one host-detecting + * wrapper (`hooks/.mjs`) that both host documents reference. Codex + * documents exporting `CLAUDE_PLUGIN_ROOT` and `PLUGIN_ROOT` into hook + * processes, and its hook envelope matches Claude's, which is what makes + * the shared wrapper sound. + * - `skills/` is discovered conventionally by every host, so a skill must + * select every selected host or none of them (AB4104). Its Markdown is the + * one document every selected host accepts (see the normalizer's shared + * skill document). + * - An Agent Plugins v1 root `plugin.json` makes Codex prefer it over + * `.codex-plugin/plugin.json` (disabling hooks and apps and forcing a root + * `mcp.json`), and the portable and Cursor projections both own the root + * `mcp.json`. Beside any other host the portable projection is therefore + * the namespaced view `portable/`: a complete Agent Plugins pack whose + * `mcp.json` reaches the shared compiled servers through `portable/mcp/` + * shims, installed and validated as its own plugin root. + */ +export { + compositeHostNames, + compositeTargetName, + isCompositeHost, + sortedCompositeHosts, + type CompositeHost, +} from './composite-hosts.ts'; + +const codexCompositePaths = Object.freeze({ + hooks: '.codex-plugin/hooks.json', + mcp: '.codex-plugin/mcp.json', +}); +const cursorCompositePaths = Object.freeze({ + hooks: 'hooks/hooks-cursor.json', + marketplace: cursorArtifactPaths.marketplace, + mcp: cursorArtifactPaths.mcp, + plugin: cursorArtifactPaths.plugin, +}); +/** The namespaced Agent Plugins view of a composite root. */ +export const portableViewDirectory = 'portable'; + +/** + * The directory, relative to the composite root, that one host reads as its + * plugin root: the root itself for every host except the portable projection, + * which beside other hosts lives in `portable/`. Empty for a single-host root. + */ +export const compositeHostRoot = (hosts: readonly string[], host: string): string => { + const selected = sortedCompositeHosts(hosts); + return selected.length > 1 && host === 'portable' ? portableViewDirectory : ''; +}; + +/** Composite diagnostics name no target: the whole root is at issue, not one projection. */ +const errorDiagnostic = (code: string, message: string): Diagnostic => Object.freeze({ code, message, severity: 'error' }); +const schemaDiagnostics = ( + document: string, + valid: boolean, + errors: readonly { readonly instancePath: string; readonly message?: string }[] | null | undefined, +): Diagnostic[] => valid + ? [] + : [errorDiagnostic( + `composite.schema.${document}`, + `Composite plugin root ${document}.json is invalid: ${(errors ?? []) + .map((error) => `${error.instancePath || '/'}: ${error.message ?? 'schema validation failed'}`) + .join('; ') || 'schema validation failed'}.`, + )]; + +const scopeMessage = ( + kind: string, + name: string, + selected: readonly string[], + hosts: readonly CompositeHost[], + reason: string, +): string => + `${kind} ${JSON.stringify(name)} selects ${JSON.stringify(selected)} but this root projects ${JSON.stringify(hosts)}; ${reason} ` + + 'Select every projected host (or none of them) on the declaration, or build the hosts that differ into a separate --output.'; + +/** + * AB4104: a declaration whose emitted file is read by every projected host + * must select all of them or none, because one root cannot isolate it. + */ +export const compositeScopeDiagnostics = (model: NormalizedPlugin, names: readonly string[]): readonly Diagnostic[] => { + const hosts = sortedCompositeHosts(names); + if (hosts.length < 2) return Object.freeze([]); + const diagnostics: Diagnostic[] = []; + // The portable view carries its own skills/ copy, so only the hosts sharing + // the root's skills/ directory must agree. + const sharedSkillHosts = hosts.filter((host) => compositeHostRoot(hosts, host) === ''); + const partial = (targets: readonly string[]): readonly string[] | undefined => { + const selected = sharedSkillHosts.filter((host) => targets.includes(host)); + return selected.length === 0 || selected.length === sharedSkillHosts.length ? undefined : selected; + }; + for (const skill of model.skills) { + const selected = partial(skill.targets); + if (selected === undefined) continue; + diagnostics.push({ + code: 'AB4104', + message: scopeMessage('Skill', skill.name, selected, sharedSkillHosts, 'every host discovers the shared skills/ directory conventionally, so a skill cannot be hidden from some of them.'), + severity: 'error', + sourcePath: skill.source, + }); + } + if (hosts.includes('claude') && hosts.includes('cursor')) { + for (const command of model.commands ?? []) { + if (!command.targets.includes('cursor')) continue; + diagnostics.push({ + code: 'AB4104', + message: scopeMessage('Command', command.name, hosts.filter((host) => command.targets.includes(host)), hosts, 'commands/ carries Claude Code frontmatter in a root that projects Claude Code, and Cursor reads plain Markdown prompts from the same directory.'), + severity: 'error', + sourcePath: command.source, + }); + } + } + return Object.freeze(diagnostics); +}; + +const identicalStrings = (left: readonly string[], right: readonly string[]): boolean => + left.length === right.length && left.every((value, index) => value === right[index]); + +/** AB4105: two projections wrote different bytes to one path; identical bytes merge, pooling their source inputs. */ +const mergeEntries = ( + diagnostics: Diagnostic[], + ...sides: readonly (readonly TargetArtifactEntry[])[] +): TargetArtifactEntry[] => { + const merged = new Map(); + for (const entry of sides.flat()) { + 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({ + code: 'AB4105', + message: `Two host projections of this plugin root emitted different content for ${JSON.stringify(entry.relativePath)}.`, + severity: 'error', + }); + continue; + } + if (!identicalStrings(existing.sourceInputs, entry.sourceInputs)) { + merged.set(entry.relativePath, Object.freeze({ + ...existing, + sourceInputs: sourceInputs(...existing.sourceInputs, ...entry.sourceInputs), + })); + } + } + return [...merged.values()]; +}; + +/** + * A hook selecting several projected hosts is planned once per host side; the + * wrapper bytes are identical (the Universal codec), so the compiled entry is + * kept once with every host it serves recorded on it. + */ +const mergeHookEntries = ( + diagnostics: Diagnostic[], + ...sides: readonly (readonly TargetHookEntry[])[] +): readonly TargetHookEntry[] => { + const merged = new Map(); + for (const entry of sides.flat()) { + const existing = merged.get(entry.relativePath); + if (existing === undefined) { + merged.set(entry.relativePath, entry); + continue; + } + if (existing.virtualSource !== entry.virtualSource) { + diagnostics.push({ + code: 'AB4105', + message: `Two host projections of this plugin root compiled different wrappers to ${JSON.stringify(entry.relativePath)}.`, + severity: 'error', + }); + continue; + } + const hosts = [...new Set([...(existing.hosts ?? [existing.target]), ...(entry.hosts ?? [entry.target])])]; + merged.set(entry.relativePath, Object.freeze({ ...existing, hosts: Object.freeze(hosts) })); + } + return Object.freeze([...merged.values()]); +}; + +const cursorMcpPlanContext = Object.freeze({ codePrefix: 'composite.cursor', errorDiagnostic }); + +// Cursor's wrappers are indexed like any host's: the index keeps one entry +// per hook and host, and Cursor's dedicated `.cursor.mjs` wrapper is the +// canonical one for the cursor projection. +const cursorCompositeHookContract = createCursorHookContract({ + manifestPath: cursorCompositePaths.hooks, + wrapperPath: (hook: NormalizedHook) => `hooks/${hook.name}.cursor.mjs`, +}); + +interface AgentsDocumentOptions { + readonly bin: boolean; + readonly cliBins: readonly string[]; + readonly commands: boolean; + readonly hosts: readonly CompositeHost[]; + readonly lsp: boolean; + readonly outputStyles: boolean; + readonly rules: boolean; + readonly settings: boolean; + readonly workflows: boolean; +} + +const hostLabel: Readonly> = Object.freeze({ + claude: 'Claude Code', + codex: 'Codex', + cursor: 'Cursor', + portable: 'Agent Plugins (portable)', +}); + +const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions): string => { + const description = model.metadata.description ?? model.metadata.name; + const has = (host: CompositeHost): boolean => options.hosts.includes(host); + const labels = options.hosts.map((host) => hostLabel[host]); + const hookLine = [ + ...(has('claude') && has('codex') + ? ['`hooks/hooks.json` (Claude Code) and `.codex-plugin/hooks.json` (Codex) share one host-detecting wrapper per hook'] + : has('claude') || has('codex') + ? [`\`hooks/hooks.json\` with one wrapper per hook (${has('claude') ? 'Claude Code' : 'Codex'})`] + : []), + ...(has('cursor') ? ['`hooks/hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`)'] : []), + ]; + return [ + `# ${model.metadata.name}`, + '', + description, + '', + `This directory is a multi-host agent plugin root (version ${model.metadata.version}) compiled by agent-bundle.`, + `One root serves ${labels.join(', ')}: 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 ${labels.join(', ')} commands using this root's compiled names.`, + ...(has('cursor') || has('portable') + ? [`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', + '', + ...(has('claude') ? ['- `.claude-plugin/` — Claude Code manifest and host documents.', '- `.mcp.json` — Claude Code MCP configuration (plugin-root convention).'] : []), + ...(has('codex') + ? [`- \`.codex-plugin/\` — Codex manifest and host documents${has('claude') ? ' (its hooks and MCP documents live here, beside Claude Code\'s conventional ones)' : ''}.`] + : []), + ...(has('cursor') ? ['- `.cursor-plugin/plugin.json` and root `mcp.json` — Cursor local-plugin manifest and MCP document.'] : []), + ...(has('portable') ? ['- `plugin.json` and root `mcp.json` — Agent Plugins v1 manifest and MCP document.'] : []), + ...(options.lsp + ? ['- `.lsp.json` — Claude Code language-server configuration (plugin-root convention). Claude Code only.'] + : []), + ...(options.settings + ? ['- `settings.json` — Claude Code default configuration applied when the plugin is enabled (plugin-root convention). Claude Code only.'] + : []), + ...(options.commands + ? ['- `commands/` — Claude Code command prompts. Claude Code only.'] + : []), + ...(options.bin + ? ['- `bin/` — Claude Code executables added to the Bash tool PATH while the plugin is enabled. Claude Code only.'] + : []), + ...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. Claude Code only.'] : []), + ...(options.outputStyles ? ['- `output-styles/` — Claude Code output style definitions. Claude Code only.'] : []), + ...(options.rules ? ['- `rules/` — Cursor rules (`.mdc`). Cursor only.'] : []), + ...(hookLine.length === 0 ? [] : [`- \`hooks/\` — ${hookLine.join('; ')}.`]), + '- `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 root 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'); +}; + +interface CursorSidePlan { + readonly diagnostics: readonly Diagnostic[]; + readonly entries: readonly TargetArtifactEntry[]; + readonly hookEntries: readonly TargetHookEntry[]; +} + +/** + * The Cursor projection of a composite root: the same shared `skills/`, + * `scripts/`, and `mcp/` through `.cursor-plugin/plugin.json`, the root + * `mcp.json`, the relocated Cursor-format hooks document, and Cursor rules. + * Commands are omitted beside Claude Code (AB4104 refuses the overlap). + */ +const planCursorSide = ( + model: NormalizedPlugin, + hosts: readonly CompositeHost[], + root: HookPlanRootOptions, + targetSourceInputs: readonly string[], +): CursorSidePlan => { + const diagnostics: Diagnostic[] = []; + const entries: TargetArtifactEntry[] = []; + const isSelected = (targets: readonly string[]): boolean => targets.includes('cursor'); + const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); + const selectedCommands = hosts.includes('claude') ? [] : (model.commands ?? []).filter((command) => isSelected(command.targets)); + + const cursorMarketplace = planCursorMarketplace(model); + diagnostics.push(...cursorMarketplace.diagnostics); + if (cursorMarketplace.document !== undefined && cursorMarketplace.valid) { + entries.push({ + content: `${stableJson(cursorMarketplace.document)}\n`, + kind: 'write', + relativePath: cursorCompositePaths.marketplace, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), + }); + } + + const cursorServers: Record> = Object.create(null) as Record>; + const mcpSourceInputs: string[] = []; + for (const server of model.mcpServers) { + if (!isSelected(server.targets)) 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 hookEntries: readonly TargetHookEntry[] = Object.freeze([]); + if (!isValidCursorPluginName(model.metadata.name)) { + diagnostics.push(errorDiagnostic('composite.cursor.name', cursorPluginNameError(model.metadata.name))); + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: Object.freeze(entries), hookEntries }); + } + + // Cursor's envelope is not the shared Claude/Codex format, so its hooks + // lower separately over dedicated `hooks/.cursor.mjs` wrappers; the + // empty document remains as a schema-collision guard when nothing lowers. + let cursorHooksDocument: Record = emptyCursorHooksDocument; + // Cursor never shares a wrapper: its side names the root's endpoint but serves Cursor alone. + const cursorHooks = planHooks(model, 'cursor', cursorCompositeHookContract, 'cursor', { + ...(root.artifactTarget === undefined ? {} : { artifactTarget: root.artifactTarget }), + hosts: ['cursor'], + }); + diagnostics.push(...cursorHooks.diagnostics); + const emitCursorHooks = cursorHooks.document !== undefined; + if (cursorHooks.document !== undefined) { + const valid = cursorHooksValidator(cursorHooks.document); + diagnostics.push(...schemaDiagnostics('cursor-hooks', valid, cursorHooksValidator.errors)); + if (valid) { + cursorHooksDocument = cursorHooks.document; + hookEntries = cursorHooks.hookEntries; + } + } + const variables = cursorVariables(cursorMcp); + const manifestMetadata = planCursorManifestMetadata(model, cursorMcpPlanContext); + diagnostics.push(...manifestMetadata.diagnostics); + const manifest = cursorManifest(model, { + ...(selectedCommands.length === 0 ? {} : { commands: './commands/' }), + ...(emitCursorHooks ? { hooks: `./${cursorCompositePaths.hooks}` } : {}), + ...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorCompositePaths.mcp}` } : {}), + ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), + ...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}), + ...(variables === undefined ? {} : { variables }), + }, manifestMetadata.document); + const manifestValid = cursorPluginValidator(manifest); + diagnostics.push(...schemaDiagnostics('cursor-plugin', manifestValid, cursorPluginValidator.errors)); + if (!manifestValid) { + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: Object.freeze(entries), hookEntries: Object.freeze([]) }); + } + entries.push({ + content: `${stableJson(manifest)}\n`, + kind: 'write', + relativePath: cursorCompositePaths.plugin, + sourceInputs: sourceInputs( + model.metadata.provenance.sourcePath, + ...targetSourceInputs, + ...selectedRules.map((rule) => rule.source), + ...selectedCommands.map((command) => command.source), + model.metadata.logo?.source, + ...manifestMetadata.sourceInputs, + ), + }); + const logoEntry = pluginLogoCopyEntry(model); + if (logoEntry !== undefined) entries.push(logoEntry); + if (cursorMcp !== undefined && cursorMcpValid) { + entries.push({ + content: `${stableJson(cursorMcp)}\n`, + kind: 'write', + relativePath: cursorCompositePaths.mcp, + sourceInputs: sourceInputs(...targetSourceInputs, ...mcpSourceInputs), + }); + } + if (emitCursorHooks) { + entries.push({ + content: `${stableJson(cursorHooksDocument)}\n`, + kind: 'write', + relativePath: cursorCompositePaths.hooks, + sourceInputs: sourceInputs(...targetSourceInputs, ...hookEntries.map((entry) => entry.hook.provenance.sourcePath)), + }); + } + entries.push(...ruleWriteEntries(model, isSelected)); + if (selectedCommands.length > 0) { + entries.push(...commandWriteEntries(model, isSelected, (command) => + command.markdown === command.body ? command.markdown : command.body)); + } + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: Object.freeze(entries), hookEntries }); +}; + +/** + * The portable projection of a composite root: the Agent Plugins pack under + * `portable/`, with `portable/mcp/.mjs` shims that run the shared + * compiled servers. Every referenced path stays inside the view, so the pack + * installs and validates as its own plugin root. + */ +const planPortableView = (model: NormalizedPlugin): TargetArtifactPlan => { + const side = withoutInstallSurface(portableAdapter.plan(model)); + const entries: TargetArtifactEntry[] = side.entries.map((entry) => Object.freeze({ + ...entry, + relativePath: `${portableViewDirectory}/${entry.relativePath}`, + })); + for (const server of model.mcpServers) { + if (server.source === undefined || !server.targets.includes('portable')) continue; + const outputName = localMcpOutputName(server); + entries.push({ + content: `import '../../mcp/${outputName}';\n`, + kind: 'write', + relativePath: `${portableViewDirectory}/mcp/${outputName}`, + sourceInputs: sourceInputs(server.provenance.sourcePath), + }); + } + return Object.freeze({ diagnostics: side.diagnostics, entries: Object.freeze(entries), hookEntries: Object.freeze([]) }); +}; + +const planComposite = (model: NormalizedPlugin, hosts: readonly CompositeHost[]): TargetArtifactPlan => { + const diagnostics: Diagnostic[] = [...compositeScopeDiagnostics(model, hosts)]; + if (diagnostics.length > 0) { + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: Object.freeze([]), hookEntries: Object.freeze([]) }); + } + const has = (host: CompositeHost): boolean => hosts.includes(host); + const artifactTarget = compositeTargetName(hosts); + // Claude Code and Codex share hook wrappers; Cursor and portable never do. + const sharedWrapperHosts = hosts.filter((host) => host === 'claude' || host === 'codex'); + const root: HookPlanRootOptions = Object.freeze({ + artifactTarget, + hostContractRevision: sharedWrapperHosts.map((host) => hostAdapters[host].metadata.observedVersion).join('+'), + hosts: sharedWrapperHosts, + }); + const targetSourceInputs = model.targets + .filter((target) => hosts.includes(target.name as CompositeHost)) + .map((target) => target.provenance.sourcePath); + + const sides: TargetArtifactPlan[] = []; + if (has('claude')) sides.push(planClaudeArtifacts(model, { composite: root, targetName: 'claude' })); + if (has('codex')) { + sides.push(planCodexArtifacts(model, { + composite: root, + ...(has('claude') + ? { hooksRelativePath: codexCompositePaths.hooks, mcpRelativePath: codexCompositePaths.mcp, sharedCopyEntries: false } + : {}), + targetName: 'codex', + })); + } + if (has('portable')) sides.push(planPortableView(model)); + const cursorSide = has('cursor') ? planCursorSide(model, hosts, root, targetSourceInputs) : undefined; + for (const side of sides) diagnostics.push(...side.diagnostics); + if (cursorSide !== undefined) diagnostics.push(...cursorSide.diagnostics); + + const entries = mergeEntries(diagnostics, ...sides.map((side) => side.entries), cursorSide?.entries ?? []); + const hookEntries = mergeHookEntries(diagnostics, ...sides.map((side) => side.hookEntries ?? []), cursorSide?.hookEntries ?? []); + + 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: entries.some((entry) => entry.relativePath.startsWith('commands/')), + hosts, + lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), + outputStyles: entries.some((entry) => entry.relativePath.startsWith('output-styles/')), + rules: entries.some((entry) => entry.relativePath.startsWith('rules/')), + 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 withInstallSurface(Object.freeze({ + diagnostics: Object.freeze(diagnostics), + entries: sortedEntries(entries), + hookEntries, + }), model, hosts as readonly BuiltInTarget[]); +}; + +/** The portable planner attaches its own install surface; the composite writes one for every host instead. */ +const withoutInstallSurface = (plan: TargetArtifactPlan): TargetArtifactPlan => Object.freeze({ + ...plan, + entries: plan.entries.filter((entry) => entry.relativePath !== 'INSTALL.md' && entry.relativePath !== 'install.mjs'), +}); + +const hostAdapters: Readonly> = Object.freeze({ + claude: claudeAdapter, + codex: codexAdapter, + cursor: cursorAdapter, + portable: portableAdapter, +}); + +const prefixedSchemas = >( + prefix: string, + schemas: readonly Schema[], +): readonly Schema[] => schemas.map((schema) => Object.freeze({ ...schema, name: `${prefix}-${schema.name}` })); + +const requireValidation = (host: CompositeHost): TargetArtifactValidationContract => { + const validation = hostAdapters[host].artifactValidation; + if (validation === undefined) throw new Error(`Composite plugin root requires the ${host} artifact validation contract.`); + return validation; +}; + +/** + * The document a host reads at one composite root, after relocation: Codex's + * hooks and MCP documents move under `.codex-plugin/` beside Claude Code, and + * Cursor's hooks document moves to `hooks/hooks-cursor.json` beside any host. + */ +export const compositeDocumentPath = (hosts: readonly string[], host: CompositeHost, path: string): string => { + const selected = sortedCompositeHosts(hosts); + if (selected.length < 2) return path; + const hostRoot = compositeHostRoot(selected, host); + if (hostRoot !== '') return `${hostRoot}/${path}`; + if (host === 'codex' && selected.includes('claude')) { + if (path === codexArtifactPaths.hooksManifest) return codexCompositePaths.hooks; + if (path === codexArtifactPaths.mcp) return codexCompositePaths.mcp; + } + if (host === 'cursor' && path === cursorArtifactPaths.hooks) return cursorCompositePaths.hooks; + return path; +}; + +/** + * The hook contract one host follows inside a root: its own contract, with + * the document path the composition relocated it to. Cursor's composite + * contract also swaps to the dedicated `.cursor.mjs` wrappers. + */ +export const compositeHookContract = (hosts: readonly string[], host: string): TargetHookContract | undefined => { + if (!isCompositeHost(host)) return undefined; + const selected = sortedCompositeHosts(hosts); + const contract = hostAdapters[host].hookContract; + if (contract === undefined || selected.length < 2) return contract; + if (host === 'cursor') return cursorCompositeHookContract; + const manifestPath = compositeDocumentPath(selected, host, contract.manifestPath); + return manifestPath === contract.manifestPath ? contract : Object.freeze({ ...contract, manifestPath }); +}; + +/** + * The MCP runtime contract one host follows inside a root: its own, with the + * document path the composition relocated it to. + */ +export const compositeMcpRuntime = (hosts: readonly string[], host: string): TargetMcpRuntimeContract | undefined => { + if (!isCompositeHost(host)) return undefined; + const runtime = hostAdapters[host].mcpRuntime; + if (runtime === undefined) return undefined; + const manifestPath = compositeDocumentPath(hosts, host, runtime.manifestPath); + return manifestPath === runtime.manifestPath ? runtime : Object.freeze({ ...runtime, manifestPath }); +}; + +const compositeValidation = (hosts: readonly CompositeHost[]): TargetArtifactValidationContract => deepFreeze({ + documents: hosts.flatMap((host): TargetArtifactValidationContract['documents'] => { + const validation = requireValidation(host); + // The Cursor manifest is skipped (not failed) when the plugin name is not + // Cursor-safe, so its documents are optional at a composite root. + return validation.documents.map((document) => Object.freeze({ + ...document, + path: compositeDocumentPath(hosts, host, document.path), + required: host === 'cursor' ? false : document.required, + schema: `${host}-${document.schema}`, + })); + }), + schemas: hosts.flatMap((host) => { + const validation = requireValidation(host); + if (host === 'codex' && hosts.includes('claude')) { + // The Codex manifest points at the relocated MCP document, so its + // validator widens the pinned pointer to that relocation. + return [ + ...prefixedSchemas(host, validation.schemas.filter((schema) => schema.name !== 'plugin')), + Object.freeze({ name: 'codex-plugin', validate: codexPluginDocumentValidator(codexCompositePaths.mcp) }), + ]; + } + if (host === 'cursor') { + return [ + 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) }), + ]; + } + return prefixedSchemas(host, validation.schemas); + }), +}); + +const compositeMetadata = (hosts: readonly CompositeHost[]): TargetAdapterMetadata => Object.freeze({ + adapterRevision: '2.0.0', + observedVersion: hosts.map((host) => hostAdapters[host].metadata.observedVersion).join('+'), + schemas: Object.freeze(hosts.flatMap((host) => prefixedSchemas(host, hostAdapters[host].metadata.schemas))), +}); + +const compositeLayout = (hosts: readonly CompositeHost[]): TargetArtifactLayout => { + const has = (host: CompositeHost): boolean => hosts.includes(host); + return Object.freeze({ + assets: standardArtifactLayout.assets, + ...(has('claude') ? { bin: 'bin' } : {}), + cliBin: standardArtifactLayout.cliBin, + ...(has('claude') || has('cursor') + ? { commands: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands' }) } + : {}), + hookWrappers: standardArtifactLayout.hookWrappers, + mcpApps: standardArtifactLayout.mcpApps, + mcpEntries: standardArtifactLayout.mcpEntries, + ...(has('claude') ? { outputStyles: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'output-styles' }) } : {}), + rootDocuments: Object.freeze(['AGENTS.md', ...(standardArtifactLayout.rootDocuments ?? [])]), + ...(has('cursor') ? { rules: Object.freeze({ allowedSuffixes: Object.freeze(['.mdc']), directory: 'rules' }) } : {}), + scripts: standardArtifactLayout.scripts, + skills: standardArtifactLayout.skills, + ...(has('claude') ? { workflows: 'workflows' } : {}), + }); +}; + +/** Capability rows of the composite are the union of its hosts': the root emits a surface when any projection does. */ +const compositeCapabilities = (hosts: readonly CompositeHost[]): Readonly> => { + const names = [...new Set(hosts.flatMap((host) => Object.keys(hostAdapters[host].capabilities)))].sort((left, right) => left.localeCompare(right)); + return Object.freeze(Object.fromEntries(names.map((capability) => [ + capability, + hosts + .map((host) => hostAdapters[host].capabilities[capability]) + .filter((state): state is CapabilityState => state !== undefined) + .reduce((left, right) => unionCapabilityStates(left, right)), + ]))); +}; + +const composites = new Map(); + +/** + * The adapter that plans and validates a root projecting `names` (two or more + * hosts). Memoized per host set; a single host is its own adapter. + */ +export const createCompositeAdapter = (names: readonly string[]): TargetAdapter => { + const hosts = sortedCompositeHosts(names); + const unknown = [...new Set(names)].filter((name) => !isCompositeHost(name)); + if (unknown.length > 0) { + throw new Error( + `A plugin root composed of several targets projects built-in hosts only (${compositeHostNames.join(', ')}); ` + + `${JSON.stringify(unknown)} must be built one target per --output.`, + ); + } + if (hosts.length < 2) { + throw new Error(`A composite plugin root needs at least two host projections; got ${JSON.stringify(names)}.`); + } + const key = compositeTargetName(hosts); + const cached = composites.get(key); + if (cached !== undefined) return cached; + const primary = hosts[0]!; + const noticeDeliveries = hosts.flatMap((host) => hostAdapters[host].noticeDelivery === undefined ? [] : [hostAdapters[host].noticeDelivery!]); + const adapter: TargetAdapter = Object.freeze({ + artifactValidation: compositeValidation(hosts), + artifactLayout: compositeLayout(hosts), + capabilities: compositeCapabilities(hosts), + ...(compositeHookContract(hosts, primary) === undefined ? {} : { hookContract: compositeHookContract(hosts, primary)! }), + // Each side plans from its own config extension, so host-scoped + // declarations under those keys (for example `claude.lspServers`) are + // eligible for emission at this root. + lowersConfigExtensions: Object.freeze([...hosts]), + metadata: compositeMetadata(hosts), + ...(hostAdapters[primary].mcpRuntime === undefined ? {} : { mcpRuntime: hostAdapters[primary].mcpRuntime! }), + name: key, + // The generated MCP entry serves every projected host, so it may only + // wire the cross-request routes all of them advertise. + ...(noticeDeliveries.length === 0 + ? {} + : { noticeDelivery: noticeDeliveries.reduce((left, right) => intersectNoticeDeliveryAdvertisements(left, right)) }), + ...(hosts.includes('claude') + ? { + binSource: (config: Readonly) => config.claude?.bin, + outputStylesSource: (config: Readonly) => config.claude?.outputStyles, + workflowsSource: (config: Readonly) => config.claude?.workflows, + } + : {}), + plan: (model: NormalizedPlugin) => planComposite(model, hosts), + }); + composites.set(key, adapter); + return adapter; +}; diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index d9bcb2790..0c6e8aa04 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -16,8 +16,21 @@ import { deepFreeze } from '../core/freeze.ts'; export interface TargetHookWrapper { + /** + * The artifact identity the wrapper's event endpoint is named after; the + * generated MCP entry of the same root names its endpoint identically. It + * equals `target` for a single-host root and the composite name when the + * root hosts several projections (#555). + */ + readonly artifactTarget?: string; readonly event: NormalizedHookEvent; readonly hook: NormalizedHook; + /** + * Every host projection this one wrapper serves. Absent means exactly + * `target`; several hosts mean the wrapper detects the calling host at run + * time (the shared Claude/Codex document of a composite root, #555). + */ + readonly hosts?: readonly string[]; /** False when this wrapper is a host-document variant of an indexed hook rather than its canonical entry. */ readonly indexed?: false; readonly nativeEvent: string; @@ -27,6 +40,20 @@ export interface TargetHookWrapper { readonly target: string; } +/** Options a root composed of several host projections threads into hook planning (#555). */ +export interface HookPlanRootOptions { + /** The composite root's identity, shared with its generated MCP entry's event endpoint. */ + readonly artifactTarget?: string; + /** + * The provenance revision a wrapper serving several hosts records (the + * shared hosts' observed versions joined); a single-host wrapper keeps its + * host contract's own revision. + */ + readonly hostContractRevision?: string; + /** The hosts one planned wrapper may serve; a wrapper serves those of them its hook selects. */ + readonly hosts?: readonly string[]; +} + export interface TargetHookEntry extends TargetHookWrapper { /** Timeout projected into the native host's seconds unit. */ readonly timeout?: number; @@ -641,6 +668,29 @@ const standaloneEventRoute = (route: NonNullable): export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean => entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute); +/** + * How a wrapper shared by Claude Code and Codex learns which host invoked it. + * Codex exports `PLUGIN_ROOT` to plugin hook commands (and `CLAUDE_PLUGIN_ROOT` + * as an alias of the same path); Claude Code exports `CLAUDE_PLUGIN_ROOT` + * only. So `PLUGIN_ROOT` names Codex when it agrees with any + * `CLAUDE_PLUGIN_ROOT` present — one that disagrees is ambient shell state, + * not a host. The simulation harness pins a host through + * `AGENT_BUNDLE_HOOK_HOST`. The block is shared verbatim by the composite + * wrapper and the Universal codec (see the parity invariant on + * nativeHookWrapperSource). + */ +export const hostDetectionSource: readonly string[] = Object.freeze([ + // No new identifiers besides `declaredHost` and `target`: the block is + // spliced into a module whose own bindings (`pluginRoot`, ...) it must not shadow. + 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', + 'const target = declaredHost === "claude" || declaredHost === "codex"', + ' ? declaredHost', + ' : process.env.PLUGIN_ROOT !== undefined', + ' && (process.env.CLAUDE_PLUGIN_ROOT === undefined || process.env.CLAUDE_PLUGIN_ROOT === process.env.PLUGIN_ROOT)', + ' ? "codex"', + ' : "claude";', +]); + const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, @@ -653,15 +703,15 @@ 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'; + // A wrapper shared by several hosts (the composite root's one Claude-format + // document serves Claude Code and Codex) detects the calling host at run + // time; a single-host wrapper is its host. 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";', - ] + : (entry.hosts?.length ?? 1) > 1 + ? hostDetectionSource + : entry.hosts?.length === 1 + ? [`const target = ${JSON.stringify(entry.hosts[0])};`] : ['const target = artifactTarget;']; return [ // Only a wrapper that can render in-process needs the operator `.env` @@ -691,7 +741,7 @@ 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)};`, + `const artifactTarget = ${JSON.stringify(entry.artifactTarget ?? entry.target)};`, ...targetSource, `const runtimeMode = ${JSON.stringify(route.runtime)};`, `const fallbackMode = ${JSON.stringify(route.fallback)};`, @@ -1129,6 +1179,7 @@ export const planHooks = ( target: string, contract: TargetHookContract, concreteEventTarget?: string, + root: HookPlanRootOptions = {}, ): HookPlan => { const diagnostics: Diagnostic[] = []; const selected = model.hooks @@ -1184,9 +1235,14 @@ export const planHooks = ( : contract.documentEntry(entryInput); (groups[nativeEvent] ??= []).push(group); if (prebuilt) continue; + // The hosts of the composite root this hook actually selects: one wrapper + // serves them all, so it is host-detecting when there are several. + const hosts = root.hosts?.filter((host) => hook.targets.includes(host)); const wrapper: TargetHookWrapper = { + ...(root.artifactTarget === undefined ? {} : { artifactTarget: root.artifactTarget }), event: hook.event, hook, + ...(hosts === undefined ? {} : { hosts }), ...(contract.indexedWrappers === false ? { indexed: false as const } : {}), nativeEvent, ...(matcher === undefined ? {} : { nativeMatcher: matcher }), @@ -1200,7 +1256,7 @@ export const planHooks = ( ? contract.wrapperSource(wrapper) : eventRouteHookWrapperSource( wrapper, - contract.hostContractRevision ?? target, + ((hosts?.length ?? 1) > 1 ? root.hostContractRevision : undefined) ?? contract.hostContractRevision ?? target, concreteEventTarget, model.state?.lifetime === 'workspace-durable', ), @@ -1250,12 +1306,7 @@ export const nativeHookWrapperSource = ( // 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";', - ] + ? hostDetectionSource : [`const target = ${JSON.stringify(entry.target)};`]; return [ // The installed pack's operator `.env` layer (#469): the first import, so 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..21a977d25 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -20,7 +20,13 @@ 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 { + compositeHookContract, + compositeHostRoot, + compositeMcpRuntime, + compositeTargetName, + createCompositeAdapter, +} from './composite.ts'; import { routedCliBinLayout, type TargetAdapter, @@ -530,6 +536,7 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #nativeHookSources = new Map(); readonly #noticeDeliveries = new Map(); readonly #outputStylesSources = new Map(); + readonly #roots = new Map(); readonly #workflowsSources = new Map(); register(adapter: TargetAdapter, options: { readonly default?: boolean } = {}): this { @@ -775,6 +782,87 @@ export class TargetRegistry implements NormalizationTargetRegistry { defaultTargetNames(): readonly string[] { return Object.freeze([...this.#defaults]); } + + /** + * The contracts of one plugin root projecting `targetNames` (#555): the + * registered snapshots of a single target, or the composite that plans + * and validates several hosts in one root. Every name must be registered. + */ + root(targetNames: readonly string[]): ArtifactRootContracts { + const names = [...new Set(targetNames)]; + for (const name of names) { + if (!this.#adapters.has(name)) throw new Error(`Unknown target adapter "${name}".`); + } + if (names.length === 0) throw new Error('A plugin root projects at least one target.'); + if (names.length === 1) { + const name = names[0]!; + const hookContract = this.#hookContracts.get(name); + return Object.freeze({ + adapter: this.get(name), + artifactLayout: this.artifactLayout(name), + artifactValidation: this.artifactValidation(name), + ...(hookContract === undefined ? {} : { hookContract }), + hookContractFor: (host: string) => (host === name ? hookContract : undefined), + hostRoot: () => '', + ...(this.#mcpRuntimes.has(name) ? { mcpRuntime: this.#mcpRuntimes.get(name)! } : {}), + mcpRuntimeFor: (host: string) => (host === name ? this.#mcpRuntimes.get(name) : undefined), + metadata: this.metadata(name), + name, + ...(this.#noticeDeliveries.has(name) ? { noticeDelivery: this.#noticeDeliveries.get(name)! } : {}), + targets: Object.freeze([name]), + }); + } + const key = compositeTargetName(names); + const cached = this.#roots.get(key); + if (cached !== undefined) return cached; + // Only the built-in hosts compose into one root; an advanced registry's + // own adapters are built one target per output. + const adapter = createCompositeAdapter(names); + const hookContract = snapshotHookContract(adapter); + const mcpRuntime = snapshotMcpRuntime(adapter); + const noticeDelivery = snapshotNoticeDelivery(adapter); + const root: ArtifactRootContracts = Object.freeze({ + adapter, + artifactLayout: snapshotArtifactLayout(adapter, hookContract, mcpRuntime), + artifactValidation: snapshotArtifactValidation(adapter, snapshotMetadata(adapter.metadata)), + ...(hookContract === undefined ? {} : { hookContract }), + hookContractFor: (host: string) => compositeHookContract(names, host), + hostRoot: (host: string) => compositeHostRoot(names, host), + ...(mcpRuntime === undefined ? {} : { mcpRuntime }), + mcpRuntimeFor: (host: string) => compositeMcpRuntime(names, host), + metadata: snapshotMetadata(adapter.metadata), + name: key, + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + targets: Object.freeze([...names]), + }); + this.#roots.set(key, root); + return root; + } +} + +/** The contracts one plugin root follows as a whole; see TargetRegistry.root. */ +export interface ArtifactRootContracts { + readonly adapter: TargetAdapter; + readonly artifactLayout: TargetArtifactLayout; + readonly artifactValidation: TargetArtifactValidationContract; + /** The contract of the host owning the conventional hook document, when any host lowers hooks. */ + readonly hookContract?: TargetHookContract; + /** One host's hook contract inside this root, with the document path the composition relocated it to. */ + readonly hookContractFor: (host: string) => TargetHookContract | undefined; + /** + * The directory, relative to the root, one host reads as its plugin root: + * '' for every host at the root itself, `portable` for the Agent Plugins + * view of a composite root. + */ + readonly hostRoot: (host: string) => string; + readonly mcpRuntime?: TargetMcpRuntimeContract; + /** One host's MCP runtime contract inside this root, with the document path the composition relocated it to. */ + readonly mcpRuntimeFor: (host: string) => TargetMcpRuntimeContract | undefined; + readonly metadata: TargetAdapterMetadata; + /** The root's identity: the host name, or the composite name of several hosts. */ + readonly name: string; + readonly noticeDelivery?: NoticeDeliveryAdvertisement; + readonly targets: readonly string[]; } export const createDefaultRegistry = (): TargetRegistry => @@ -782,5 +870,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/types.ts b/packages/agent-bundle/src/adapters/types.ts index 732e94b26..3c3f91b00 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -298,17 +298,24 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): ), }); } - for (const skill of input.sharedCopyEntries === false ? [] : model.skills) { + // Shared copies (the skill document, its resources, assets, payloads) are + // emitted by one host of a composite root; a host's sidecars (Codex + // `agents/openai.yaml`) are its own files beside the shared document, so + // every host still emits those (#555). + const sharedCopies = input.sharedCopyEntries !== false; + for (const skill of model.skills) { if (!isSelected(skill.targets)) continue; const hostDocument = skill.hostDocuments?.[targetName]; const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; if (generatedSkill) { - entries.push({ - content: hostDocument.skillMarkdown, - kind: 'write', - relativePath: `skills/${skill.name}/SKILL.md`, - sourceInputs: sourceInputs(skill.source), - }); + if (sharedCopies) { + entries.push({ + content: hostDocument.skillMarkdown, + kind: 'write', + relativePath: `skills/${skill.name}/SKILL.md`, + sourceInputs: sourceInputs(skill.source), + }); + } for (const sidecar of hostDocument.sidecars) { if (sidecar.content === undefined) continue; entries.push({ @@ -318,7 +325,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): sourceInputs: sourceInputs(skill.source, sidecar.source), }); } - } else if (skill.markdown !== undefined) { + } else if (sharedCopies && skill.markdown !== undefined) { // A rendered skill's SKILL.md is compiled from its component module. entries.push({ content: skill.markdown, @@ -327,6 +334,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): sourceInputs: sourceInputs(skill.source), }); } + if (!sharedCopies) continue; const skipCopies = new Set(generatedSkill ? ['SKILL.md', ...hostDocument.sidecars.map((sidecar) => sidecar.relativePath)] : []); diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index ec95a201c..61eed03e5 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -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; @@ -765,10 +763,14 @@ export const validate = async (options: ValidateOptions): Promise target.name) + // Every projected host reads the same plugin root (#555); the portable + // projection beside other hosts is its namespaced `portable/` view. + const registry = registryFor(options); + const targetNames = validated.snapshot.manifest.targets.map((target) => target.name); + const root = registry.root(targetNames.filter((name) => registry.has(name))); + const reports = await Promise.all(targetNames .filter(isHostValidatedTarget) - .map((target) => hostValidationReport(target, join(artifact, target), options.strict))); + .map((target) => hostValidationReport(target, join(artifact, root.hostRoot(target)), options.strict))); return Object.freeze({ diagnostics: freezeDiagnostics([ ...validated.diagnostics, @@ -1071,11 +1073,16 @@ export const inspect = async (options: InspectOptions): Promise = let plans: readonly InspectionPlan[]; try { const components = inspectableComponents(model); + // Every selected target is projected into one root (#555): the entries + // and hook entries shown per target are the root's plan, while component + // accounting stays the host's own judgment. + const rootContracts = prepared.registry.root(model.targets.map((target) => target.name)); + const rootPlan = rootContracts.adapter.plan(model); plans = Object.freeze(model.targets .filter((candidate) => options.target === undefined || candidate.name === options.target) .map((target) => { const adapter = prepared.registry.get(target.name); - const plan = adapter.plan(model); + const plan = rootPlan; const accounted = accountComponentsFor( components, target.name, @@ -1104,18 +1111,19 @@ export const inspect = async (options: InspectOptions): Promise = let bundler: BundlerInspection | undefined; if (options.focus === 'bundler') { try { + // The bundler composes the one plugin root every target shares (#555). + const hosts = model.targets.map((target) => target.name); + const rootContracts = prepared.registry.root(hosts); bundler = await composeBundlerInspection({ 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 }), - }; - }), + root: { + cliBin: hosts.some((host) => targetHostsCliBin(prepared.registry, host)), + hookEntries: rootContracts.adapter.plan(model).hookEntries ?? [], + hosts, + name: rootContracts.name, + ...(rootContracts.noticeDelivery === undefined ? {} : { noticeDelivery: rootContracts.noticeDelivery }), + }, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); } catch { @@ -1237,7 +1245,7 @@ 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` @@ -1260,7 +1268,7 @@ const buildHostValidation = async ( continue; } 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..f11d94b77 100644 --- a/packages/agent-bundle/src/build/artifact-layout.ts +++ b/packages/agent-bundle/src/build/artifact-layout.ts @@ -19,11 +19,3 @@ export const isDirectOutputLayoutPath = ( 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/artifact-root.ts b/packages/agent-bundle/src/build/artifact-root.ts new file mode 100644 index 000000000..7edb287d3 --- /dev/null +++ b/packages/agent-bundle/src/build/artifact-root.ts @@ -0,0 +1,60 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { ArtifactRootContracts, TargetRegistry } from '../adapters/registry.ts'; +import { isErrno } from '../core/errors.ts'; +import { joinArtifact } from '../core/paths.ts'; +import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts'; +import { artifactManifestName } from './emit.ts'; +import { parseArtifactManifest } from './manifest.ts'; + +/** + * The root contracts of a built artifact (#555), derived from the targets its + * manifest declares; undefined when there is no manifest (a host's namespaced + * view, or a plain plugin directory) or it names no registered target. + * Callers validate the artifact before trusting the layout this describes. + */ +export const readArtifactRootContracts = async ( + artifactRoot: string, + registry: TargetRegistry, +): Promise => { + const targets = await readArtifactTargets(artifactRoot); + const known = targets?.filter((name) => registry.has(name)) ?? []; + return known.length === 0 ? undefined : registry.root(known); +}; + +/** + * The targets a built artifact's manifest declares — the hosts its one root + * projects — or undefined when there is no manifest at that root. + */ +export const readArtifactTargets = async (artifactRoot: string): Promise => { + let contents: string; + try { + contents = await readFile(join(artifactRoot, artifactManifestName), 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined; + throw error; + } + return parseArtifactManifest(contents).targets.map((target) => target.name); +}; + +/** + * The directory one host reads as its plugin root inside a built artifact: + * the root itself for every host except a namespaced view (`portable/` + * beside other hosts), and the root itself when no contracts are known. + */ +export const hostRootDirectory = ( + artifactRoot: string, + root: ArtifactRootContracts | undefined, + host: string, +): string => { + const hostRoot = root?.hostRoot(host) ?? ''; + return hostRoot === '' ? artifactRoot : joinArtifact(artifactRoot, hostRoot); +}; + +/** The MCP runtime contract one host follows inside a built artifact, with any relocated document path. */ +export const hostMcpRuntime = ( + root: ArtifactRootContracts | undefined, + registry: TargetRegistry, + host: string, +): TargetMcpRuntimeContract | undefined => root?.mcpRuntimeFor(host) ?? registry.mcpRuntime(host); diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index be26fff1f..c1c68cc68 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -3,7 +3,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 { ArtifactRootContracts, TargetRegistry } from '../adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from '../adapters/types.ts'; import { deduplicateDiagnostics, DiagnosticBag, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; @@ -57,8 +57,13 @@ export interface BuildResult { /** The routed-CLI executables emitted into host artifacts (#387), one per hosting target. */ readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; + /** + * One entry per emitted hook wrapper. `hosts` lists every host projection a + * wrapper serves; `target` is the host whose native format it follows (#555). + */ readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; + /** One entry per compiled MCP server; `target` is the root's identity (a host, or `claude+codex`), `hosts` the projections it serves. */ readonly compiledMcpEntries: readonly CompiledMcpEntry[]; readonly manifest: ArtifactManifest; readonly outputProvenance: readonly ArtifactOutputProvenance[]; @@ -75,15 +80,24 @@ export interface BuildOptions { readonly tools?: AgentBundleToolsConfig; } -interface PlannedTarget { - /** True when the target's adapter publishes the `cli` capability, admitting the routed CLI bin. */ +/** + * The one plugin root a build emits (#555): every selected target's projection + * planned into the same directory. A single target is its adapter's plan; two + * or more are composed by the registry's root contracts. + */ +interface PlannedRoot { + /** True when any projected host publishes the `cli` capability, admitting the routed CLI bin. */ readonly cliBin: boolean; + readonly contracts: ArtifactRootContracts; readonly entries: readonly TargetArtifactEntry[]; readonly hookEntries: readonly TargetHookEntry[]; + /** The root's identity: the host name, or the composite name of several hosts. */ readonly name: string; + /** The projected hosts, in the order the model selected them. */ + readonly targets: readonly string[]; } -interface StagedTarget extends PlannedTarget { +interface StagedRoot extends PlannedRoot { readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; @@ -152,73 +166,58 @@ const prebuiltPayloadDiagnostics = ( return diagnostics; }; -const planTargets = (options: BuildOptions): readonly PlannedTarget[] => { +const planRoot = (options: BuildOptions): PlannedRoot => { 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 targets = options.model.targets.map((target) => target.name); + const contracts = options.registry.root(targets); + const plan = contracts.adapter.plan(options.model); + diagnostics.push(...plan.diagnostics); + const hookEntries = plan.hookEntries ?? Object.freeze([]); + for (const hookEntry of hookEntries) { + const served = hookEntry.hosts ?? [hookEntry.target]; + const foreign = served.filter((host) => !targets.includes(host)); + if (foreign.length > 0) { + diagnostics.push({ + code: 'AB5000', + message: `Plugin root ${JSON.stringify(contracts.name)} planned hook ${JSON.stringify(hookEntry.hook.id)} for ${JSON.stringify(foreign)}, which it does not project.`, + severity: 'error', + }); } - 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, - }); } + const cliBin = targets.some((target) => targetHostsCliBin(options.registry, target)); + if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(options.model, contracts.name, plan.entries)); new DiagnosticBag(deduplicateDiagnostics(diagnostics)).throwIfErrors(); - return planned; + return Object.freeze({ cliBin, contracts, entries: plan.entries, hookEntries, name: contracts.name, targets }); }; -const planStagedTargets = (options: { +const planStagedRoot = (options: { readonly artifactRoot: string; readonly model: NormalizedPlugin; + readonly planned: PlannedRoot; 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)); +}): StagedRoot => { + const { planned } = options; + const root = options.artifactRoot; + const selection = { hosts: planned.targets, target: planned.name }; + const scripts = options.model.scripts.filter((script) => script.targets.some((target) => planned.targets.includes(target))); const compiledEntries = planCompiledEntries(scripts, { cwd: options.projectRoot, outDir: root }); - const compiledHooks = planCompiledHooks(target.hookEntries, { outDir: root }); - const compiledMcpApps = planCompiledMcpApps(options.model.mcpApps ?? [], { - outDir: root, - target: target.name, - }); - const compiledMcpEntries = planCompiledMcpEntries(options.model.mcpServers, { - outDir: root, - target: target.name, - }); - const compiledCliBins = target.cliBin - ? planCompiledCliBins(options.model, { outDir: root, target: target.name }) + const compiledHooks = planCompiledHooks(planned.hookEntries, { outDir: root }); + const compiledMcpApps = planCompiledMcpApps(options.model.mcpApps ?? [], { outDir: root, ...selection }); + const compiledMcpEntries = planCompiledMcpEntries(options.model.mcpServers, { outDir: root, ...selection }); + const compiledCliBins = planned.cliBin + ? planCompiledCliBins(options.model, { outDir: root, target: planned.name }) : Object.freeze([]); - return { ...target, compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; -}); + return { ...planned, 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 = (staged: StagedRoot): readonly string[] => [ + ...staged.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, @@ -239,15 +238,15 @@ const outputCandidatesFor = (options: { readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; readonly model: NormalizedPlugin; - readonly targets: readonly StagedTarget[]; + readonly staged: StagedRoot; }): readonly ArtifactOutputCandidate[] => [ - ...options.targets.flatMap((target) => target.entries.map((entry) => ({ + ...options.staged.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.staged.root, entry.relativePath), sourceInputs: entry.sourceInputs, - }))), + })), ...options.compiledCliBins.flatMap((entry) => [{ kind: 'bundle' as const, path: entry.output, @@ -310,11 +309,12 @@ const assertOutputProvenanceSources = (options: { } }; +/** The manifest names the real host projections of the root, never the composite identity. */ const manifestTargets = ( registry: TargetRegistry, - targets: readonly StagedTarget[], + targets: readonly string[], ): ArtifactManifest['targets'] => Object.freeze(targets - .map(({ name }) => { + .map((name) => { const metadata = registry.metadata(name); return Object.freeze({ adapterRevision: metadata.adapterRevision, @@ -332,7 +332,7 @@ const manifestFor = (options: { readonly model: NormalizedPlugin; readonly projectContext: ProjectContext; readonly registry: TargetRegistry; - readonly targets: readonly StagedTarget[]; + readonly targets: readonly string[]; }): ArtifactManifest => { const targets = manifestTargets(options.registry, options.targets); return { @@ -354,14 +354,14 @@ 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({ + const planned = planRoot(options); + const preflight = planStagedRoot({ artifactRoot: outputRoot, model: options.model, + planned, projectRoot: options.projectRoot, - targets: planned, }); - assertUniqueArtifactDestinations(plannedDestinations(preflightTargets)); + assertUniqueArtifactDestinations(plannedDestinations(preflight)); const stageParent = dirname(outputRoot); await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); @@ -369,13 +369,13 @@ export const build = async (options: BuildOptions): Promise => { assertInside(outputRoot, resolve(outputRoot, relative(stageRoot, entry.output))); try { - const stagedTargets = planStagedTargets({ + const staged = planStagedRoot({ artifactRoot: stageRoot, model: options.model, + planned, projectRoot: options.projectRoot, - targets: planned, }); - assertUniqueArtifactDestinations(plannedDestinations(stagedTargets)); + assertUniqueArtifactDestinations(plannedDestinations(staged)); const compiledCliBins: CompiledCliBin[] = []; const compiledEntries: CompiledEntry[] = []; @@ -391,81 +391,82 @@ 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), + const selection = { hosts: staged.targets, target: staged.name }; + let rootMcpApps: readonly CompiledMcpApp[] = Object.freeze([]); + for (const stage of planTargetStages(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. + rootMcpApps = await compileMcpApps(options.model.mcpApps ?? [], { + cwd: options.projectRoot, + meta, + outDir: staged.root, + ...selection, + ...tools, + }); + compiledMcpApps.push(...rootMcpApps); + break; + case 'node-surfaces': { + await emitPlanEntries({ entries: staged.entries, root: staged.root }); + // The generated entries serve every projected host, so they wire + // only the cross-request routes all of them advertise. + const noticeDelivery = staged.contracts.noticeDelivery; + // 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: staged.root, ...tools }, + [ + staged.cliBin + ? planCliBinsSurface(options.model, { outDir: staged.root, target: staged.name }) + : settledRslibSurface(Object.freeze([])), + await planScriptsSurface( + options.model.scripts.filter((script) => script.targets.some((target) => staged.targets.includes(target))), + { + cwd: options.projectRoot, layouts: options.model.layouts ?? [], - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + outDir: staged.root, ...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(staged.hookEntries, { + artifactEpoch: options.projectContext.revision, + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...noticePolicy, + outDir: staged.root, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + }), + await planMcpEntriesSurface(options.model.mcpServers, { + apps: rootMcpApps, + artifactEpoch: options.projectContext.revision, + eventHooks: staged.hookEntries + .filter((entry) => entry.hook.eventRoute !== undefined) + .map((entry) => entry.hook), + layouts: options.model.layouts ?? [], + ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + ...noticePolicy, + outDir: staged.root, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + ...selection, + }), + ], + ); + 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)}.`); } } } @@ -478,16 +479,19 @@ export const build = async (options: BuildOptions): Promise => { 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 }), - })), + // keeps exactly one entry per hook and host projection, pointing at the + // canonical wrapper that host's contract simulates. One wrapper shared + // by several hosts is listed once per host it serves (#555). + hooks: compiledHooks + .filter((entry) => entry.indexed !== false) + .flatMap((entry) => (entry.hosts ?? [entry.target]).map((target) => ({ + event: entry.event, + id: entry.id, + name: entry.name, + path: relative(stageRoot, entry.output).replaceAll('\\', '/'), + target, + ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), + }))), }); const outputProvenance = createOutputProvenance({ artifactRoot: stageRoot, @@ -499,7 +503,7 @@ export const build = async (options: BuildOptions): Promise => { compiledMcpApps, compiledMcpEntries, model: options.model, - targets: stagedTargets, + staged, }), projectRoot: options.projectRoot, }); @@ -532,7 +536,7 @@ export const build = async (options: BuildOptions): Promise => { model: options.model, projectContext: options.projectContext, registry: options.registry, - targets: stagedTargets, + targets: staged.targets, }), }); const diagnostics = await validateArtifact({ artifactRoot: stageRoot, bundleSyntaxCheck, registry: options.registry }); diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 093faf16d..f1d2b9e6e 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -104,21 +104,45 @@ interface PlannedScriptEntry extends CompiledEntry { export interface CompiledHookEntry extends CompiledEntry { readonly event: TargetHookEntry['event']; + /** + * Every host projection this one wrapper serves; absent means exactly + * `target` (#555). Authoritative for "which hosts run this file": a wrapper + * shared by Claude Code and Codex lists both here. + */ + readonly hosts?: readonly string[]; readonly id: string; /** False when this wrapper is a host-document variant excluded from the canonical hook index. */ readonly indexed?: false; + /** + * The host whose native hook format the wrapper follows (the document that + * names it) — one host even when `hosts` lists several. Unlike + * `CompiledMcpEntry.target`, never the composite root's name. + */ readonly target: string; /** Native hook timeout in seconds. Omit it to use the host default. */ readonly timeout?: number; } export interface CompiledMcpEntry extends CompiledEntry { + /** The host projections this entry serves; absent means exactly `target` (#555). */ + readonly hosts?: readonly string[]; readonly id: string; readonly workerOutput?: string; readonly workerSourceInputs?: readonly string[]; + /** The artifact identity (a host, or the composite name of several hosts). */ readonly target: string; } +/** Selection of the declarations one plugin root compiles: its identity plus the hosts it projects. */ +export interface CompiledRootSelection { + /** The hosts whose declarations are compiled; defaults to `[target]`. */ + readonly hosts?: readonly string[]; + readonly target: string; +} + +export const selectsRoot = (targets: readonly string[], selection: CompiledRootSelection): boolean => + (selection.hosts ?? [selection.target]).some((host) => targets.includes(host)); + const outputName = (script: NormalizedScript): string => script.mode === 'bundle' ? `${script.name}.mjs` : `${script.name}${extname(script.source).toLowerCase()}`; @@ -287,7 +311,8 @@ export const planScriptsSurface = async ( }; }; -const localMcpOutputName = (server: NormalizedMcpServer): string => { +/** The compiled `mcp/.mjs` file name a local MCP server's alias names. */ +export const localMcpOutputName = (server: NormalizedMcpServer): string => { const output = server.args?.[0]; const match = typeof output === 'string' ? mcpEntryAliasPattern.exec(output) @@ -300,11 +325,11 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { export const planCompiledMcpEntries = ( servers: readonly NormalizedMcpServer[], - options: { readonly outDir: string; readonly target: string }, + options: { readonly outDir: string } & CompiledRootSelection, ): readonly CompiledMcpEntry[] => { const names = new Set(); return Object.freeze(servers - .filter((server) => server.source !== undefined && server.targets.includes(options.target)) + .filter((server) => server.source !== undefined && selectsRoot(server.targets, options)) .map((server) => { const outputName = localMcpOutputName(server); const name = outputName.slice(0, -extname(outputName).length); @@ -318,6 +343,7 @@ export const planCompiledMcpEntries = ( ...(server.generatedRoutes ?? []).map((route) => route.source), ])]); return Object.freeze({ + ...(options.hosts === undefined ? {} : { hosts: options.hosts }), id: server.id, name, output: resolveArtifactDestination(resolve(options.outDir, 'mcp'), outputName), @@ -353,8 +379,7 @@ export const planMcpEntriesSurface = async ( readonly providers?: readonly CompiledProvider[]; readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - readonly target: string; - }, + } & CompiledRootSelection, ): Promise> => { const compiled = planCompiledMcpEntries(servers, options); const eventHostId = compiled.find((entry) => @@ -391,6 +416,7 @@ export const planMcpEntriesSurface = async ( ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), target: options.target, + ...(options.hosts === undefined ? {} : { eventTargets: options.hosts }), workerFile: `${entry.name}-flight.mjs`, }); }); @@ -530,6 +556,7 @@ export const planCompiledHooks = ( .flatMap((entry) => [entry.hook.provenance.sourcePath, entry.hook.source]))]); return deepFreeze(entries.map((entry, index) => ({ event: entry.event, + ...(entry.hosts === undefined ? {} : { hosts: entry.hosts }), id: entry.hook.id, ...(entry.indexed === false ? { indexed: false as const } : {}), name: entry.hook.name, diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 014ece222..476b3dda9 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -741,7 +741,14 @@ export interface GeneratedRouteMcpEntryOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; + /** + * The artifact identity the event endpoint is named after (the host, or the + * composite name of a root projecting several hosts, #555); hook wrappers + * of the same root name it identically. + */ readonly target?: string; + /** The hosts whose hook wrappers may reach this entry's event endpoint; defaults to `[target]`. */ + readonly eventTargets?: readonly string[]; readonly workerFile: string; } @@ -1101,9 +1108,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 = options.eventTargets ?? [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/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 4225f69f9..41b46b0a5 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -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 (the plugin root) 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 plugin root's identity: the host name, or the composite name of several hosts (#555). */ readonly target?: string; } @@ -71,7 +72,19 @@ export interface BundlerInspection { export const generatedDtsTsconfigToken = ''; -const artifactOutputToken = (target: string): string => `/${target}`; +/** Every selected target reads the one plugin root (#555), so the output token names it alone. */ +const artifactOutputToken = ''; + +/** The plugin root the bundler inspection composes for: its identity plus the hosts it projects. */ +export interface BundlerInspectionRoot { + /** True when a projected host hosts the routed CLI bin (its adapter publishes the `cli` capability). */ + readonly cliBin?: boolean; + readonly hookEntries: readonly TargetHookEntry[]; + /** The projected hosts, as the model selected them. */ + readonly hosts: readonly string[]; + readonly name: string; + readonly noticeDelivery?: NoticeDeliveryAdvertisement; +} const isPlainObject: (value: object) => boolean = isPlainRecord; @@ -134,13 +147,14 @@ const rslibInspectionEntry = (options: { const scriptEntries = async ( model: NormalizedPlugin, projectRoot: string, - target: string, + root: BundlerInspectionRoot, tools: AgentBundleToolsConfig | undefined, ): Promise => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = root.name; const scripts = model.scripts.filter((script) => - script.mode === 'bundle' && script.targets.includes(target)); + script.mode === 'bundle' && script.targets.some((host) => root.hosts.includes(host))); return Promise.all(scripts.map(async (script) => { const exports = await scanEntryExports(script.source); return rslibInspectionEntry({ @@ -163,7 +177,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,22 +187,23 @@ 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 root (#387), composed by the build's own planner. */ const cliBinEntries = ( model: NormalizedPlugin, projectRoot: string, - target: string, + root: BundlerInspectionRoot, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = root.name; 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 +215,14 @@ const cliBinEntries = ( const mcpEntryEntries = async ( model: NormalizedPlugin, projectRoot: string, - target: string, + root: BundlerInspectionRoot, 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 = root.name; + const noticeDelivery = root.noticeDelivery; + const planned = planCompiledMcpEntries(model.mcpServers, { hosts: root.hosts, outDir: outputRoot, target }); const entries: BundlerInspectionEntry[] = []; for (const entry of planned) { const server = model.mcpServers.find((candidate) => candidate.id === entry.id); @@ -223,6 +239,8 @@ const mcpEntryEntries = async ( routes: generatedRoutes, serverName, ...(model.state === undefined ? {} : { state: model.state }), + target, + eventTargets: root.hosts, workerFile, }); entries.push(rslibInspectionEntry({ @@ -263,7 +281,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 +311,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 +330,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 +344,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 +356,13 @@ const hookEntries = ( const mcpAppsEntry = ( model: NormalizedPlugin, projectRoot: string, - target: string, + root: BundlerInspectionRoot, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = root.name; const apps = model.mcpApps ?? []; - const planned = planCompiledMcpApps(apps, { outDir: outputRoot, target }); + const planned = planCompiledMcpApps(apps, { hosts: root.hosts, outDir: outputRoot, target }); if (planned.length === 0) return []; const sources = planned.map((app) => { const source = apps.find((candidate) => candidate.id === app.id); @@ -362,7 +381,7 @@ const mcpAppsEntry = ( })), kind: 'mcp-apps' as const, name: 'mcp-apps', - outputPath: `${target}/mcp-apps`, + outputPath: 'mcp-apps', target, })]; }; @@ -402,26 +421,20 @@ export const composeBundlerInspection = async (options: { 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; - }[]; + /** The one plugin root the build composes (#555). */ + readonly root: BundlerInspectionRoot; 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), - ); - } + const { root } = options; + entries.push( + ...(root.cliBin === true ? cliBinEntries(options.model, options.projectRoot, root, options.tools) : []), + ...(await scriptEntries(options.model, options.projectRoot, root, options.tools)), + ...(await mcpEntryEntries(options.model, options.projectRoot, root, options.tools)), + ...hookEntries(root.hookEntries, meta, options.projectRoot, root.name, options.tools), + ...mcpAppsEntry(options.model, options.projectRoot, root, options.tools), + ); entries.push(...(await packageBuildEntries(options.model, options.projectRoot, options.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..994b0be69 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -113,14 +113,18 @@ const appIdentity = (app: NormalizedMcpApp): string => stableJson({ }); export type McpAppTargetSelection = - | Readonly<{ readonly target: string; readonly targets?: never }> - | Readonly<{ readonly target?: never; readonly targets: Readonly> }>; + /** One root: its identity, and the hosts it projects (defaults to the identity itself, #555). */ + | Readonly<{ readonly hosts?: readonly string[]; readonly target: string; readonly targets?: never }> + | Readonly<{ readonly hosts?: never; readonly target?: never; readonly targets: Readonly> }>; const selectedAppTarget = ( app: NormalizedMcpApp, selection: McpAppTargetSelection, ): string | undefined => { - const target = selection.target ?? selection.targets[app.id]; + if (selection.target !== undefined) { + return (selection.hosts ?? [selection.target]).some((host) => app.targets.includes(host)) ? selection.target : undefined; + } + const target = selection.targets[app.id]; return target !== undefined && app.targets.includes(target) ? target : undefined; }; @@ -250,7 +254,9 @@ export const compileMcpApps = async ( ): Promise => { const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, - ...(options.target === undefined ? { targets: options.targets } : { target: options.target }), + ...(options.target === undefined + ? { targets: options.targets } + : { target: options.target, ...(options.hosts === undefined ? {} : { hosts: options.hosts }) }), }); 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 f5cb4bd9e..1ee6ed92a 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -8,6 +8,7 @@ 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 { compositeHostRoot } from '../adapters/composite.ts'; import { installSurfaceRequirements } from '../install/surface.ts'; import { artifactManifestName } from './emit.ts'; import { parseArtifactManifest } from './manifest.ts'; @@ -112,12 +113,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: @@ -261,8 +256,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}`)), + // The install surface lives at the one plugin root every target shares (#555). + ...installSurfaceRequirements(manifest.targets.map((target) => target.name)).map((path) => `${artifactPrefix}/${path}`), 'README.md', ]); @@ -308,11 +303,15 @@ export const packInventoryDiagnostics = async (options: { ['normalized plugin', options.model.metadata.version], ['artifact provenance', manifest.project.packageVersion], ]; + // Every host manifest lives at the one plugin root (#555); the portable + // projection beside other hosts is its namespaced `portable/` view. + const targetNames = manifest.targets.map((target) => target.name); for (const target of manifest.targets) { for (const path of hostManifestPaths(target.name)) { - const absolute = join(artifactRoot, target.name, path); + const relativePath = `${compositeHostRoot(targetNames, target.name)}${compositeHostRoot(targetNames, target.name) === '' ? '' : '/'}${path}`; + const absolute = join(artifactRoot, relativePath); if (await exists(absolute)) { - versions.push([`${target.name}/${path}`, (await jsonRecord(absolute)).version]); + versions.push([relativePath, (await jsonRecord(absolute)).version]); } } } 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/build/validate-artifact-hooks.ts b/packages/agent-bundle/src/build/validate-artifact-hooks.ts index cd2e944fa..a2d37f5c4 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, @@ -51,6 +51,10 @@ export const validateHookCoherence = 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 targets = new Set(options.manifest.targets.map((target) => target.name)); + // One root projects every declared host (#555): wrapper layout and each + // host's (possibly relocated) hook document come from the root contracts. + const rootTargets = [...targets].filter((target) => options.registry.has(target)); + const root = rootTargets.length === 0 ? undefined : options.registry.root(rootTargets); const indexedByTarget = new Map(); for (const hook of index.hooks) { const entries = indexedByTarget.get(hook.target) ?? []; @@ -67,16 +71,14 @@ export const validateHookCoherence = async (options: { )); continue; } - 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}/`; + if (!options.registry.has(hook.target) || root === undefined) continue; + const contract = root.hookContractFor(hook.target); + const layout = root.artifactLayout.hookWrappers; 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) @@ -91,11 +93,11 @@ export const validateHookCoherence = async (options: { } for (const { name: target } of options.manifest.targets) { - if (!options.registry.has(target) || !options.registry.supports(target, 'hooks')) continue; - const contract = options.registry.hookContract(target); + if (!options.registry.has(target) || !options.registry.supports(target, 'hooks') || root === undefined) continue; + const contract = root.hookContractFor(target); if (contract === undefined) continue; const hooks = indexedByTarget.get(target) ?? []; - const manifestPath = targetArtifactPath(target, contract.manifestPath); + const manifestPath = contract.manifestPath; if (!files.has(manifestPath)) { if (hooks.length === 0) continue; diagnostics.push(diagnostic( @@ -130,7 +132,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; @@ -143,7 +145,7 @@ export const validateHookCoherence = async (options: { )); } } - const wrapperLayout = options.registry.artifactLayout(target).hookWrappers; + const wrapperLayout = root.artifactLayout.hookWrappers; for (const command of commands.commands) { const relativePath = compilerHookWrapperPath(contract, command.command); if (relativePath === undefined) continue; @@ -151,7 +153,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..1a1a287e3 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,7 @@ export const manifestLogoPathDiagnostics = (options: { options.target, )]); } - const artifactPath = targetArtifactPath(options.target, relativePath); + 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..1927f4d08 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[] = []; @@ -125,16 +125,33 @@ export const validateMcpCoherence = async (options: { const manifestFiles = new Map(options.manifest.files.map((file) => [file.path, file])); const artifactRoot = resolve(options.artifactRoot); + // One root projects every declared host (#555): each host's MCP document + // (possibly relocated by the composition) references the shared `mcp/`. + const rootTargets = options.manifest.targets.map((target) => target.name).filter((target) => options.registry.has(target)); + const root = rootTargets.length === 0 ? undefined : options.registry.root(rootTargets); + // A compiled server must be referenced by at least one projected host's + // document (a server declared for one host is compiled once at the root), + // and by any one document at most once. + const rootReferences = new Map(); + const rootMcpEntries = root === undefined + ? [] + : options.files.filter((file) => isDirectOutputLayoutPath(file.path, root.artifactLayout.mcpEntries)); + for (const file of rootMcpEntries) rootReferences.set(file.path, 0); + let rootReferencingTarget: string | undefined; 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 (root === undefined || !options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue; + const runtime = root.mcpRuntimeFor(target.name); if (runtime === undefined) continue; - const manifestPath = targetArtifactPath(target.name, runtime.manifestPath); - const targetRoot = resolve(artifactRoot, target.name); - const mcpLayout = options.registry.artifactLayout(target.name).mcpEntries; + const manifestPath = runtime.manifestPath; + // A host projected as a namespaced view (`portable/`) resolves `${PLUGIN_ROOT}` + // to that view; every other host resolves it to the root itself. + const hostRoot = root.hostRoot(target.name); + const targetRoot = hostRoot === '' ? artifactRoot : resolve(artifactRoot, hostRoot); 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, []); + if (hostRoot === '') { + rootReferencingTarget ??= target.name; + for (const file of rootMcpEntries) referenceCounts.set(file.path, []); + } const manifestFile = files.get(manifestPath); if (manifestFile !== undefined) { @@ -233,7 +250,7 @@ export const validateMcpCoherence = async (options: { value: server.command, }); if (commandReference.status === 'artifact-local') { - const path = targetArtifactPath(target.name, commandReference.path); + const path = commandReference.path; recordMcpReference(referenceCounts, path, { field: 'command', server: entry.name }); entryPaths.add(path); } @@ -257,7 +274,7 @@ export const validateMcpCoherence = async (options: { value: argument, }); if (argumentReference.status === 'artifact-local') { - const path = targetArtifactPath(target.name, argumentReference.path); + const path = argumentReference.path; recordMcpReference(referenceCounts, path, { field: 'argument', server: entry.name }); entryPaths.add(path); } @@ -275,24 +292,31 @@ 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 (rootReferences.has(path)) rootReferences.set(path, rootReferences.get(path)! + occurrences.length); + 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, occurrences] of rootReferences) { + if (occurrences > 0) continue; + if (path.endsWith('-flight.mjs')) { + const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs'; + if ((rootReferences.get(mainPath) ?? 0) > 0) { + 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 in any projected host document.`, + path, + rootReferencingTarget, + )); + } 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..1034d092e 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -16,47 +16,60 @@ 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 host projections of the root, as the manifest names them. */ +export const manifestTargetNames = (manifest: ArtifactManifest): readonly string[] => + Object.freeze(manifest.targets.map((target) => target.name)); interface EmittedSkill { readonly name: string; readonly path: string; readonly root: string; - readonly target: string; + /** The hosts reading this document: every root host, or the one host whose namespaced view holds it. */ + readonly targets: readonly string[]; +} + +/** A host whose projection is a namespaced view (`portable/`) of a composite root, with its own skills/. */ +interface SkillView { + readonly host: string; + readonly prefix: string; + readonly skillLayout: string | undefined; } +/** + * One plugin root holds one `skills//SKILL.md` per skill (#555), read by + * every projected host, plus one per skill in each host's namespaced view; + * the layouts come from the root's contracts. + */ const emittedSkillFor = ( file: ArtifactFile, - targets: ReadonlySet, - registry: TargetRegistry, + skillLayout: string | undefined, + rootTargets: readonly string[], + views: readonly SkillView[], ): 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 - ) { + const view = views.find((candidate) => file.path.startsWith(candidate.prefix)); + const layoutName = view === undefined ? skillLayout : view.skillLayout; + if (layoutName === undefined) return undefined; + const relativePath = view === undefined ? file.path : file.path.slice(view.prefix.length); + const segments = relativePath.split('/'); + const [layout, name, document] = segments; + if (layout !== layoutName || name === undefined || document !== 'SKILL.md' || segments.length !== 3) { return undefined; } - if (skillLayout === undefined) return undefined; return { name, path: file.path, - root: `${target}/${skillLayout}/${name}`, - target, + root: `${view?.prefix ?? ''}${layoutName}/${name}`, + targets: view === undefined ? rootTargets : [view.host], }; }; +/** The frontmatter contract a host applies to a shared skill document. */ +const frontmatterIssuesFor = (target: string, frontmatter: Readonly>) => target === 'claude' + ? validateClaudeSkillFrontmatter(frontmatter) + : target === 'cursor' + ? validateCursorSkillFrontmatter(frontmatter) + : validateAgentSkillsFrontmatter(frontmatter); + const isSkillRootEscape = (reference: string): boolean => reference === '..' || reference.startsWith('../') || reference.startsWith('/'); @@ -69,42 +82,55 @@ export const validateEmittedSkills = async (options: { readonly registry: TargetRegistry; }): Promise => { const diagnostics: Diagnostic[] = []; - const targets = targetNamespaces(options.manifest); + const targets = manifestTargetNames(options.manifest).filter((target) => options.registry.has(target)); + if (targets.length === 0) return Object.freeze(diagnostics); + const root = options.registry.root(targets); + // A single-host root's layout diagnostics name that host; a composite's name none. + const rootTarget = targets.length === 1 ? targets[0] : undefined; + const skillLayout = root.artifactLayout.skills; + const views: readonly SkillView[] = targets + .filter((target) => root.hostRoot(target) !== '') + .map((target) => ({ + host: target, + prefix: `${root.hostRoot(target)}/`, + skillLayout: options.registry.artifactLayout(target).skills, + })); + const rootTargets = targets.filter((target) => root.hostRoot(target) === ''); const skills = options.files - .map((file) => emittedSkillFor(file, targets, options.registry)) + .map((file) => emittedSkillFor(file, skillLayout, rootTargets, views)) .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, skillLayout, rootTargets, views) !== 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, + rootTarget, 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 existing = resourceFilesBySkill.get(root) ?? []; - resourceFilesBySkill.set(root, [...existing, file]); + const view = views.find((candidate) => file.path.startsWith(candidate.prefix)); + const layoutName = view === undefined ? skillLayout : view.skillLayout; + const relativePath = view === undefined ? file.path : file.path.slice(view.prefix.length); + const [layout, name] = relativePath.split('/'); + if (layoutName === undefined || layout !== layoutName || name === undefined) continue; + const skillRoot = `${view?.prefix ?? ''}${layout}/${name}`; + const existing = resourceFilesBySkill.get(skillRoot) ?? []; + resourceFilesBySkill.set(skillRoot, [...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, + rootTarget, skillRecovery, )); } @@ -118,7 +144,7 @@ export const validateEmittedSkills = async (options: { 'AB6015', 'Emitted Skill Markdown cannot be read.', skill.path, - skill.target, + rootTarget, skillRecovery, )); continue; @@ -130,7 +156,7 @@ export const validateEmittedSkills = async (options: { 'AB6015', 'Emitted Skill Markdown must start with YAML frontmatter.', skill.path, - skill.target, + rootTarget, skillRecovery, )); continue; @@ -140,33 +166,32 @@ export const validateEmittedSkills = async (options: { 'AB6015', `Emitted Skill YAML frontmatter is invalid: ${parsed.message}`, skill.path, - skill.target, + rootTarget, 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, - )); + // Every host reading this document must accept its frontmatter: all root + // hosts for a shared document, the one host of a namespaced view. + for (const target of skill.targets) { + for (const issue of frontmatterIssuesFor(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, + rootTarget, skillRecovery, )); } @@ -175,7 +200,7 @@ export const validateEmittedSkills = async (options: { 'AB6034', 'Emitted Skill Markdown must contain instructions after its YAML frontmatter.', skill.path, - skill.target, + rootTarget, artifactDiagnosticRecoveries.AB6034, )); } @@ -189,7 +214,7 @@ export const validateEmittedSkills = async (options: { 'AB6016', `Emitted Skill reference ${JSON.stringify(reference)} escapes its Skill root.`, skill.path, - skill.target, + rootTarget, artifactDiagnosticRecoveries.AB6016, )); } else if (!resources.has(reference)) { @@ -197,7 +222,7 @@ export const validateEmittedSkills = async (options: { 'AB6016', `Emitted Skill references missing regular resource ${JSON.stringify(reference)}.`, skill.path, - skill.target, + rootTarget, artifactDiagnosticRecoveries.AB6016, )); } diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index d7ca0525e..07b0c02f4 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -2,7 +2,7 @@ import { lstat, readFile } from 'node:fs/promises'; import { dirname, posix, resolve } from 'node:path'; import { portableAdapter } from '../adapters/portable.ts'; -import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { createDefaultRegistry, type ArtifactRootContracts, type TargetRegistry } from '../adapters/registry.ts'; import type { TargetArtifactDocumentIssue, TargetArtifactDocumentValidator, @@ -43,7 +43,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 { manifestTargetNames, validateEmittedSkills } from './validate-artifact-skills.ts'; import { installSurfaceRequirements } from '../install/surface.ts'; export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './artifact-diagnostics.ts'; @@ -346,6 +346,7 @@ const validateTargetContracts = async (options: { }): Promise => { const diagnostics: Diagnostic[] = []; const files = new Set(options.files.map((file) => file.path)); + const knownTargets = manifestTargetNames(options.manifest).filter((target) => options.registry.has(target)); for (const target of options.manifest.targets) { if (!options.registry.has(target.name)) { @@ -366,72 +367,75 @@ const validateTargetContracts = async (options: { )); continue; } + } + if (knownTargets.length === 0) return Object.freeze(diagnostics); + + // One root projects every declared host (#555): the install surface and + // the host documents (relocated where the composition moved them) all live + // at the root, validated by the root's contracts. + const root = options.registry.root(knownTargets); + // A single-host root's diagnostics name that host; a composite root's name + // no projection, since the whole root is at issue. + const rootTarget = root.targets.length === 1 ? root.targets[0] : undefined; + for (const relativePath of installSurfaceRequirements(knownTargets)) { + if (files.has(relativePath)) continue; + diagnostics.push(diagnostic( + relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', + `Plugin root ${JSON.stringify(root.name)} is missing required install surface ${JSON.stringify(relativePath)}.`, + relativePath, + rootTarget, + )); + } - 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 = root.artifactValidation; + const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); + for (const document of validation.documents) { + const generatedPaths = document.path.includes('*') + ? [...files] + .filter((path) => matchesArtifactDocumentPath(document.path, path)) + .sort((left, right) => left.localeCompare(right)) + : [document.path].filter((path) => files.has(path)); + if (generatedPaths.length === 0) { + if (document.required) { + diagnostics.push(diagnostic( + 'AB6011', + `Plugin root ${JSON.stringify(root.name)} is missing required document ${JSON.stringify(document.path)}.`, + document.path, + rootTarget, + )); + } + continue; } - - 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))) - .sort((left, right) => left.localeCompare(right)) - : [`${targetPrefix}${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}`, - target.name, - )); - } + for (const generatedPath of generatedPaths) { + let parsed: unknown; + try { + parsed = JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, generatedPath)))) as unknown; + } catch { continue; } - for (const generatedPath of generatedPaths) { - let parsed: unknown; - try { - parsed = JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, generatedPath)))) as unknown; - } catch { - continue; - } - const validate = validators.get(document.schema); - if (validate === undefined) continue; - 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}.`, - generatedPath, - target.name, - )); - } - if ( - document.path.endsWith('plugin.json') && - isRecord(parsed) && - typeof parsed.logo === 'string' - ) { - diagnostics.push(...manifestLogoPathDiagnostics({ - files, - generatedPath, - logo: parsed.logo, - target: target.name, - })); - } + const validate = validators.get(document.schema); + if (validate === undefined) continue; + const issues = validateSchemaDocument(validate, parsed); + const issue = issues[0]; + if (issue !== undefined) { + diagnostics.push(diagnostic( + 'AB6012', + `Plugin root ${JSON.stringify(root.name)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + generatedPath, + rootTarget, + )); + } + if ( + document.path.endsWith('plugin.json') && + isRecord(parsed) && + typeof parsed.logo === 'string' + ) { + diagnostics.push(...manifestLogoPathDiagnostics({ + files, + generatedPath, + logo: parsed.logo, + target: root.name, + })); } } } @@ -457,17 +461,22 @@ const validatePortableTargets = async (options: { readonly registry: TargetRegistry; }): Promise => { const diagnostics: Diagnostic[] = []; + const knownTargets = manifestTargetNames(options.manifest).filter((target) => options.registry.has(target)); + const root = knownTargets.length === 0 ? undefined : options.registry.root(knownTargets); 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 (root === undefined || !options.registry.has(target.name) || options.registry.get(target.name) !== portableAdapter) continue; + // The portable pack is the root itself, or its namespaced `portable/` + // view beside other hosts (#555). + const hostRoot = root.hostRoot(target.name); + const prefix = hostRoot === '' ? '' : `${hostRoot}/`; 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)) && + (prefix === '' || entry.path === hostRoot || 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), + pluginDirectory: resolve(options.artifactRoot, hostRoot), target: target.name, })) { diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(target.name)}: ${entry.message}` })); @@ -494,18 +503,47 @@ const isRecursiveArtifactPath = (relativePath: string, directory: string | undef const isAdapterRootDocument = (relativePath: string, rootDocuments: readonly string[] | undefined): boolean => rootDocuments?.includes(relativePath) === true; -const isTargetArtifactPath = ( - path: string, - target: string, +/** + * True when a root-relative path belongs to a layout the root's contracts + * declare: the compiler-owned directories, any projected host's hook or MCP + * document (relocated where the composition moved it), or a host document. + */ +/** + * True when a path inside a host's namespaced view (the `portable/` Agent + * Plugins pack of a composite root) belongs to that host's own layout: its + * documents, skills, assets, payloads, and the `mcp/` shims onto the shared + * compiled servers. + */ +const isHostViewArtifactPath = ( + relativePath: string, + host: 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); + const layout = registry.artifactLayout(host); + const hookContract = registry.hookContract(host); + const mcpRuntime = registry.mcpRuntime(host); + return isRecursiveArtifactPath(relativePath, layout.assets) || + isDirectOutputLayoutPath(relativePath, layout.mcpEntries) || + isSkillArtifactPath(relativePath, layout.skills) || + isAdapterRootDocument(relativePath, layout.rootDocuments) || + relativePath === hookContract?.manifestPath || + relativePath === mcpRuntime?.manifestPath || + registry.artifactValidation(host).documents.some((document) => + matchesArtifactDocumentPath(document.path, relativePath)); +}; + +const isRootArtifactPath = ( + relativePath: string, + root: ArtifactRootContracts, + registry: TargetRegistry, +): boolean => { + for (const host of root.targets) { + const hostRoot = root.hostRoot(host); + if (hostRoot !== '' && relativePath.startsWith(`${hostRoot}/`)) { + return isHostViewArtifactPath(relativePath.slice(hostRoot.length + 1), host, registry); + } + } + const layout = root.artifactLayout; return isRecursiveArtifactPath(relativePath, layout.assets) || isRecursiveArtifactPath(relativePath, layout.bin) || isDirectOutputLayoutPath(relativePath, layout.cliBin) || @@ -517,9 +555,10 @@ const isTargetArtifactPath = ( isDirectOutputLayoutPath(relativePath, layout.scripts) || isSkillArtifactPath(relativePath, layout.skills) || isAdapterRootDocument(relativePath, layout.rootDocuments) || - relativePath === hookContract?.manifestPath || - relativePath === mcpRuntime?.manifestPath || - registry.artifactValidation(target).documents.some((document) => + root.targets.some((host) => + relativePath === root.hookContractFor(host)?.manifestPath || + relativePath === root.mcpRuntimeFor(host)?.manifestPath) || + root.artifactValidation.documents.some((document) => matchesArtifactDocumentPath(document.path, relativePath)); }; @@ -530,56 +569,37 @@ const validateArtifactOwnership = (options: { readonly registry: TargetRegistry; }): readonly Diagnostic[] => { const diagnostics: Diagnostic[] = []; - const targets = targetNamespaces(options.manifest); + const knownTargets = manifestTargetNames(options.manifest).filter((target) => options.registry.has(target)); + // Unknown targets are diagnosed by the target-contract validator; without + // their registry contracts there is no trustworthy layout to classify against. + if (knownTargets.length === 0 || knownTargets.length !== options.manifest.targets.length) return Object.freeze(diagnostics); + const root = options.registry.root(knownTargets); + const rootTarget = root.targets.length === 1 ? root.targets[0] : undefined; 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 (isRootArtifactPath(file.path, root, options.registry)) continue; + // Prebuilt payload files live in config-named directories at 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 plugin root's emitted layouts.`, file.path, - target, + rootTarget, 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, + rootTarget, ownershipRecovery, )); } diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4472da8f2..7bc730271 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 plugin root when claude is selected', 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..671a9bfa4 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -69,7 +69,7 @@ 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 { codexSkillSidecars, decideSkillTreeLayout, lowerSkillIr, 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'; @@ -81,23 +81,38 @@ const isSkillHost = (name: string): name is SkillHost => 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); - } + 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'); +/** + * One plugin root holds one `skills//SKILL.md` read by every projected + * host (#555). When several hosts are selected the skill lowers to the one + * document all of them accept: the authored pass-through Markdown when every + * host would pass it through unchanged, otherwise the portable document, + * which strips every host extension and admits no host placeholder. + */ +const sharedSkillDocuments = (skillIr: SkillIr, hosts: readonly SkillHost[]) => { + const lowered = hosts.map((host) => lowerSkillIr(skillIr, host)); + const first = lowered[0]; + const shared = first !== undefined + && lowered.every((document) => document.passThrough && document.skillMarkdown === first.skillMarkdown) + ? first + : lowerSkillIr(skillIr, 'portable'); + // Codex's `agents/openai.yaml` is a file of its own beside the shared + // SKILL.md that no other host reads, so Codex keeps it in a shared root. + const codex = hosts.includes('codex') ? codexSkillSidecars(skillIr) : undefined; + return Object.fromEntries(hosts.map((host) => [ + host, + host === 'codex' && codex !== undefined && codex.sidecars.length > 0 + ? deepFreeze({ + ...shared, + diagnostics: [...shared.diagnostics, ...codex.diagnostics], + sidecars: codex.sidecars, + }) + : shared, + ])); }; const unique = (values: readonly string[]): string[] => [...new Set(values)]; @@ -1232,10 +1247,9 @@ 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 = skillHosts.length > 1 + ? sharedSkillDocuments(skillIr, skillHosts) + : lowerSkillIrForHosts(skillIr, skillHosts); return { body: skill.body, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 9a6fe1560..5b6d7a89e 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 { compositeHostNames, isCompositeHost } from '../adapters/composite-hosts.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'; @@ -2315,6 +2316,30 @@ const routedCliBinTargetDiagnostics = ( return diagnostics; }; +/** + * Every selected target is projected into one plugin root (#555), and only + * the built-in hosts know how to share one: an advanced registry's own adapter + * is built alone, one target per `--output`. Judged here so `validate` and + * `inspect` name the target instead of the build refusing the root. + */ +const compositeRootDiagnostics = ( + model: NormalizedPlugin, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const known = model.targets.filter((target) => registry.has(target.name)); + if (new Set(known.map((target) => target.name)).size < 2) return []; + return known + .filter((target) => !isCompositeHost(target.name)) + .map((target) => ({ + code: 'AB4106', + message: `Target ${JSON.stringify(target.name)} cannot share one plugin root with the other selected targets: a root of several targets projects the built-in hosts only (${compositeHostNames.join(', ')}).`, + recovery: `Build ${JSON.stringify(target.name)} alone into its own --output, and the built-in hosts into another.`, + severity: 'error', + sourcePath: target.provenance.sourcePath, + target: target.name, + })); +}; + export const validateModel = ( model: NormalizedPlugin, registry: NormalizationTargetRegistry, @@ -2322,16 +2347,24 @@ export const validateModel = ( const diagnostics: Diagnostic[] = []; for (const target of model.targets) { - if (!registry.has(target.name)) { - diagnostics.push({ - code: 'AB4100', - message: `Unknown target ${JSON.stringify(target.name)}.`, - severity: 'error', - sourcePath: target.provenance.sourcePath, - target: target.name, - }); - } + if (registry.has(target.name)) continue; + // `plugin` was the Claude Code + Codex composite before every selected + // target was projected into one root (#555); name its replacement. + const retiredComposite = target.name === 'plugin' && !registry.has('plugin'); + diagnostics.push({ + code: 'AB4100', + message: retiredComposite + ? 'Unknown target "plugin": the plugin target was retired; every selected target is now projected into one plugin root.' + : `Unknown target ${JSON.stringify(target.name)}.`, + ...(retiredComposite + ? { recovery: "Select the hosts it stood for instead — targets: ['claude', 'codex'] — and read the built root from the artifact directory itself." } + : {}), + severity: 'error', + sourcePath: target.provenance.sourcePath, + target: target.name, + }); } + diagnostics.push(...compositeRootDiagnostics(model, registry)); diagnostics.push(...routedCliBinTargetDiagnostics(model, registry)); 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..caba98ab8 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -260,19 +260,31 @@ export class ArtifactInspectionService { }); } + /** + * One tree per projected target (#555): every target reads the same root, + * so each tree holds the root's files, except a host projected as a + * namespaced view (`portable/` beside other hosts), whose tree holds that + * view's files. + */ #targets( manifest: ArtifactManifest, files: readonly ArtifactInspectionFile[], ): readonly ArtifactInspectionTarget[] { + const known = manifest.targets.map((target) => target.name).filter((name) => this.#registry.has(name)); + const root = known.length === 0 ? undefined : this.#registry.root(known); + const hostRoots = new Set(known.map((name) => root!.hostRoot(name)).filter((hostRoot) => hostRoot !== '')); return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => { - const root = emptyTreeBuildDirectory(); - const prefix = `${target.name}/`; + const tree = emptyTreeBuildDirectory(); + const hostRoot = root === undefined || !known.includes(target.name) ? '' : root.hostRoot(target.name); + const prefix = hostRoot === '' ? '' : `${hostRoot}/`; for (const file of files) { if (!file.path.startsWith(prefix)) continue; + // A root host's tree leaves out the namespaced views of other hosts. + if (prefix === '' && [...hostRoots].some((view) => file.path.startsWith(`${view}/`))) continue; const segments = file.path.slice(prefix.length).split('/'); const fileName = segments.pop(); if (fileName === undefined) continue; - let directory = root; + let directory = tree; for (const segment of segments) { let child = directory.directories.get(segment); if (child === undefined) { @@ -283,7 +295,7 @@ export class ArtifactInspectionService { } directory.files.set(fileName, file); } - return Object.freeze({ name: target.name, tree: treeNode(target.name, target.name, root) }); + return Object.freeze({ name: target.name, tree: treeNode(target.name, target.name, tree) }); })); } @@ -324,7 +336,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 +361,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..a2503822b 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts @@ -30,10 +30,14 @@ export const artifactScriptCatalog = ( ): readonly ArtifactScriptCatalogEntry[] => { const entries: ArtifactScriptCatalogEntry[] = []; const identities = new Set(); + // Every target reads the same plugin root (#555): the shared `scripts/` + // directory is catalogued once per projected target. + const targets = manifest.targets.map((target) => target.name).filter((name) => registry.has(name)); + const scripts = targets.length === 0 ? undefined : registry.root(targets).artifactLayout.scripts; for (const target of manifest.targets) { - const layout = registry.artifactLayout(target.name).scripts; + const layout = targets.includes(target.name) ? scripts : undefined; if (layout === undefined) continue; - const prefix = `${target.name}/${layout.directory}/`; + 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..fdfd04e0a 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -85,15 +85,21 @@ const failureDiagnostics = ( }]); }; +/** + * One digest per projected target. Every target reads the same plugin root + * (#555), so the digests share the root's file listing and differ only by the + * target name they are keyed under. + */ const targetDigests = async ( artifactRoot: string, model: NormalizedPlugin, -): Promise>> => Object.freeze(Object.fromEntries( - await Promise.all(model.targets.map(async (target) => [ +): Promise>> => { + const files = await listArtifactFiles(artifactRoot); + return Object.freeze(Object.fromEntries(model.targets.map((target) => [ target.name, - digest(await listArtifactFiles(join(artifactRoot, target.name))), - ])), -)); + digest({ files, target: target.name }), + ]))); +}; const createAttempt = async (projectRoot: string): Promise => { const attemptsRoot = join(resolve(projectRoot), '.agent-bundle', 'attempts'); diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 29b811541..aae9367d2 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -783,32 +783,10 @@ 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.`, - ); - } + // Every selected target reads the staged root itself (#555); the + // selection is recorded, not laid out as per-target directories. + if (record.targets.length === 0) { + throw new EpochStoreError('EPOCH_STAGING_INVALID', 'Staged epoch selects no target.'); } const manifestPath = join(record.root, this.#manifestRelativePath(record.epoch)); @@ -857,32 +835,10 @@ 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.`, - ); - } + // Every target reads the same plugin root (#555): the epoch directory + // itself is the root every recorded target digest describes. + if (Object.keys(epoch.targetDigests).length === 0) { + throw new EpochStoreError('EPOCH_METADATA_INVALID', 'Active epoch records no target.'); } let manifestRelativePath: string; diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 6d6ba436a..77c495395 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 { compositeMcpRuntime } from '../adapters/composite.ts'; +import { readArtifactTargets } from '../build/artifact-root.ts'; import { stableJson } from '../core/digest.ts'; import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; @@ -64,7 +66,16 @@ interface DevInstallMarker { readonly schemaVersion: 1; } -const mcpDocumentPath = (host: InstallHost): string => { +/** + * The MCP document the host reads inside the installed root. A root that + * projects several hosts relocates some of them (#555: Codex beside Claude + * Code reads `.codex-plugin/mcp.json`), so the root's own target list decides; + * a root without a manifest falls back to the host's conventional document. + */ +const mcpDocumentPath = async (bundleRoot: string, host: InstallHost): Promise => { + const targets = await readArtifactTargets(bundleRoot); + const relocated = targets === undefined ? undefined : compositeMcpRuntime(targets, host)?.manifestPath; + if (relocated !== undefined) return relocated; switch (host) { case 'claude': case 'codex': @@ -87,7 +98,7 @@ const rewriteMcpDocument = async ( projectRoot: string, run: PlatformRun, ): Promise => { - const path = join(bundleRoot, mcpDocumentPath(host)); + const path = join(bundleRoot, await mcpDocumentPath(bundleRoot, host)); let document: unknown; try { document = JSON.parse(await run(readFileString(path))) as unknown; @@ -401,7 +412,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 host reads the same plugin 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..adbd3b4c7 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,7 @@ import { tmpdir } from 'node:os'; import { isAbsolute, resolve } from 'node:path'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; +import { hostMcpRuntime, hostRootDirectory } from '../../build/artifact-root.ts'; import { validateArtifact } from '../../build/validate-artifact.ts'; import { DiagnosticError } from '../../core/diagnostics.ts'; import { joinArtifact } from '../../core/paths.ts'; @@ -312,7 +313,7 @@ export class McpSessionService { }); const program = Effect.gen({ self: this }, function* (this: McpSessionService) { const target = options.target; - const runtime = yield* liftTry(() => this.#runtime(target)); + yield* liftTry(() => this.#assertMcpTarget(target)); if (options.serverName.trim().length === 0) { return yield* Effect.fail(McpSessionError.invalidServerName()); } @@ -328,8 +329,21 @@ 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)); - const server = yield* liftPromise(() => this.#server(targetRoot, target, runtime, options.serverName)); + // Every target reads the epoch root itself (#555); the runtime contract + // and the host's plugin root come from the epoch's root contracts. + const rootContracts = yield* liftTry(() => { + const known = Object.keys(epochReference.epoch.targetDigests).filter((name) => this.#registry.has(name)); + return known.length === 0 ? undefined : this.#registry.root(known); + }); + const runtime = yield* liftTry(() => { + const contract = hostMcpRuntime(rootContracts, this.#registry, target); + if (contract === undefined) throw new Error(`Unsupported MCP target ${JSON.stringify(target)}.`); + return contract; + }); + // The host's plugin root (`${PLUGIN_ROOT}`, the default cwd); the MCP + // document path is root-relative, so it is read from the epoch root. + const targetRoot = yield* liftTry(() => hostRootDirectory(epochRoot, rootContracts, target)); + const server = yield* liftPromise(() => this.#server(epochRoot, target, runtime, options.serverName)); const fs = yield* FileSystem.FileSystem; const pluginDataScope = yield* Scope.make(); const releasePluginData = (): Promise => runPromise(Scope.close(pluginDataScope, Exit.void)); @@ -486,22 +500,19 @@ export class McpSessionService { return entry; } - #runtime(name: string): TargetMcpRuntimeContract { - if (!this.#registry.has(name) || !this.#registry.supports(name, 'mcp')) { + #assertMcpTarget(name: string): void { + if (!this.#registry.has(name) || !this.#registry.supports(name, 'mcp') || this.#registry.mcpRuntime(name) === undefined) { throw new Error(`Unsupported MCP target ${JSON.stringify(name)}.`); } - const runtime = this.#registry.mcpRuntime(name); - if (runtime === undefined) throw new Error(`Unsupported MCP target ${JSON.stringify(name)}.`); - return runtime; } async #server( - targetRoot: string, + epochRoot: string, target: string, runtime: TargetMcpRuntimeContract, name: string, ): Promise { - const path = joinArtifact(targetRoot, runtime.manifestPath); + const path = joinArtifact(epochRoot, runtime.manifestPath); let document: unknown; try { document = parseJsonWithoutDuplicateKeys(await this.#run(readFileString(path))); 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..86c767062 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts @@ -166,7 +166,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,10 +174,7 @@ 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; + const wrapperPath = hook.path; for (const group of groups) { if (!isRecord(group) || !Array.isArray(group.hooks)) continue; const hasWrapper = group.hooks.some((entry) => @@ -234,7 +231,8 @@ const assertTargetDigest = async ( ): Promise => { let actual: string; try { - actual = digest(await listArtifactFiles(join(artifact, target))); + // Every target reads the same plugin root (#555); the digest is keyed by target. + actual = digest({ files: await listArtifactFiles(artifact), target }); } catch { throw new Error(`Hook playground target ${JSON.stringify(target)} cannot be verified against its stored digest.`); } @@ -288,7 +286,12 @@ export class HookPlaygroundService { const selected = matching.filter((hook) => hook.target === 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); + // The host's contract inside this root, with the document path the + // composition may have relocated it to (#555). + const rootTargets = Object.keys(reference.epoch.targetDigests).filter((name) => this.#registry.has(name)); + const contract = rootTargets.includes(options.target) + ? this.#registry.root(rootTargets).hookContractFor(options.target) + : undefined; if (contract === undefined) return unsupportedTarget(options.target, example.event); if (selected.length !== 1) return unsupportedTarget(options.target, example.event); const target = options.target; 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..0d3219ba9 100644 --- a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts +++ b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; +import { hostMcpRuntime, readArtifactRootContracts } from '../../build/artifact-root.ts'; import type { DiscoveryBundleFinding, DiscoveryDiagnostic, @@ -124,7 +125,9 @@ const enumerateMcpServers = async ( const bundleRoot = value.bundle?.bundleRoot; if (bundleRoot === undefined) return undefined; try { - const runtime = registry.mcpRuntime(value.host); + // The installed root may compose several hosts (#555), relocating this + // host's MCP document; its root contracts name the path that applies. + const runtime = hostMcpRuntime(await readArtifactRootContracts(bundleRoot, registry), registry, value.host); if (runtime === undefined) return undefined; const document = parseJsonWithoutDuplicateKeys( await run(readFileString(join(bundleRoot, runtime.manifestPath))), 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/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index 16e45bb17..7a95bc0b1 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -15,6 +15,7 @@ import { resolve } from 'node:path'; import { Effect, FileSystem } from 'effect'; import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; +import { hostMcpRuntime, readArtifactRootContracts } from '../../build/artifact-root.ts'; import type { McpProbeFailure, McpProbeFailureKind, @@ -447,7 +448,7 @@ export class McpProbeService { `No prepared ${options.host} bundle is available for MCP probing.`, ); } - const runtime = this.#runtime(options.host); + const runtime = await this.#runtime(bundleRoot, options.host); const server = await this.#server(bundleRoot, options.host, runtime, options.serverName); const pluginData = await this.#createPluginData(); let launch: ResolvedMcpSessionLaunch; @@ -543,10 +544,14 @@ export class McpProbeService { void pending.then(() => this.#pendingTeardowns.delete(pending)); } - #runtime(host: McpProbeHost): TargetMcpRuntimeContract { + /** + * The host's MCP runtime at the prepared root: the bundle may compose + * several hosts (#555), relocating this host's MCP document. + */ + async #runtime(bundleRoot: string, host: McpProbeHost): Promise { let runtime: TargetMcpRuntimeContract | undefined; try { - runtime = this.#registry.mcpRuntime(host); + runtime = hostMcpRuntime(await readArtifactRootContracts(bundleRoot, this.#registry), this.#registry, host); } catch { runtime = undefined; } diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index af9309061..91916c479 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { isCompositeHost } from '../adapters/composite-hosts.ts'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { readFileBytes } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.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 build plans the one root every selected target shares (#555), so + // its judgment — including the composition rules (AB4104, AB4105) — is + // the plan judged here; a set that cannot compose is already AB4106. + const rootTargets = [...new Set(model.targets.map((target) => target.name).filter((name) => registry.has(name)))]; + if (rootTargets.length === 1 || (rootTargets.length > 1 && rootTargets.every(isCompositeHost))) { + diagnostics.push(...registry.root(rootTargets).adapter.plan(model).diagnostics); } } catch { return failedPreparation( diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index d53426c4f..4a0752e5e 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -1,6 +1,7 @@ import { lstat, readdir, realpath } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; +import { compositeHostRoot } from '../adapters/composite.ts'; import { projectMeta } from '../build/meta.ts'; import { parseSkill, type SkillDocument, type SkillResource } from '../config/skill.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; @@ -360,7 +361,14 @@ export class SkillDocumentService { } throw error; }); - const targetRoot = join(realEpochRoot, target); + // Every target reads the epoch root itself (#555); a host projected as a + // namespaced view (`portable/` beside other hosts) reads that view. + const targets = Object.keys(reference.epoch.targetDigests); + if (!targets.includes(target)) { + throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target is not available in this epoch.'); + } + const hostRoot = compositeHostRoot(targets, target); + const targetRoot = hostRoot === '' ? realEpochRoot : join(realEpochRoot, hostRoot); 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.'); diff --git a/packages/agent-bundle/src/eval/artifact.ts b/packages/agent-bundle/src/eval/artifact.ts index 1898bed2e..07abbb6f1 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 projected target, derived from the manifest's own recorded + * file hashes. Every target reads the same plugin root (#555), so the digests + * differ only by the target name they are keyed under. + */ 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..f4b7ddee3 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 }), + // Every target reads the same plugin root (#555). + (fs) => fs.copy(options.artifact.root, temporary.candidate, { overwrite: true }), )); } catch (error) { throw new CodexEvalHarnessError( diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index eb7e69042..61e19c58a 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -493,7 +493,7 @@ export const projectEventDocument = ( nativeEvent: string, nativeInput?: Readonly>, ): Readonly> | undefined => { - if (target === 'plugin') { + if (target.includes('+')) { throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); } const contexts: string[] = []; 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..246150829 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -8,6 +8,7 @@ import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; +import { safeArtifactPath } from '../core/paths.ts'; import capabilityTable from '../adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import hooksSchema from '../adapters/schemas/codex/hooks.schema.json' with { type: 'json' }; import marketplaceSchema from '../adapters/schemas/codex/marketplace.schema.json' with { type: 'json' }; @@ -80,28 +81,47 @@ interface PinnedDocumentContract { } const schemaValidator = createAdapterValidator(); -const pinnedDocumentContracts = Object.freeze([ - Object.freeze({ - path: '.codex-plugin/plugin.json', - required: true, - validate: validateJsonSchemaDocument(schemaValidator.compile(pluginSchema)), - }), - Object.freeze({ - path: 'hooks/hooks.json', - required: false, - validate: validateJsonSchemaDocument(schemaValidator.compile(hooksSchema)), - }), - Object.freeze({ - path: '.mcp.json', - required: false, - validate: validateJsonSchemaDocument(schemaValidator.compile(mcpSchema)), - }), - Object.freeze({ - path: '.agents/plugins/marketplace.json', - required: false, - validate: validateJsonSchemaDocument(schemaValidator.compile(marketplaceSchema)), - }), -]); +const codexPluginManifestPath = '.codex-plugin/plugin.json'; +const validatePluginDocument = validateJsonSchemaDocument(schemaValidator.compile(pluginSchema)); +const validateHooksDocument = validateJsonSchemaDocument(schemaValidator.compile(hooksSchema)); +const validateMcpDocument = validateJsonSchemaDocument(schemaValidator.compile(mcpSchema)); +const validateMarketplaceDocument = validateJsonSchemaDocument(schemaValidator.compile(marketplaceSchema)); + +/** + * A `.codex-plugin/plugin.json` pointer (`hooks`, `mcpServers`) as the + * plugin-relative path it names, or the conventional path when the manifest + * does not name one — or names something outside the plugin. + */ +const pointedDocumentPath = (value: unknown, conventional: string): string => { + if (typeof value !== 'string') return conventional; + const relative = value.startsWith('./') ? value.slice(2) : value; + return safeArtifactPath(relative) ? relative : conventional; +}; + +/** + * The documents Codex reads from a plugin: the manifest, the marketplace, and + * the hooks and MCP documents the manifest points at. Codex follows those + * pointers, so a root that also projects Claude Code (#555) — where Codex's + * documents live under `.codex-plugin/` beside Claude's conventional ones — + * is checked against Codex's own files, not Claude's. + */ +const pinnedDocumentContracts = async (pluginDirectory: string): Promise => { + let manifest: unknown; + try { + manifest = JSON.parse(await readFile(join(pluginDirectory, codexPluginManifestPath), 'utf8')); + } catch { + manifest = undefined; + } + const pointers = typeof manifest === 'object' && manifest !== null && !Array.isArray(manifest) + ? (manifest as Readonly>) + : {}; + return Object.freeze([ + Object.freeze({ path: codexPluginManifestPath, required: true, validate: validatePluginDocument }), + Object.freeze({ path: pointedDocumentPath(pointers['hooks'], 'hooks/hooks.json'), required: false, validate: validateHooksDocument }), + Object.freeze({ path: pointedDocumentPath(pointers['mcpServers'], '.mcp.json'), required: false, validate: validateMcpDocument }), + Object.freeze({ path: '.agents/plugins/marketplace.json', required: false, validate: validateMarketplaceDocument }), + ]); +}; const runCodexCommand: CodexPluginCommandRunner = (request) => runBoundedChildProcess(request, { labels: { outputLimit: 'output-limit', timedOut: 'timed-out' }, @@ -143,7 +163,7 @@ const validatePinnedDocuments = async ( target: string, ): Promise => { const diagnostics: Diagnostic[] = []; - for (const contract of pinnedDocumentContracts) { + for (const contract of await pinnedDocumentContracts(pluginDirectory)) { const path = join(pluginDirectory, contract.path); let source: string; try { diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 8d7c155e7..b66bc4445 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -466,16 +466,17 @@ const validateBundleFiles = async ( } }; +/** + * The composite plugin root is canonical (#555): the supplied directory holds + * the host's manifest directly. Nested `/` and `/plugin` + * directories are read only as the layout of artifacts built before #555. + */ +/** The plugin root is the supplied directory itself (#555): it holds the host's manifest directly. */ 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.`, - ); + throw new Error(`No ${host} bundle manifest ${JSON.stringify(manifest)} was found in ${JSON.stringify(root)}; --from names the built plugin 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 75948cc9a..4709df715 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -178,17 +178,18 @@ const readString = ( return value; }; +/** + * The plugin root is the supplied directory itself (#555): it holds the host's + * manifest directly. Every host reads the one root, so nothing nested under + * it is probed. + */ 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; - const pluginRoot = join(root, 'plugin'); - if (await exists(join(pluginRoot, manifest))) return pluginRoot; throw failure( 'AB7001', - `No ${host} bundle manifest was found in ${JSON.stringify(root)}, its ${JSON.stringify(host)} target directory, or its "plugin" target directory.`, + `No ${host} bundle manifest ${JSON.stringify(manifest)} was found in ${JSON.stringify(root)}; --from names the built plugin root.`, host, ); }; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 2f2ada271..8762a67c5 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -14,7 +14,10 @@ import { preservedRuntimeEntries, } from './receipt.ts'; -export type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; +export type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'portable'; + +/** The order host sections take in a composite root's `INSTALL.md`. */ +const hostSectionOrder: readonly BuiltInTarget[] = Object.freeze(['claude', 'codex', 'cursor', 'portable']); const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; @@ -229,11 +232,17 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ '', ]; -const portableInstructions = (): string[] => [ +const portableInstructions = (packDirectory = ''): string[] => [ '## Portable Agent Plugin', '', 'Portable is a distribution profile, not a host runtime with one universal install location.', - 'This bundle follows the Agent Plugins open standard (Agent Plugins 1.0.0, https://agent-plugins.org).', + ...(packDirectory === '' + ? ['This bundle follows the Agent Plugins open standard (Agent Plugins 1.0.0, https://agent-plugins.org).'] + : [ + `The \`${packDirectory}/\` directory of this root is the Agent Plugins pack (Agent Plugins 1.0.0,`, + 'https://agent-plugins.org): point Agent Plugins clients at that directory. Cursor loads it through', + `\`agent-bundle install cursor --from ./${packDirectory}\` or the bundled installer below.`, + ]), 'Cursor loads this format natively from `~/.cursor/plugins/local/`; restart Cursor or run', '`Developer: Reload Window` after copying it. Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT', 'are also native clients. The bundled installer provides the Cursor local copy:', @@ -280,28 +289,41 @@ 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(); - case 'plugin': - return [...claudeInstructions(model), ...codexInstructions(model), ...cursorInstructions(model)]; - default: { - const exhaustive: never = target; - throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); - } +const hostInstructions = (model: NormalizedPlugin, target: BuiltInTarget, packDirectory: string): string[] => { + switch (target) { + case 'claude': + return claudeInstructions(model); + case 'codex': + return codexInstructions(model); + case 'cursor': + return cursorInstructions(model); + case 'portable': + return portableInstructions(packDirectory); + 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 hosts a root actually projects (#555): a single + * host's section alone, or every selected host's section in host order. + */ +const installMarkdown = (model: NormalizedPlugin, targets: readonly BuiltInTarget[]): string => [ + ...header(model), + ...hostSectionOrder + .filter((host) => targets.includes(host)) + .flatMap((host) => hostInstructions(model, host, portablePackDirectory(targets))), +].join('\n'); + +/** + * Where the Agent Plugins pack lives relative to the root: the root itself for + * a portable-only root, `portable/` beside other hosts (#555). + */ +const portablePackDirectory = (targets: readonly string[]): string => + targets.includes('portable') && targets.length > 1 ? 'portable' : ''; + /** * The `--uninstall` half of the standalone installer, mirroring * `install/uninstall.ts` for the two Cursor deliveries: a receipt naming this @@ -650,7 +672,12 @@ const cursorUninstallerSource = (): readonly string[] => [ * owned-files-only in-place replacement. `tests/install-surface.test.ts` and * the host-install proofs pin the two implementations to each other. */ -const cursorInstallerSource = (model: NormalizedPlugin): string => { +/** + * @param packDirectory The Cursor-loadable pack relative to the installer: the + * root itself (a Cursor Plugin, or a portable-only root), or the `portable/` + * Agent Plugins view when the root projects no Cursor Plugin beside it. + */ +const cursorInstallerSource = (model: NormalizedPlugin, packDirectory = ''): string => { const name = JSON.stringify(model.metadata.name); const version = JSON.stringify(model.metadata.version); return [ @@ -671,7 +698,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// Runtime roots match case-insensitively: on case-insensitive filesystems State/ is state/.', 'const isPreservedRoot = (name) => preservedEntries.includes(String(name).toLowerCase());', `const markerFiles = ${JSON.stringify(installSurfaceMarkerFiles)};`, - "const source = resolve(fileURLToPath(new URL('.', import.meta.url)));", + `const source = resolve(fileURLToPath(new URL(${JSON.stringify(packDirectory === '' ? '.' : `./${packDirectory}/`)}, import.meta.url)));`, "const cursorRoot = join(homedir(), '.cursor');", "const installRoot = join(cursorRoot, 'plugins', 'local');", 'const destination = join(installRoot, pluginName);', @@ -1384,45 +1411,55 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ].join('\n'); }; -const needsCursorInstaller = (target: BuiltInTarget): boolean => - target === 'cursor' || target === 'plugin' || target === 'portable'; +/** The self-contained installer ships only when a selected host has no complete native lifecycle CLI (#555 §5). */ +const needsCursorInstaller = (targets: readonly string[]): boolean => + targets.includes('cursor') || targets.includes('portable'); +const isBuiltInTarget = (target: string): target is BuiltInTarget => + (hostSectionOrder as readonly string[]).includes(target); + +/** + * The install-surface files a root projecting `targets` must carry: every + * built-in host contributes `INSTALL.md`; Cursor and the portable projection + * add the standalone `install.mjs`. + */ export const installSurfaceRequirements = ( - target: string, + targets: readonly string[] | string, ): readonly string[] => { - if (target === 'cursor' || target === 'plugin' || target === 'portable') { - return Object.freeze(['INSTALL.md', 'install.mjs']); - } - if (target === 'claude' || target === 'codex') { - return Object.freeze(['INSTALL.md']); - } - return Object.freeze([]); + const selected = (typeof targets === 'string' ? [targets] : targets).filter(isBuiltInTarget); + if (selected.length === 0) return Object.freeze([]); + return Object.freeze(needsCursorInstaller(selected) ? ['INSTALL.md', 'install.mjs'] : ['INSTALL.md']); }; export const installSurfaceEntries = ( model: NormalizedPlugin, - target: BuiltInTarget, -): readonly TargetArtifactWrite[] => Object.freeze([ + target: BuiltInTarget | readonly BuiltInTarget[], +): readonly TargetArtifactWrite[] => { + const targets = typeof target === 'string' ? [target] : target; + return Object.freeze([ Object.freeze({ - content: installMarkdown(model, target), + content: installMarkdown(model, targets), kind: 'write' as const, relativePath: 'INSTALL.md', sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), }), - ...(needsCursorInstaller(target) + ...(needsCursorInstaller(targets) ? [Object.freeze({ - content: cursorInstallerSource(model), + // A root projecting a Cursor Plugin installs the root; otherwise the + // installer copies the Agent Plugins pack, at the root or in `portable/`. + content: cursorInstallerSource(model, targets.includes('cursor') ? '' : portablePackDirectory(targets)), kind: 'write' as const, relativePath: 'install.mjs', sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), })] : []), -]); + ]); +}; export const withInstallSurface = ( plan: TargetArtifactPlan, model: NormalizedPlugin, - target: BuiltInTarget, + target: BuiltInTarget | readonly BuiltInTarget[], ): TargetArtifactPlan => Object.freeze({ ...plan, entries: sortedEntries([...plan.entries, ...installSurfaceEntries(model, target)]), diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 2fe591227..53cf7e078 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 { hostMcpRuntime, hostRootDirectory, readArtifactRootContracts } from '../build/artifact-root.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; @@ -77,8 +78,7 @@ export const resolveMcpStdioLaunch = async ( 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) { + if (registry.mcpRuntime(options.target) === undefined) { throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); } @@ -87,8 +87,15 @@ 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); - const manifestPath = joinArtifact(targetRoot, runtime.manifestPath); + // Every target reads the one plugin root (#555); the runtime contract and + // the host's plugin root come from the artifact's root contracts. + const rootContracts = await readArtifactRootContracts(artifact, registry); + const runtime = hostMcpRuntime(rootContracts, registry, options.target); + if (runtime === undefined) throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); + // The host's plugin root (`${PLUGIN_ROOT}`, the default cwd); the MCP + // document path is root-relative, so it is read from the artifact root. + const targetRoot = hostRootDirectory(artifact, rootContracts, options.target); + const manifestPath = joinArtifact(artifact, runtime.manifestPath); let document: unknown; try { document = parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(manifestPath))); diff --git a/packages/agent-bundle/src/services/mcp-service.ts b/packages/agent-bundle/src/services/mcp-service.ts index bab42d721..80f53f208 100644 --- a/packages/agent-bundle/src/services/mcp-service.ts +++ b/packages/agent-bundle/src/services/mcp-service.ts @@ -16,6 +16,7 @@ import { Effect, type FileSystem } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { createDefaultRegistry, TargetRegistry } from '../adapters/registry.ts'; +import { hostMcpRuntime, hostRootDirectory, readArtifactRootContracts } from '../build/artifact-root.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { joinArtifact, resolveContained } from '../core/paths.ts'; @@ -220,18 +221,25 @@ export class McpService { ) => Promise, ): Promise<{ readonly connection: McpConnectionState; readonly value: Result }> { const artifact = resolve(options.artifact); - const runtime = this.#runtime(options.target); + this.#assertMcpTarget(options.target); const diagnostics = await validateArtifact({ artifactRoot: artifact, registry: this.#registry }); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const targetRoot = joinArtifact(artifact, options.target); + // Every target reads the one plugin root (#555); the runtime contract and + // the host's plugin root come from the artifact's root contracts. + const rootContracts = await readArtifactRootContracts(artifact, this.#registry); + const runtime = hostMcpRuntime(rootContracts, this.#registry, options.target); + if (runtime === undefined) throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); + // The host's plugin root (`${PLUGIN_ROOT}`, the default cwd); the MCP + // document path is root-relative, so it is read from the artifact root. + const targetRoot = hostRootDirectory(artifact, rootContracts, options.target); // 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 // 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(artifact, options.target, runtime, options.server); return yield* withTempDirectory( { directory: tmpdir(), prefix: 'agent-bundle-mcp-' }, (pluginData) => liftPromise(() => this.#connect(options, operation, { pluginData, runtime, server, targetRoot })), @@ -297,7 +305,7 @@ export class McpService { } #server( - targetRoot: string, + artifact: string, target: string, runtime: TargetMcpRuntimeContract, name: string, @@ -305,7 +313,7 @@ export class McpService { 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(artifact, runtime.manifestPath); return Effect.flatMap(readFileString(path), (contents) => Effect.suspend(() => { let document: unknown; try { @@ -324,15 +332,10 @@ export class McpService { })); } - #runtime(name: string): TargetMcpRuntimeContract { - if (!this.#registry.has(name) || !this.#registry.supports(name, 'mcp')) { - throw new Error(`Unsupported MCP target ${JSON.stringify(name)}.`); - } - const runtime = this.#registry.mcpRuntime(name); - if (runtime === undefined) { + #assertMcpTarget(name: string): void { + if (!this.#registry.has(name) || !this.#registry.supports(name, 'mcp') || this.#registry.mcpRuntime(name) === undefined) { throw new Error(`Unsupported MCP target ${JSON.stringify(name)}.`); } - return runtime; } #throwIfStderrExceeded(capture: StderrCapture | undefined): void { diff --git a/packages/agent-bundle/src/skills/lower.ts b/packages/agent-bundle/src/skills/lower.ts index 6a01d855e..5c2a711db 100644 --- a/packages/agent-bundle/src/skills/lower.ts +++ b/packages/agent-bundle/src/skills/lower.ts @@ -183,6 +183,22 @@ const lowerBody = ( return { body, diagnostics, tokenLowering }; }; +/** + * The Codex `agents/openai.yaml` sidecar a skill's `codex` extension declares, + * with its schema diagnostics. A file of its own beside `SKILL.md` that no + * other host reads, so a root shared with other hosts (#555) still emits it. + */ +export const codexSkillSidecars = ( + ir: SkillIr, +): { readonly diagnostics: readonly Diagnostic[]; readonly sidecars: readonly SkillSidecarRef[] } => { + if (ir.passThrough || ir.extensions.codex === undefined) return { diagnostics: [], sidecars: [] }; + const sidecar = codexSidecarDocument(ir.extensions.codex); + return { + diagnostics: schemaIssues('codex', validateCodexOpenaiYaml(sidecar), ir.source), + sidecars: [{ content: stringifyYaml(sidecar), relativePath: 'agents/openai.yaml' }], + }; +}; + export const lowerSkillIr = (ir: SkillIr, host: SkillHost): SkillHostDocument => { if (ir.passThrough) { return deepFreeze({ @@ -208,17 +224,13 @@ export const lowerSkillIr = (ir: SkillIr, host: SkillHost): SkillHostDocument => case 'cursor': frontmatter = cursorFrontmatter(ir.portable, ir.extensions.cursor); break; - case 'codex': + case 'codex': { frontmatter = portableFrontmatter(ir.portable); - if (ir.extensions.codex !== undefined) { - const sidecar = codexSidecarDocument(ir.extensions.codex); - diagnostics.push(...schemaIssues(host, validateCodexOpenaiYaml(sidecar), ir.source)); - sidecars.push({ - content: stringifyYaml(sidecar), - relativePath: 'agents/openai.yaml', - }); - } + const codex = codexSkillSidecars(ir); + diagnostics.push(...codex.diagnostics); + sidecars.push(...codex.sidecars); break; + } case 'portable': frontmatter = portableFrontmatter(ir.portable); break; diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 8e7659b67..937179786 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -4,6 +4,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { compositeHookContract, compositeMcpRuntime } from '../adapters/composite.ts'; import { artifactManifestName } from '../build/emit.ts'; import { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.ts'; import { parseArtifactManifest } from '../build/manifest.ts'; @@ -124,21 +125,34 @@ const hostManifestPath = (host: InstallHost): string => { } }; -const hostMcpPath = (host: InstallHost): string => - host === 'cursor' ? 'mcp.json' : '.mcp.json'; +/** + * The MCP document the installed host reads. A root that projects several + * hosts relocates some of them (#555: Codex beside Claude Code reads + * `.codex-plugin/mcp.json`), so the built artifact's target list decides; + * without a manifest the host's conventional document applies. + */ +const hostMcpPath = (host: InstallHost, targets: readonly string[] | undefined): string => + (targets === undefined ? undefined : compositeMcpRuntime(targets, host)?.manifestPath) + ?? (host === 'cursor' ? 'mcp.json' : '.mcp.json'); /** - * 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 - * `hooks/hooks.json` folder discovery when the field is absent. + * The hook document the installed host loads. Claude Code reads the pinned + * `hooks/hooks.json`; Codex reads the same unless it shares the root with + * Claude Code, where its `.codex-plugin/plugin.json` points at + * `.codex-plugin/hooks.json` (#555); Cursor reads whatever the installed + * `.cursor-plugin/plugin.json` `hooks` field names (`hooks/hooks-cursor.json` + * beside another host, #438), falling back to `hooks/hooks.json` folder + * discovery when the field is absent. */ -const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { +const hostHookPath = ( + host: InstallHost, + installedManifest: Readonly>, + targets: readonly string[] | undefined, +): string => { switch (host) { case 'claude': case 'codex': - return 'hooks/hooks.json'; + return (targets === undefined ? undefined : compositeHookContract(targets, host)?.manifestPath) ?? 'hooks/hooks.json'; case 'cursor': { const source = resolveCursorHooksSource(installedManifest); return source.kind === 'file' ? source.path : cursorDefaultHooksPath; @@ -306,21 +320,23 @@ export const openInstalledHostMcpServer = async ( failures.push({ check: 'manifest-schema', reason: 'built artifact manifest was unavailable or invalid' }); } const target = artifactManifest?.targets.find((candidate) => candidate.name === options.host); + const rootTargets = artifactManifest?.targets.map((candidate) => candidate.name); if (target === undefined) { failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare target ${options.host}` }); } 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)) ?? []; + // Every projected host reads the same plugin root (#555), so the manifest's + // files are the host's component files. + 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)), @@ -336,12 +352,10 @@ 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/'); - }); + const resourceFiles = targetFiles.filter((file) => + file.path.startsWith('assets/') || file.path.startsWith('skills/') || file.path.startsWith('commands/')); for (const resource of resourceFiles) { - const path = resource.path.slice(prefix.length); + const path = resource.path; if (await fileHash(join(installedRoot, path)) === undefined) { failures.push({ check: 'resources', reason: `installed resource ${path} was missing` }); } @@ -393,7 +407,7 @@ export const openInstalledHostMcpServer = async ( } if (installedHooks !== undefined && installedHooks.length > 0) { const hookDocument = await readJsonRecord( - join(installedRoot, hostHookPath(options.host, installedManifest)), + join(installedRoot, hostHookPath(options.host, installedManifest, rootTargets)), 'hook-commands', 'installed hook document', failures, @@ -403,7 +417,7 @@ 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; + const path = hook.path; if (await fileHash(join(installedRoot, path)) === undefined) { failures.push({ check: 'hook-commands', reason: `installed hook command target ${path} was missing` }); } @@ -411,7 +425,7 @@ export const openInstalledHostMcpServer = async ( } const mcpDocument = await readJsonRecord( - join(installedRoot, hostMcpPath(options.host)), + join(installedRoot, hostMcpPath(options.host, rootTargets)), 'mcp-command', 'installed MCP document', failures, diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index f221f2bdd..ca74d7fe7 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -7,7 +7,6 @@ import { capabilityBooleanView, capabilityEvidence, capabilityIsSupported, - intersectCapabilityStates, intersectNoticeDeliveryAdvertisements, noticeDeliveryAdvertisementFrom, supportedCapability, @@ -18,6 +17,7 @@ import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.j import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import cursorCapabilityTable from '../src/adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; import cursorHooksSchema from '../src/adapters/schemas/cursor/hooks.schema.json' with { type: 'json' }; +import { createCompositeAdapter } from '../src/adapters/composite.ts'; import { cursorContractCapabilityRows } from '../src/adapters/cursor.ts'; import { NOTICE_DELIVERY_ROUTES } from '../src/adapters/notice-delivery.ts'; import type { NoticeDeliveryAdvertisement, NoticeDeliveryRoute } from '../src/adapters/notice-delivery.ts'; @@ -31,21 +31,6 @@ const evidence = (target: string): CapabilityEvidence => Object.freeze({ }); 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 +47,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 +67,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 +98,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 +145,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 +156,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 without inventing Codex coverage', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.lsp).toMatchObject({ @@ -204,13 +170,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 +189,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 +211,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 +223,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 +234,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 +241,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 +260,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 +272,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 without inventing coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -365,19 +288,14 @@ 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', () => { +it('reports Claude plugin settings support without inventing coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.settings).toMatchObject({ @@ -387,10 +305,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 +312,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 +335,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 +370,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 +415,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 without inventing coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.userConfig).toMatchObject({ @@ -527,18 +425,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 without inventing coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.channels).toMatchObject({ @@ -548,21 +441,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 +457,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 without inventing coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.dependencies).toMatchObject({ @@ -594,16 +474,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 +554,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 +612,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,42 +643,37 @@ 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) => { - const registry = createDefaultRegistry(); +it.each(['marketplaceManifest', 'allowCrossMarketplaceDependenciesOn'] as const)( + 'reports Claude %s support without inventing coverage on hosts that lack it', + (capability) => { + const registry = createDefaultRegistry(); - expect(registry.get('claude').capabilities[capability]).toMatchObject({ - evidence: { - observedVersion: '2.1.260', - target: 'claude', - }, - 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); - } - // Cursor publishes its own dated marketplace manifest row (#189); the - // cross-marketplace dependency allowlist remains Claude-only. - if (capability === 'marketplaceManifest') { - expect(registry.get('cursor').capabilities[capability]).toMatchObject({ - evidence: { observedVersion: '2026-08-28', target: 'cursor' }, + expect(registry.get('claude').capabilities[capability]).toMatchObject({ + evidence: { + observedVersion: '2.1.260', + target: 'claude', + }, state: 'supported', }); - expect(registry.supports('cursor', capability)).toBe(true); - } else { - expect(registry.get('cursor').capabilities[capability]).toBeUndefined(); - expect(registry.supports('cursor', capability)).toBe(false); - } - expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); -}); + for (const target of ['codex', 'portable'] as const) { + expect(registry.get(target).capabilities[capability]).toBeUndefined(); + expect(registry.supports(target, capability)).toBe(false); + } + // Cursor publishes its own dated marketplace manifest row (#189); the + // cross-marketplace dependency allowlist remains Claude-only. + if (capability === 'marketplaceManifest') { + expect(registry.get('cursor').capabilities[capability]).toMatchObject({ + evidence: { observedVersion: '2026-08-28', target: 'cursor' }, + state: 'supported', + }); + expect(registry.supports('cursor', capability)).toBe(true); + } else { + expect(registry.get('cursor').capabilities[capability]).toBeUndefined(); + expect(registry.supports('cursor', capability)).toBe(false); + } + expect(registry.supports('claude', capability)).toBe(true); + }, +); it('pins the authored Claude marketplace source matrix and version gates', () => { expect(claudeCapabilityTable.plugin.marketplaceManifest.sourceMatrix).toEqual({ @@ -826,7 +690,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 Cursor or portable coverage', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.manifestPaths).toMatchObject({ @@ -836,10 +700,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 +707,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 host', () => { const registry = createDefaultRegistry(); for (const target of ['claude', 'codex', 'cursor'] as const) { @@ -860,10 +719,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,7 +796,7 @@ it('records dated Codex manifest and package capability rows', () => { ]); }); -it('mirrors Codex manifest metadata and path states through the unified adapter', () => { +it('reports Codex manifest metadata support and a degraded canonical manifest path row', () => { const registry = createDefaultRegistry(); expect(registry.get('codex').capabilities.manifestMetadata).toMatchObject({ @@ -953,60 +808,6 @@ it('mirrors Codex manifest metadata and path states through the unified adapter' 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', () => { @@ -1063,15 +864,12 @@ 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 { @@ -1177,10 +975,8 @@ it('validates lowersConfigExtensions at registration and answers extension lower })).toThrow(/lowersConfigExtensions must be an array of nonempty extension keys/u); } const registry = createDefaultRegistry(); - // Own key, declared composite sides, and nothing else. + // Own key 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 +1077,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 +1089,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 +1266,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 and mirrors every one through the Cursor adapter (#189)', () => { const registry = createDefaultRegistry(); const cursor = registry.get('cursor'); - const unified = registry.get('plugin'); const expectedStates = { agentPluginFormat: 'unavailable', agents: 'unavailable', @@ -1547,13 +1313,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,13 +1357,13 @@ 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( + // A root projecting all three hosts serves every one of them, so it advertises their intersection. + const composite = createCompositeAdapter(['claude', 'codex', 'cursor']).noticeDelivery!; + expect(composite).toEqual(intersectNoticeDeliveryAdvertisements( intersectNoticeDeliveryAdvertisements(registry.noticeDelivery('claude')!, registry.noticeDelivery('codex')!), registry.noticeDelivery('cursor')!, )); - expect(selectNoticeDeliveryRoutes(plugin)).toEqual({ + expect(selectNoticeDeliveryRoutes(composite)).toEqual({ kind: 'selected', routes: ['mcp-resource-updated', 'mcp-inbox', 'next-event'], }); @@ -1627,10 +1386,13 @@ it('spells the notice delivery taxonomy locally so public declarations never res it('advertises dated sensitivity ceilings per route and host (#99 acceptance item 7)', () => { const registry = createDefaultRegistry(); - const ceiling = (host: string, route: NoticeDeliveryRoute): string | undefined => { - const entry = registry.noticeDelivery(host)![route]; + const composite = createCompositeAdapter(['claude', 'codex', 'cursor']).noticeDelivery!; + const ceilingOf = (advertisement: NoticeDeliveryAdvertisement, route: NoticeDeliveryRoute): string | undefined => { + const entry = advertisement[route]; return entry.state === 'supported' ? entry.sensitivity : undefined; }; + const ceiling = (host: string, route: NoticeDeliveryRoute): string | undefined => + ceilingOf(registry.noticeDelivery(host)!, route); for (const host of ['claude', 'codex', 'cursor']) { // The hook response returns to the recipient's own host process: the // recipient's trust boundary, so a secret notice may travel in full. @@ -1643,16 +1405,17 @@ 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']) { + const hostAdvertisements = ['claude', 'codex', 'cursor', 'portable'].map((host) => registry.noticeDelivery(host)!); + for (const advertisement of [...hostAdvertisements, composite]) { for (const route of NOTICE_DELIVERY_ROUTES) { - const entry = registry.noticeDelivery(host)![route]; + const entry = advertisement[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'); + // A composite root takes the lowest ceiling of its hosts. + expect(ceilingOf(composite, 'next-event')).toBe('secret'); + expect(ceilingOf(composite, '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/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 14746a769..dc399b1a8 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,9 @@ 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'); + // Claude Code reads the plugin root itself (#555): the Codex projection + // shares it, so the validator and load check run over the root. + const claudeBundle = artifact; const calls: string[][] = []; const runner: ClaudePluginCommandRunner = async (request) => { calls.push([...request.args]); @@ -454,7 +456,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 into the same root, but only the claude target 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 +479,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' }; @@ -506,13 +508,13 @@ it('build surfaces a Claude load refusal as AB7325 even when plugin validate --s } }, 30_000); -it('build reports one informational AB6019 skip for all Claude-validated targets when claude is absent (#476)', async () => { +it('build reports one informational AB6019 skip for the Claude-validated target when claude is absent (#476)', async () => { const root = await createProject(); try { 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 +530,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' }), @@ -838,6 +839,19 @@ it('returns an invalid inspection for selected targets outside the normalized pr it('reports skipped target/component pairs against each target emission surface', async () => { const root = await createProject(); + const cursorOnlyDocuments = [ + join(root, 'src', 'commands', 'cursor-only.md'), + join(root, 'src', 'rules', 'cursor-only.mdc'), + ]; + const configure = (targets: readonly string[]): Promise => writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " hooks: { sessionStart: { handler: './src/hook.ts' } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", + ` targets: ${JSON.stringify(targets)},`, + '};', + '', + ].join('\n')); try { await Promise.all([ mkdir(join(root, 'src', 'commands'), { recursive: true }), @@ -848,24 +862,23 @@ it('reports skipped target/component pairs against each target emission surface' 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'), + cursorOnlyDocuments[0]!, '---\ntargets:\n - cursor\n---\nCursor 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'), writeFile( - join(root, 'src', 'rules', 'cursor-only.mdc'), + cursorOnlyDocuments[1]!, '---\ndescription: Cursor-only rule\ntargets:\n - cursor\n---\nCursor guidance.\n', ), - writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - " 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'],", - '};', - '', - ].join('\n')), + // One plugin root cannot carry Cursor commands beside Claude Code + // (AB4104: `commands/` is Claude Code frontmatter Markdown there), and a + // document selecting a host the root does not project is refused, not + // skipped. So the Cursor accounting is inspected from a root without + // Claude Code and the Claude Code accounting, below, from a root without + // the Cursor-only documents; the per-host judgments do not depend on + // which hosts share the root. + configure(['portable', 'codex', 'cursor']), ]); const result = await readyInspection({ root }); @@ -894,7 +907,7 @@ it('reports skipped target/component pairs against each target emission surface' expect(planFor('portable')?.selected).toEqual([ expect.objectContaining({ capability: expect.objectContaining({ name: 'skills', state: 'supported' }), kind: 'skill', name: 'review' }), ]); - // Cursor emits both surfaces with dated evidence; Claude emits commands but no rules. + // Cursor emits both surfaces with dated evidence. expect(planFor('cursor')?.selected).toEqual(expect.arrayContaining([ expect.objectContaining({ capability: expect.objectContaining({ evidence: expect.objectContaining({ target: 'cursor' }), name: 'commands', state: 'supported' }), @@ -907,9 +920,6 @@ it('reports skipped target/component pairs against each target emission surface' name: 'cursor-only', }), ])); - expect(planFor('claude')?.selected.some((component) => component.kind === 'command' && component.name === 'shared')).toBe(true); - expect(planFor('claude')?.skipped.find((component) => component.kind === 'rule' && component.name === 'shared')?.capability) - .toEqual({ name: 'rules', reason: expect.any(String), state: 'unavailable' }); for (const plan of result.plans) { expect(Object.isFrozen(plan.selected)).toBe(true); expect(plan.selected.length + plan.skipped.length).toBe( @@ -922,29 +932,50 @@ it('reports skipped target/component pairs against each target emission surface' 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' }), - ]); expect(planFor('cursor')?.skipped).toEqual([ expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), ]); - expect(planFor('claude')?.skipped.some((component) => - 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([ + // The entries are the one root's plan (#555): the shared command and rule + // land at the root once for every host that reads them. + expect(planFor('cursor')?.entries).toEqual(expect.arrayContaining([ expect.objectContaining({ relativePath: 'commands/shared.md' }), expect.objectContaining({ relativePath: 'rules/shared.mdc' }), ])); + expect(planFor('codex')?.entries).toEqual(planFor('cursor')?.entries); expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true); + + // Claude Code emits commands but no rules. + await Promise.all(cursorOnlyDocuments.map((path) => rm(path))); + await configure(['portable', 'codex', 'claude']); + const claudeRoot = await readyInspection({ root }); + const claudePlanFor = (target: string) => claudeRoot.plans.find((plan) => plan.target === target); + + expect(claudePlanFor('claude')?.selected.some((component) => component.kind === 'command' && component.name === 'shared')).toBe(true); + expect(claudePlanFor('claude')?.skipped.find((component) => component.kind === 'rule' && component.name === 'shared')?.capability) + .toEqual({ name: 'rules', reason: expect.any(String), state: 'unavailable' }); + expect(claudePlanFor('claude')?.skipped).toEqual([ + expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), + expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), + ]); + expect(claudePlanFor('claude')?.skipped.some((component) => + component.kind === 'command' && component.name === 'shared')).toBe(false); + expect(claudePlanFor('portable')?.skipped).toEqual([ + expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), + expect.objectContaining({ kind: 'hook', name: 'sessionStart', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), + expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), + ]); + for (const plan of claudeRoot.plans) { + expect(plan.selected.length + plan.skipped.length).toBe( + claudePlanFor('claude')!.selected.length + claudePlanFor('claude')!.skipped.length, + ); + } + expect(claudePlanFor('claude')?.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ relativePath: 'commands/shared.md' }), + ])); + expect(claudePlanFor('claude')?.entries.some((entry) => entry.relativePath.startsWith('rules/'))).toBe(false); } finally { await rm(join(root, '..'), { force: true, recursive: true }); } @@ -956,7 +987,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 +1002,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 +1016,12 @@ 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. The entries are the one root's plan (#555): `.lsp.json` is + // emitted once, at the root Claude Code reads, and never into the portable + // view. 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 +1029,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' }, @@ -1008,14 +1036,16 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per outcome: 'skipped', reason: 'excluded-by-targets', })]); - expect(planFor(target).entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); + expect(planFor(target).entries).toEqual(planFor('claude').entries); } + expect(planFor('claude').entries.filter((entry) => entry.relativePath.endsWith('.lsp.json')).map((entry) => entry.relativePath)) + .toEqual(['.lsp.json']); expect(result.model.lspServers).toEqual([{ declaredBy: 'claude', 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 +1055,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 +1110,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 }); @@ -1154,6 +1179,9 @@ it('judges event-route admission and lsp inheritance by the component-emission o // 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. + // Advanced adapters are built one target per root (#555), so each adapter + // is inspected as its own root over the one project. + const loweringTarget = 'lowering'; const registry = new TargetRegistry() .register({ ...syntheticAdapter, @@ -1174,7 +1202,7 @@ it('judges event-route admission and lsp inheritance by the component-emission o capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), configExtension: undefined, lowersConfigExtensions: ['synthetic'], - name: 'composite', + name: loweringTarget, plan: () => Object.freeze({ diagnostics: [], entries: Object.freeze([]) }), }); try { @@ -1191,7 +1219,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 +1233,20 @@ 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] })]); + + // The lowering adapter's root inherits the declaration even though the + // declaring adapter is not selected there; the route it never selected + // reads as excluded. + const lowering = await readyInspection({ registry, root, targets: [loweringTarget] }); + const loweringPlan = lowering.plans.find((plan) => plan.target === loweringTarget)!; + expect(lowering.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', targets: [loweringTarget] })]); + expect(loweringPlan.selected).toEqual(expect.arrayContaining([ + expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), + ])); + expect(loweringPlan.skipped).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'event-route', name: 'session/start', reason: 'excluded-by-targets' }), + ])); } finally { await rm(join(root, '..'), { force: true, recursive: true }); } @@ -1256,49 +1297,50 @@ 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: ['cursor'],", '};', '', ].join('\n')), ]); - const result = await readyInspection({ root }); - const selectedOn = (target: string, kind: string) => + // Cursor commands cannot share one plugin root with Claude Code (AB4104), + // so the configured root is Cursor's and Claude Code is inspected as its + // own root over the same project. + const cursor = await readyInspection({ root }); + const claude = await readyInspection({ root, targets: ['claude'] }); + const selectedOn = (result: Awaited>, target: string, kind: string) => result.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. - expect(selectedOn('cursor', 'command').omittedFeatures).toEqual([ + expect(selectedOn(cursor, 'cursor', 'command').omittedFeatures).toEqual([ { capability: { name: 'commands.argumentHint', reason: expect.stringContaining('frontmatter-free'), state: 'unavailable' }, feature: 'argumentHint' }, { 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. - expect(selectedOn('claude', 'command')).not.toHaveProperty('omittedFeatures'); - expect(selectedOn('plugin', 'command')).not.toHaveProperty('omittedFeatures'); + expect(Object.isFrozen(selectedOn(cursor, 'cursor', 'command').omittedFeatures)).toBe(true); + // Claude documents every field, so it omits nothing; a component with no + // omissions has no key. + expect(selectedOn(claude, 'claude', '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']) { - 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. + expect(selectedOn(cursor, 'cursor', 'rule')).not.toHaveProperty('omittedFeatures'); + expect(selectedOn(cursor, 'cursor', 'hook')).not.toHaveProperty('omittedFeatures'); + expect(selectedOn(claude, 'claude', 'hook')).not.toHaveProperty('omittedFeatures'); + // A skill with a Claude host extension keeps it in a Claude Code root. A + // root shared with another host carries the one skills/ document every + // projected host accepts — the portable document — which strips the host + // extension (#555). await writeFile( 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 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'); + const withExtension = await readyInspection({ root, targets: ['claude'] }); + expect(selectedOn(withExtension, 'claude', 'skill')).not.toHaveProperty('omittedFeatures'); + expect(withExtension.model.skills[0]!.hostDocuments?.claude?.frontmatter).toHaveProperty('model', 'sonnet'); + const shared = await readyInspection({ root, targets: ['claude', 'codex'] }); + for (const host of ['claude', 'codex']) { + expect(shared.model.skills[0]!.hostDocuments?.[host]?.frontmatter).toEqual({ description: 'Reviews changes', name: 'review' }); + } + expect(shared.plans[0]!.entries.filter((entry) => entry.relativePath === 'skills/review/SKILL.md')).toHaveLength(1); // `validate` surfaces the matching omit-with-reason warnings for the // implicit Cursor target; inspect stays a ready plan. const validated = await validate({ root }); @@ -1312,6 +1354,48 @@ it('reports omitted component features per target from the host feature rows (#1 } }); +it('judges the one plugin root every selected target shares in validate and inspect (#555)', async () => { + const root = await createProject(); + try { + // A conventional command selects every target; Cursor reads plain Markdown + // prompts from the same commands/ directory Claude Code fills with + // frontmatter, so a claude+cursor root refuses it (AB4104). The build + // judges the shared root; `validate` and `inspect` must say the same + // instead of leaving the refusal to `build`. + await mkdir(join(root, 'src', 'commands'), { recursive: true }); + await writeFile(join(root, 'src', 'commands', 'deploy.md'), '---\ndescription: Deploy\n---\nDeploy prompt.\n'); + const composition = await validate({ root, targets: ['claude', 'cursor'] }); + expect(composition.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB4104', severity: 'error', sourcePath: join(root, 'src', 'commands', 'deploy.md') }), + ])); + const inspected = await inspect({ root, targets: ['claude', 'cursor'] }); + expect(inspected.state).toBe('invalid'); + expect(inspected.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'AB4104' })])); + // Each host alone is a valid root over the same project. + expect((await validate({ root, targets: ['cursor'] })).diagnostics.filter(({ code }) => code === 'AB4104')).toEqual([]); + expect((await validate({ root, targets: ['claude'] })).diagnostics.filter(({ code }) => code === 'AB4104')).toEqual([]); + + // Only the built-in hosts compose: an advanced registry's own adapter + // beside one of them is a target set no root can hold (AB4106), named on + // the adapter's target rather than thrown from the planner. + const registry = createDefaultRegistry().register(syntheticAdapter); + const mixed = await validate({ registry, root, targets: ['claude', syntheticTarget] }); + expect(mixed.diagnostics.filter(({ code }) => code === 'AB4106')).toEqual([expect.objectContaining({ + message: expect.stringContaining(`Target "${syntheticTarget}" cannot share one plugin root`), + severity: 'error', + target: syntheticTarget, + })]); + const mixedInspection = await inspect({ registry, root, targets: ['claude', syntheticTarget] }); + expect(mixedInspection.state).toBe('invalid'); + expect(mixedInspection.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'AB4106' })])); + await expect(build({ output: join(root, 'artifact'), registry, root, targets: ['claude', syntheticTarget] })) + .rejects.toMatchObject({ diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'AB4106' })]) }); + expect((await validate({ registry, root, targets: [syntheticTarget] })).diagnostics.filter(({ code }) => code === 'AB4106')).toEqual([]); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + it('never counts an opaque third-party lspServers declaration as emitted by a host that does not lower it (#100)', async () => { const root = await createProject(); const registry = createDefaultRegistry().register({ @@ -1324,50 +1408,55 @@ it('never counts an opaque third-party lspServers declaration as emitted by a ho " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'synthetic'],", + " targets: ['claude'],", '};', '', ].join('\n')); + // An advanced adapter is built one target per root (#555), so the Claude + // Code root and the synthetic root are inspected over the same project. 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'] })]); + 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([ + const synthetic = await readyInspection({ registry, root, targets: ['synthetic'] }); + expect(synthetic.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: ['synthetic'] })]); + expect(synthetic.plans.find((plan) => plan.target === 'synthetic')!.selected).toEqual(expect.arrayContaining([ 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 root projecting several hosts inherits a host's LSP declaration only + // through the host that lowers it: `codex.lspServers` reaches neither the + // Codex projection (unavailable) nor Claude Code, which lowers only + // `claude.lspServers`, so the shared root writes no `.lsp.json`. 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 +1561,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,11 +1629,15 @@ 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 }); - 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')) }), - ])); - } + // Claude Code and Codex share one root (#555): a single host-detecting + // wrapper at `hooks/` serves Claude Code's conventional hooks document and + // the Codex document relocated beside it. + expect(hooks.build.manifest.files).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: expect.stringMatching(/^hooks\/.+\.mjs$/u) }), + expect.objectContaining({ path: 'hooks/hooks.json' }), + expect.objectContaining({ path: '.codex-plugin/hooks.json' }), + ])); + expect(hooks.build.manifest.files.filter((file) => /^hooks\/.+\.mjs$/u.test(file.path))).toHaveLength(1); await expect(validate({ artifact: hookArtifact, root })).resolves.toEqual({ diagnostics: [] }); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -1582,10 +1675,13 @@ it('returns an output-independent project context without absolute project paths it('keeps rule and command model digests root-independent and sensitive to content', async () => { const [leftRoot, rightRoot] = await Promise.all([createProject(), createProject()]); + // Cursor beside Codex: Codex reads neither commands/ nor rules/, so a + // Cursor-only command shares the root without a scope conflict (AB4104 + // refuses Cursor commands beside Claude Code, which fills commands/ too). const config = [ 'export default {', " plugin: { name: 'rule-digest-fixture', version: '1.0.0' },", - " targets: ['cursor', 'claude'],", + " targets: ['cursor', 'codex'],", '};', '', ].join('\n'); @@ -1894,8 +1990,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 +2078,10 @@ 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), + // Every selected host shares the root's `scripts/` (#555): one copy serves + // Claude Code, Codex, and the portable view alike. + 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 +2114,31 @@ 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'], }), ])); + expect(manifest.files.filter((file) => file.path.endsWith('/scripts/shell.sh'))).toEqual([]); 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 +2167,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..17ab874b5 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { afterEach, expect, it } from '@rstest/core'; +import { compositeTargetName } from '../src/adapters/composite.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { build, inspect } from '../src/api.ts'; @@ -26,7 +27,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 @@ -179,95 +180,118 @@ 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) on the composite plugin root + * (#555): one build ships the compiled `src/cli/**` command graph once, as + * `bin/.mjs` (+ Flight worker) at the root every selected host + * shares, the bin runs end to end under `node`, a script route reaches it as + * a sibling, validation accepts the `bin/` layout, and `inspect` accounts + * for it as a `cli` component of every projected host. */ -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(); +it('emits the routed CLI bin once into the plugin root every capable host shares', { retry: 1, timeout: 300_000 }, async () => { + const root = await createFixture({ targets: hostTargets }); - const result = await build({ output: 'artifact', registry, root }); + const result = await build({ output: 'artifact', 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()); + // The root hosts one executable and one rendered-command worker; no host + // gets a namespaced copy of its own. + expect(result.build.compiledCliBins.map((bin) => bin.target)).toEqual([compositeTargetName(hostTargets)]); + const binPath = join(artifactRoot, 'bin', `${pluginName}.mjs`); + await expect(stat(binPath)).resolves.toMatchObject({}); + await expect(stat(join(artifactRoot, 'bin', `${pluginName}-flight.mjs`))).resolves.toMatchObject({}); 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); - - // `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', - }); + await expect(stat(join(artifactRoot, target, 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }); } - // 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({}); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'AB4765', - severity: 'warning', - target: 'legacy-host', - })); - expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + 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']); + expect(parseJsonLine(status.stdout)).toEqual({ + invocation: 'cli', + status: 'idle', + surface: 'status', + ticket: 'ticket-7', + }); // Help, version, and the rendered .tsx command ride the same executable. - const claudeBin = join(artifactRoot, 'claude', 'bin', `${pluginName}.mjs`); - const help = await execFile(process.execPath, [claudeBin, '--help']); + const help = await execFile(process.execPath, [binPath, '--help']); expect(help.stdout).toContain(`${pluginName} 3.8.7`); expect(help.stdout).toContain('Artifact routed CLI fixture.'); expect(help.stdout).toContain('status'); expect(help.stdout).toContain('report'); - await expect(execFile(process.execPath, [claudeBin, '--version'])).resolves.toMatchObject({ stdout: `${pluginName} 3.8.7\n` }); - const piped = await execFile(process.execPath, [claudeBin, 'report', '/builds']); + await expect(execFile(process.execPath, [binPath, '--version'])).resolves.toMatchObject({ stdout: `${pluginName} 3.8.7\n` }); + const piped = await execFile(process.execPath, [binPath, 'report', '/builds']); expect(piped.stdout).toBe('Found **3** builds under /builds.\n'); - const reportJson = await execFile(process.execPath, [claudeBin, 'report', '/builds', '--json']); + const reportJson = await execFile(process.execPath, [binPath, 'report', '/builds', '--json']); expect(parseJsonLine(reportJson.stdout)).toEqual({ builds: 3, root: '/builds' }); - await expect(execFile(process.execPath, [claudeBin, 'unknown'])).rejects.toMatchObject({ code: 2, stdout: '' }); + await expect(execFile(process.execPath, [binPath, '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'); + // The multi-host root's AGENTS.md documents the shared executable. + const agents = await readFile(join(artifactRoot, '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`); + // The manifest lists every projected host and inventories the bin once, + // with bundle provenance naming every command route. + expect(result.build.manifest.targets.map((target) => target.name).sort()).toEqual([...hostTargets].sort()); + 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.includes('/bin/'))).toEqual([]); - // 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 of the root. + const validation = await validateArtifact({ artifactRoot }); expect(validation.filter((entry) => entry.severity === 'error')).toEqual([]); - // `inspect` accounts for the bin as a `cli` component per target. + // `inspect` accounts for the bin as a `cli` component of every projected host. + const inspected = await inspect({ root }); + expect(inspected.state).toBe('ready'); + if (inspected.state !== 'ready') throw new Error('unreachable'); + for (const target of hostTargets) { + const plan = inspected.plans.find((candidate) => candidate.target === target); + expect(plan?.selected).toContainEqual({ + capability: { evidence: expect.objectContaining({ target }), name: 'cli', state: 'supported' }, + id: `bin:${pluginName}`, + kind: 'cli', + name: pluginName, + }); + } +}); + +/** + * A third-party host without the `cli` capability is built one target per + * root (#555): that root receives no `bin/` at all, while its other compiled + * surfaces are untouched, and the omission is reported — an AB4765 warning + * from the build and a skipped `cli` component from `inspect`. + */ +it('omits the routed CLI bin from a host without the cli capability and reports it', { retry: 1, timeout: 240_000 }, async () => { + const root = await createFixture({ targets: ['legacy-host'] }); + const registry = registryWithLegacyHost(); + + const result = await build({ output: 'artifact', registry, root }); + const artifactRoot = join(root, 'artifact'); + + expect(result.build.compiledCliBins).toEqual([]); + await expect(stat(join(artifactRoot, 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(join(artifactRoot, 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); + expect(result.build.manifest.files.some((file) => file.path.startsWith('bin/'))).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB4765', + severity: 'warning', + target: 'legacy-host', + })); + expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + const inspected = await inspect({ registry, root }); expect(inspected.state).toBe('ready'); if (inspected.state !== 'ready') throw new Error('unreachable'); - const claudePlan = inspected.plans.find((plan) => plan.target === 'claude'); - expect(claudePlan?.selected).toContainEqual({ - capability: { evidence: expect.objectContaining({ target: 'claude' }), name: 'cli', state: 'supported' }, - id: `bin:${pluginName}`, - kind: 'cli', - name: pluginName, - }); const legacyPlan = inspected.plans.find((plan) => plan.target === 'legacy-host'); expect(legacyPlan?.skipped).toContainEqual({ capability: { name: 'cli', reason: expect.stringContaining('publishes no cli capability row'), state: 'unavailable' }, @@ -276,31 +300,41 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew name: pluginName, reason: 'unsupported-capability', }); +}); - // `inspect --bundler` dumps the per-target bin composition beside the - // scripts; the npm package bin (no target) keeps its own entry. - const bundler = await inspect({ focus: 'bundler', registry, root }); +/** + * `inspect --bundler` describes the composition the build really runs: one + * Rslib pass over the root, so the bin and its worker appear once at their + * root-relative output paths, while the npm package bin (no target) keeps + * its own entry. + */ +it('inspects the routed CLI bin composition once for the plugin root', { timeout: 120_000 }, async () => { + const root = await createFixture({ targets: hostTargets }); + + const bundler = await inspect({ focus: 'bundler', root }); + expect(bundler.state).toBe('ready'); if (bundler.state !== 'ready') throw new Error('unreachable'); - const binEntries = (bundler.selected?.bundler?.entries ?? []) - .filter((entry) => entry.kind === 'bin' && entry.target !== undefined); - expect((bundler.selected?.bundler?.entries ?? []).some((entry) => + const entries = bundler.selected?.bundler?.entries ?? []; + expect(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()); + const binEntries = entries.filter((entry) => entry.kind === 'bin' && entry.target !== undefined); + 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 artifactRoot = 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. - const skill = await readFile(join(claudeRoot, 'skills', 'daemon-status', 'SKILL.md'), 'utf8'); + const skill = await readFile(join(artifactRoot, 'skills', 'daemon-status', 'SKILL.md'), 'utf8'); const reference = `\${CLAUDE_PLUGIN_ROOT}/bin/${pluginName}.mjs`; expect(skill).toContain(reference); - const binPath = join(claudeRoot, reference.slice('${CLAUDE_PLUGIN_ROOT}/'.length)); + const binPath = join(artifactRoot, reference.slice('${CLAUDE_PLUGIN_ROOT}/'.length)); await expect(stat(binPath)).resolves.toMatchObject({}); const status = await execFile(process.execPath, [binPath, 'status', '--json']); expect(parseJsonLine(status.stdout)).toEqual({ invocation: 'cli', status: 'idle', surface: 'status' }); @@ -313,7 +347,7 @@ it('lets a skill reach the artifact bin through the plugin-root token, and the b const probe = async (env: Readonly>): Promise => parseJsonLine((await execFile(process.execPath, [binPath, 'env-probe', '--json'], { env: { ...hostEnv, ...env } })).stdout); expect(await probe({})).toEqual({ atImport: 'unset', atRun: 'unset', providerAtImport: 'unset' }); - await writeFile(join(claudeRoot, '.env'), 'CLI_OPERATOR_TOKEN=from-file\n'); + await writeFile(join(artifactRoot, '.env'), 'CLI_OPERATOR_TOKEN=from-file\n'); expect(await probe({})).toEqual({ atImport: 'from-file', atRun: 'from-file', providerAtImport: 'from-file' }); expect(await probe({ CLI_OPERATOR_TOKEN: 'from-host' })).toEqual({ atImport: 'from-host', atRun: 'from-host', providerAtImport: 'from-host' }); expect(await probe({ AGENT_BUNDLE_ENV_FILE: 'none' })).toEqual({ atImport: 'unset', atRun: 'unset', providerAtImport: 'unset' }); diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 74cafca50..4114c6020 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,37 @@ 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', ]); expect(inspection.targets).toEqual([ expect.objectContaining({ name: fixtureTarget, tree: expect.objectContaining({ path: fixtureTarget }) }), ]); 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 +394,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 +450,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 +483,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 +534,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 +646,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 +675,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 +729,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 +753,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-root.test.ts b/packages/agent-bundle/tests/artifact-root.test.ts new file mode 100644 index 000000000..cc21742aa --- /dev/null +++ b/packages/agent-bundle/tests/artifact-root.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; +import { + hostMcpRuntime, + hostRootDirectory, + readArtifactRootContracts, + readArtifactTargets, +} from '../src/build/artifact-root.ts'; +import { writeFixtureManifest } from './support/manifest.ts'; + +const registry = createDefaultRegistry(); + +it('reads the targets a root declares and yields nothing where there is no manifest', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-root-')); + try { + // A host's namespaced view (portable/ beside other hosts) and a plain + // plugin directory carry no manifest: callers fall back to the host's + // conventional layout instead of failing. + expect(await readArtifactTargets(root)).toBeUndefined(); + expect(await readArtifactRootContracts(root, registry)).toBeUndefined(); + expect(hostRootDirectory(root, undefined, 'codex')).toBe(root); + expect(hostMcpRuntime(undefined, registry, 'codex')?.manifestPath).toBe('.mcp.json'); + + await writeFixtureManifest({ artifactRoot: root, targets: ['claude', 'codex', 'portable'] }); + expect(await readArtifactTargets(root)).toEqual(['claude', 'codex', 'portable']); + const contracts = await readArtifactRootContracts(root, registry); + expect(contracts?.name).toBe('claude+codex+portable'); + // Root hosts read the root itself; the portable view is its own directory. + expect(hostRootDirectory(root, contracts, 'claude')).toBe(root); + expect(hostRootDirectory(root, contracts, 'portable')).toBe(join(root, 'portable')); + // Codex beside Claude Code reads its relocated document, root-relative. + expect(hostMcpRuntime(contracts, registry, 'codex')?.manifestPath).toBe('.codex-plugin/mcp.json'); + expect(hostMcpRuntime(contracts, registry, 'claude')?.manifestPath).toBe('.mcp.json'); + expect(hostMcpRuntime(contracts, registry, 'portable')?.manifestPath).toBe('portable/mcp.json'); + + // A registry that knows none of the manifest's targets yields no contracts. + expect(await readArtifactRootContracts(root, new TargetRegistry())).toBeUndefined(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index d643e27ff..d5f4bfeb0 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -241,12 +241,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 +254,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 +276,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 +289,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,7 +302,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', + generatedPath: 'rules/review.md', target: customTarget, }), ); @@ -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,7 +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', + generatedPath: 'commands/review.mdc', target: customTarget, }), ); @@ -530,7 +530,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 +544,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 { @@ -580,9 +580,9 @@ it('returns frozen validated evidence without changing the diagnostics-only vali } }); -it('rejects a rehashed top-level artifact file outside declared target namespaces', async () => { +it('rejects a rehashed top-level artifact file outside the plugin root emitted layouts', 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 +602,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', target: customTarget }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -622,11 +622,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 +639,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 +656,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', target: customTarget }), ])); expect(unmanifestedDiagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -668,16 +668,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 +688,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 +712,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 +731,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 { @@ -751,14 +751,14 @@ it('settles promptly when the artifact manifest is a FIFO', async () => { } }); -it('rejects empty declared and undeclared target directories independently of manifest hashes', async () => { +it('rejects empty directories in bare and populated plugin roots 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 { - await mkdir(join(emptyRoot, customTarget)); + await mkdir(join(emptyRoot, 'skills')); await mkdir(join(declaredRoot, 'undeclared')); const [emptyDiagnostics, undeclaredDiagnostics] = await Promise.all([ @@ -767,10 +767,10 @@ 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: 'skills', target: customTarget }), ])); expect(undeclaredDiagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'undeclared' }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'undeclared', target: customTarget }), ])); expect([...emptyDiagnostics, ...undeclaredDiagnostics]).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -781,16 +781,16 @@ 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' }]; +it('rejects a nested empty directory under an otherwise valid plugin root', async () => { + 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', target: customTarget }), ]), ); } finally { @@ -802,20 +802,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', target: 'portable' }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -829,16 +829,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', target: 'portable' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -852,12 +852,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 +872,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 +905,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 +920,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 +947,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 { @@ -960,18 +960,18 @@ it('leaves an advanced registry adapter that reuses the portable name to its own } }); -it('admits nested project assets in the target-owned recursive asset namespace', async () => { +it('admits nested project assets in the recursive asset layout', 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: '\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 +984,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,7 +1008,7 @@ 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, }), @@ -1029,7 +1029,7 @@ 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 +1042,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,7 +1056,7 @@ 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, }), @@ -1093,9 +1093,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 { @@ -1155,19 +1155,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', target: coherenceTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-junk-deadbeef-flight.mjs', target: coherenceTarget }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -1177,18 +1177,25 @@ it('reports an orphan compiler MCP output after the artifact is rehashed', async } }); -it('does not attribute compiler MCP outputs to an equal-length sibling target', async () => { +/** + * Custom adapters are built one target per plugin root (#555), so a registry + * holding two of them validates each root by the contracts of the one target + * its manifest declares: a sibling's MCP document is neither read nor counted + * as a reference to the root's compiled servers. + */ +it('validates each custom target root by its own declared MCP runtime, not a registered sibling', async () => { const siblingTarget = 'neighbor'; const siblingMetadata = Object.freeze({ adapterRevision: 'neighbor-adapter-v1', observedVersion: 'neighbor-observed-v1', schemas: Object.freeze([]), }); + const siblingManifestTarget = Object.freeze({ ...siblingMetadata, name: siblingTarget }); const registry = coherenceRegistry().register({ artifactLayout: { mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' } }, capabilities: supportedCapabilities('mcp'), mcpRuntime: createTargetMcpRuntime({ - manifestPath: 'native/servers.json', + manifestPath: 'native/neighbor-servers.json', remoteTypes: ['streamable-http'], resolveValue: createMcpPathTokenResolver({ target: siblingTarget, tokens: {} }), }), @@ -1196,28 +1203,39 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', name: siblingTarget, plan: () => ({ diagnostics: [], entries: [] }), } satisfies TargetAdapter); - const root = await writeArtifact([ - { - contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', - kind: 'generated', - path: 'coherent/native/servers.json', - }, - { contents: 'export const coherent = true;\n', kind: 'bundle', path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, - { - contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', - kind: 'generated', - 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 })]); - - try { - expect(coherenceTarget).toHaveLength(siblingTarget.length); - expect(await validateArtifact({ artifactRoot: root, registry })).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6017' }), + const document = '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n'; + const entry = { contents: 'export const server = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-server-deadbeef.mjs' }; + const coherentRoot = await writeArtifact([ + { contents: document, kind: 'generated', path: 'native/servers.json' }, + entry, + ], true, [coherenceManifestTarget]); + const siblingRoot = await writeArtifact([ + { contents: document, kind: 'generated', path: 'native/neighbor-servers.json' }, + entry, + ], true, [siblingManifestTarget]); + const crossedRoot = await writeArtifact([ + { contents: document, kind: 'generated', path: 'native/servers.json' }, + entry, + ], true, [siblingManifestTarget]); + + try { + const [coherentDiagnostics, siblingDiagnostics, crossedDiagnostics] = await Promise.all([ + validateArtifact({ artifactRoot: coherentRoot, registry }), + validateArtifact({ artifactRoot: siblingRoot, registry }), + validateArtifact({ artifactRoot: crossedRoot, registry }), + ]); + expect(coherentDiagnostics).toEqual([]); + expect(siblingDiagnostics).toEqual([]); + expect(crossedDiagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6014', generatedPath: 'native/servers.json', target: siblingTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-server-deadbeef.mjs', target: siblingTarget }), ])); } finally { - await rm(root, { force: true, recursive: true }); + await Promise.all([ + rm(coherentRoot, { force: true, recursive: true }), + rm(siblingRoot, { force: true, recursive: true }), + rm(crossedRoot, { force: true, recursive: true }), + ]); } }); @@ -1234,12 +1252,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 +1265,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 +1273,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 +1305,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 +1324,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 +1347,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 +1369,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 +1394,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 +1403,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 +1415,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 +1434,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 +1443,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 +1455,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 +1465,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 +1479,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 +1488,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 +1500,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 +1515,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 +1529,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 +1544,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 +1561,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 +1598,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 +1609,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 +1625,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 +1636,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 +1648,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 +1667,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 +1682,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 +1691,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 +1711,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,16 +1722,26 @@ 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 }); } }); +/** + * One validation pass resolves the single-target root once per root-contract + * consumer — ownership, target contracts, the portable byte lane, MCP + * coherence, hook coherence, emitted Skills — and each resolution reads the + * registered artifact-validation snapshot. Pinning the count fixes the last + * registry re-entry a pass may make: a mutation scheduled one call later must + * never run, because it would land after the final evidence snapshots. + */ +const artifactValidationReadsPerPass = 6; + 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); @@ -1722,14 +1750,14 @@ it('does not re-enter artifact validation after taking final evidence snapshots' let artifactValidationCalls = 0; registry.artifactValidation = (target) => { artifactValidationCalls += 1; - if (artifactValidationCalls === 3) writeFileSync(manifestPath, '{"invalid":true}\n'); + if (artifactValidationCalls === artifactValidationReadsPerPass + 1) writeFileSync(manifestPath, '{"invalid":true}\n'); return artifactValidation(target); }; try { const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry }); - expect(artifactValidationCalls).toBe(2); + expect(artifactValidationCalls).toBe(artifactValidationReadsPerPass); expect(readFileSync(manifestPath)).toEqual(initialManifest); expect(result.diagnostics).toEqual([]); expect(result.snapshot).toBeDefined(); @@ -1740,22 +1768,22 @@ 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; registry.artifactValidation = (target) => { artifactValidationCalls += 1; - if (artifactValidationCalls === 3) mkdirSync(emptyDirectory); + if (artifactValidationCalls === artifactValidationReadsPerPass + 1) mkdirSync(emptyDirectory); return artifactValidation(target); }; try { const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry }); - expect(artifactValidationCalls).toBe(2); + expect(artifactValidationCalls).toBe(artifactValidationReadsPerPass); await expect(access(emptyDirectory)).rejects.toThrow(); expect(result.diagnostics).toEqual([]); expect(result.snapshot).toBeDefined(); @@ -1774,7 +1802,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 +1820,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 +1832,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 +1851,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 +1884,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 +1920,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 +1956,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 +1985,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 +1997,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 +2021,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 +2029,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 +2094,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 +2125,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 +2190,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 +2202,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 +2217,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 +2272,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 +2282,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,7 +2338,7 @@ 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(); @@ -2321,12 +2348,12 @@ const installSurfaceArtifact = async ( .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,7 +2361,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`, + generatedPath: 'INSTALL.md', target, }), ])); @@ -2344,7 +2371,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,7 +2379,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`, + generatedPath: 'install.mjs', target, }), ])); @@ -2393,9 +2420,9 @@ const claudeSettingsFiles = async (settings: string): Promise => { .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 +2440,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', })]); @@ -2440,14 +2467,14 @@ const claudeDependenciesFiles = async (dependencies: unknown): Promise = .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 +2497,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 +2506,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, @@ -2498,14 +2525,14 @@ it('fails artifact validation when a Cursor manifest logo is missing from the de .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', }), ])); @@ -2522,12 +2549,12 @@ it('accepts a Cursor manifest logo that resolves inside the artifact', async () .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..daaf33b41 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 { hostMcpRuntime, hostRootDirectory, readArtifactRootContracts } from '../src/build/artifact-root.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; @@ -148,8 +149,12 @@ class AgentBundleRemoteTransport implements Transport { } const generatedServer = async (artifact: string, target: string, serverName: string, workspaceRoot: string): Promise => { - const targetRoot = join(artifact, target); - const runtime = createDefaultRegistry().mcpRuntime(target); + // The built artifact is one plugin root (#555): the host's root and its MCP + // document come from the root contracts the manifest's targets select. + const registry = createDefaultRegistry(); + const contracts = await readArtifactRootContracts(artifact, registry); + const targetRoot = hostRootDirectory(artifact, contracts, target); + const runtime = hostMcpRuntime(contracts, registry, 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')); const result = readTargetMcpServer(runtime, document, serverName); diff --git a/packages/agent-bundle/tests/build-reproducibility.test.ts b/packages/agent-bundle/tests/build-reproducibility.test.ts index 793a78e77..4df3600b5 100644 --- a/packages/agent-bundle/tests/build-reproducibility.test.ts +++ b/packages/agent-bundle/tests/build-reproducibility.test.ts @@ -157,11 +157,12 @@ 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); expect(bundles).toEqual(expect.arrayContaining([ - 'claude/hooks/event-route-session-start.mjs', - 'portable/bin/reproducible-fixture.mjs', - 'portable/scripts/summarize.mjs', + 'hooks/event-route-session-start.mjs', + 'hooks/event-route-session-start.cursor.mjs', + 'bin/reproducible-fixture.mjs', + 'scripts/summarize.mjs', ])); const forbidden = [root, parked, '.artifact.stage-', ...stageTokens]; for (const path of bundles) { @@ -170,6 +171,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..3b0d31b49 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/'), + `src/${resource.path}`, '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', @@ -612,7 +612,7 @@ it('rejects hook entries stamped for a target other than their selected adapter' projectRoot: project.root, registry: new TargetRegistry().register(adapter, { default: true }), })).rejects.toThrow( - 'Agent Bundle compilation failed with 1 error:\n[AB5000] Target adapter "portable" planned hook "hook:malicious" for target "wrong-target", expected "portable".', + 'Agent Bundle compilation failed with 1 error:\n[AB5000] Plugin root "portable" planned hook "hook:malicious" for ["wrong-target"], which it does not project.', ); 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({ @@ -871,40 +871,43 @@ it('rejects duplicate planned destinations before replacing an existing artifact } }); -it('rejects an escaped target name before it can write outside the staging artifact', async () => { +it('never uses a target name as a path component of the plugin root', async () => { + // The output directory is the one plugin root (#555): a target name is never + // joined onto it, so even an escaping name cannot reach outside the artifact. const project = await createProject(); const targetName = '../escaped-target'; + const configPath = join(project.root, 'agent-bundle.config.ts'); const adapter: TargetAdapter = { + artifactLayout: { rootDocuments: ['plugin.json'] }, capabilities: {}, metadata: testAdapterMetadata, name: targetName, plan: () => ({ diagnostics: [], - entries: [{ content: 'escaped\n', kind: 'write', relativePath: 'plugin.json', sourceInputs: [] }], + entries: [{ content: '{"escaped":true}\n', kind: 'write', relativePath: 'plugin.json', sourceInputs: [configPath] }], hookEntries: [], }), }; try { - await mkdir(project.outputRoot, { recursive: true }); - await writeFile(join(project.outputRoot, 'previous.txt'), 'previous\n'); - - await expect( - build({ - model: { - ...modelFor(project), - scripts: [], - targets: [{ ...modelFor(project).targets[0]!, name: targetName }], - }, - outputRoot: project.outputRoot, - projectRoot: project.root, - registry: new TargetRegistry().register(adapter, { default: true }), - }), - ).rejects.toThrow(/outside/i); - await expect(readFile(join(project.outputRoot, 'previous.txt'), 'utf8')).resolves.toBe('previous\n'); + await build({ + model: { + ...modelFor(project), + scripts: [], + targets: [{ ...modelFor(project).targets[0]!, name: targetName }], + }, + outputRoot: project.outputRoot, + projectRoot: project.root, + registry: new TargetRegistry().register(adapter, { default: true }), + }); + await expect(readFile(join(project.outputRoot, 'plugin.json'), 'utf8')).resolves.toBe('{"escaped":true}\n'); await expect(readFile(join(project.root, 'escaped-target', 'plugin.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT', }); + await expect(readdir(project.root)).resolves.not.toContain('escaped-target'); + const manifest = parseArtifactManifest(await readFile(join(project.outputRoot, 'agent-bundle.manifest.json'), 'utf8')); + expect(manifest.targets.map((target) => target.name)).toEqual([targetName]); + expect(manifest.files.map((file) => file.path)).toContain('plugin.json'); } finally { await cleanupProject(project); } @@ -964,7 +967,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 +1314,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 f6272bef3..8a815a0a2 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,7 @@ 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 { createCompositeAdapter } from '../src/adapters/composite.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'; @@ -122,7 +122,7 @@ const routeHook = ( }; }; -const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlugin => ({ +const model = (target: string | readonly string[], hooks: readonly NormalizedHook[]): NormalizedPlugin => ({ extensions: {}, hooks, mcpServers: [], @@ -136,13 +136,16 @@ const model = (target: string, hooks: readonly NormalizedHook[]): NormalizedPlug runtime: { node: '22.12.0' }, scripts: [], skills: [], - targets: [{ - id: `target:${target}`, - name: target, + targets: (typeof target === 'string' ? [target] : target).map((name) => ({ + id: `target:${name}`, + name, provenance: { kind: 'config', sourcePath: configPath }, - }], + })), }); +const allHosts = ['claude', 'codex', 'cursor'] as const; +const compositeAdapter = createCompositeAdapter(allHosts); + const nativeEnvelope = async ( native: string | Readonly>, ): Promise>> => @@ -209,10 +212,10 @@ 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']), +it('keeps the shared and Cursor wrappers of one composite root on their own host spellings', () => { + const plan = compositeAdapter.plan(model(allHosts, [ + routeHook('tool/after', 'afterTool', [...allHosts]), + routeHook('session/start', 'sessionStart', [...allHosts]), ])); expect(plan.diagnostics).toEqual([]); @@ -227,6 +230,8 @@ it('keeps the shared and Cursor wrappers of the unified plugin bundle on their o const documents = writes(plan); expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']); + // Codex reads its own document beside Claude Code's conventional one (#555). + expect(Object.keys((JSON.parse(documents['.codex-plugin/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']); }); @@ -242,12 +247,13 @@ it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hook 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'])]))); + const bundle = writes(compositeAdapter.plan(model(allHosts, [routeHook('tool/after', 'afterTool', [...allHosts])]))); expect(bundle['hooks/hooks.json']).toBeDefined(); + expect(bundle['.codex-plugin/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'); + // Claude Code owns the conventional file; Codex and Cursor read their own + // documents through explicit manifest pointers (#555). + expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).toMatchObject({ hooks: './.codex-plugin/hooks.json' }); expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' }); }); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index d79765619..5ad3b6e6e 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -394,9 +394,9 @@ 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({}); + // the plugin root with the same output contract. + 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 1a2b8d902..edc078c9f 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -701,7 +701,8 @@ 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' } }, + // Scripts compile into the one plugin root (#555), not a `/` directory. + output: { distPath: { root: '' } }, tools: { rspack: [ { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, 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 { + // A root shared with Claude Code: Claude's `.mcp.json` carries an `http` + // server Codex's schema rejects, while Codex's own documents live under + // `.codex-plugin/` where `.codex-plugin/plugin.json` points. + const pluginDirectory = await writeBundle({ + '.codex-plugin/plugin.json': { + ...validDocuments['.codex-plugin/plugin.json'], + hooks: './.codex-plugin/hooks.json', + mcpServers: './.codex-plugin/mcp.json', + }, + '.codex-plugin/hooks.json': validDocuments['hooks/hooks.json'], + '.codex-plugin/mcp.json': validDocuments['.mcp.json'], + '.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 }); + } + + // A pointer that escapes the plugin is ignored in favour of the + // conventional document (the manifest itself is rejected separately). + const escaping = await writeBundle({ + '.codex-plugin/plugin.json': { ...validDocuments['.codex-plugin/plugin.json'], mcpServers: './../outside.json' }, + '.mcp.json': { mcpServers: { fixture: { type: 'streamable-http', url: 'not a uri' } } }, + }); + try { + const report = await validateCodexPlugin({ pluginDirectory: escaping, run: runWith('match').run, target: 'codex' }); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6032', generatedPath: '.mcp.json', severity: 'error' }), + ])); + } finally { + await rm(escaping, { 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/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index c70990fe3..c02a3b71f 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -9,7 +9,7 @@ import { cursorPluginValidator, isValidCursorPluginName, } from '../src/adapters/cursor.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; +import { createCompositeAdapter } from '../src/adapters/composite.ts'; import { readTargetMcpServers } from '../src/services/mcp-runtime.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; @@ -70,6 +70,20 @@ const plugin = (): NormalizedPlugin => ({ ], }); +/** + * The same model projected into one root beside Claude Code (#555): the + * composite's Cursor side, with the shared skills selecting both hosts. + */ +const compositeAdapter = createCompositeAdapter(['claude', 'cursor']); +const composite = (model: NormalizedPlugin): NormalizedPlugin => ({ + ...model, + skills: model.skills.map((skill) => ({ ...skill, targets: [...new Set([...skill.targets, 'claude'])] })), + targets: [ + ...model.targets.filter((target) => target.name !== 'plugin'), + { id: 'target:claude', name: 'claude', provenance: { kind: 'config', sourcePath: configPath } }, + ], +}); + const writeContents = (model: NormalizedPlugin): Record => Object.fromEntries( cursorAdapter.plan(model).entries .filter((entry): entry is Extract => entry.kind === 'write') @@ -78,7 +92,7 @@ const writeContents = (model: NormalizedPlugin): Record => 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,20 +130,16 @@ 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' }, + expect(compositeAdapter.plan(composite(named(overLong))).diagnostics.filter((entry) => entry.code === 'composite.cursor.name')).toEqual([ + { code: 'composite.cursor.name', message: cursorPluginNameError(overLong), severity: 'error' }, ]); - for (const plan of [cursorAdapter.plan(named(boundary)), pluginAdapter.plan(named(boundary))]) { + for (const plan of [cursorAdapter.plan(named(boundary)), compositeAdapter.plan(composite(named(boundary)))]) { expect(plan.diagnostics.filter((entry) => entry.code.endsWith('cursor.name'))).toEqual([]); } }); @@ -216,7 +226,7 @@ it('registers the cursor config extension and emits schema-admitted manifest met const manifestEntry = plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json'); expect(manifestEntry?.sourceInputs).toContain(configPath); - const bundle = pluginAdapter.plan(model); + const bundle = compositeAdapter.plan(composite(model)); expect(bundle.diagnostics).toEqual([]); const bundleManifest = JSON.parse( (bundle.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, @@ -253,7 +263,7 @@ 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(compositeAdapter.plan(composite(model)).diagnostics.map((diagnostic) => diagnostic.code)).toContain('composite.cursor.manifest.author.invalid'); expect(cursorAdapter.plan(withCursorConfig({ minClientVersions: {} })).diagnostics.map((diagnostic) => diagnostic.code)) .toEqual(['cursor.manifest.minClientVersions.invalid']); diff --git a/packages/agent-bundle/tests/dev-artifact-service.test.ts b/packages/agent-bundle/tests/dev-artifact-service.test.ts index 1618ce288..13eece19b 100644 --- a/packages/agent-bundle/tests/dev-artifact-service.test.ts +++ b/packages/agent-bundle/tests/dev-artifact-service.test.ts @@ -107,7 +107,8 @@ 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')) + // The epoch directory is the plugin root itself (#555). + 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 +212,15 @@ 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 }); + // A portable-only artifact is the plugin root itself (#555): its manifest + // and install surface sit at the root, not under `portable/`. 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 +465,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..d31d96c9a 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -69,8 +69,9 @@ const writeEpoch = async ( id: string, values: { readonly hook: string; readonly skill: string }, ): Promise => { + // The epoch is the plugin root itself (#555): the host reads it directly. const root = join(projectRoot, '.agent-bundle', 'epochs', id); - const target = join(root, 'cursor'); + const target = root; await Promise.all([ mkdir(join(target, '.cursor-plugin'), { recursive: true }), mkdir(join(target, 'hooks'), { recursive: true }), diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 5f535749c..735f02ece 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -975,16 +975,18 @@ 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 () => { +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 { - // `/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. + // 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 is never probed. const artifactRoot = join(fixture.root, 'artifact'); await mkdir(artifactRoot, { recursive: true }); - const bundle = join(artifactRoot, 'claude'); - await mkdir(bundle, { recursive: true }); + const bundle = artifactRoot; + 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(bundle, 'payload.txt'), 'payload\n'); await writeJson(join(bundle, '.claude-plugin/plugin.json'), { author: { name: 'Doctor Fixture' }, @@ -1015,7 +1017,7 @@ it('lists Claude plugins from the resolved host bundle root when --from names a expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle }), ])); expect(calls).not.toEqual(expect.arrayContaining([ - expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: artifactRoot }), + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: join(artifactRoot, 'claude') }), ])); } finally { await fixture.cleanup(); 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..f1a26eca9 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,14 @@ type ArtifactClass = | 'mcp-framework-shell' | 'mcp-raw-stdio-server'; +/** + * Classes are fixed by the root-relative layout of the one plugin root (#555). + * A host's namespaced view (`portable/mcp/.mjs`) holds re-export shims + * of the root bundles, not bundles, so a three-segment path classifies as + * nothing. + */ 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..06c14bdaf 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 its manifest is missing on disk', async () => { const root = await mkdtemp(join(tmpdir(), 'agent bundle inconsistent active epoch ')); try { @@ -1084,7 +1084,9 @@ 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 }); + // Every target reads the epoch root itself (#555), so the root's manifest + // is the on-disk evidence the active metadata must still agree with. + 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..2e103ff04 100644 --- a/packages/agent-bundle/tests/eval-claude-harness.test.ts +++ b/packages/agent-bundle/tests/eval-claude-harness.test.ts @@ -216,7 +216,8 @@ 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'), + // The built artifact is the plugin root Claude Code loads (#555). + 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..bcd5e2a07 100644 --- a/packages/agent-bundle/tests/eval-codex-harness.test.ts +++ b/packages/agent-bundle/tests/eval-codex-harness.test.ts @@ -30,12 +30,13 @@ const marketplace = { }; interface TrialWorld { + /** The built plugin root; every host reads it directly (#555). */ + readonly artifact: string; readonly commands: CodexCommandInput[]; readonly normalCodexHome: string; readonly observed: { authMode?: number; authSize?: number; codexHome?: string; pluginRoots: string[] }; readonly root: string; readonly suiteDir: string; - readonly target: string; } const evalCase = (invocation: EvalCase['invocation'] = { mode: 'automatic' }): EvalCase => normalizeEvalCase({ @@ -55,26 +56,26 @@ 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'); + const artifact = 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 }); - await mkdir(join(target, 'skills', 'release-notes'), { recursive: true }); + await mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }); + await mkdir(join(artifact, 'skills', 'release-notes'), { recursive: true }); await mkdir(join(normalCodexHome, 'plugins'), { recursive: true }); await writeFile(join(suiteDir, 'fixtures', 'repo', 'input.txt'), 'release me\n'); - await writeFile(join(target, '.agents', 'plugins', 'marketplace.json'), `${JSON.stringify(marketplace)}\n`); - await writeFile(join(target, 'skills', 'release-notes', 'SKILL.md'), '---\nname: release-notes\n---\n'); + await writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), `${JSON.stringify(marketplace)}\n`); + await writeFile(join(artifact, 'skills', 'release-notes', 'SKILL.md'), '---\nname: release-notes\n---\n'); await writeFile(join(normalCodexHome, 'auth.json'), '{"opaque":"session"}\n'); await chmod(join(normalCodexHome, 'auth.json'), 0o600); await writeFile(join(normalCodexHome, 'config.toml'), 'model = "gpt-5-codex"\n'); await writeFile(join(normalCodexHome, 'plugins', 'installed.json'), '{"installed":[]}\n'); return { + artifact, commands: [], normalCodexHome, observed: { pluginRoots: [] }, root, suiteDir, - target, }; }; @@ -136,7 +137,7 @@ const runTrial = async ( return await runCodexEvalTrial({ artifact: { binding: { manifestPath: 'pending', source: 'explicit', targetDigests: { codex: 'target-digest' } }, - root: join(world.root, 'artifact'), + root: world.artifact, }, environment: { ANTHROPIC_API_KEY: 'must-not-reach-the-child', diff --git a/packages/agent-bundle/tests/eval-native-mount.test.ts b/packages/agent-bundle/tests/eval-native-mount.test.ts index 57b1a0318..241c70238 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 run-owned artifact root is the plugin root itself; no per-target directory (#555). + 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,8 @@ 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 }); + // Preflight runs in the plugin root; dropping its Claude manifest makes the candidate unreadable. + 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..ecee37289 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -1,7 +1,7 @@ import { execFile as executeFile } from 'node:child_process'; import { cp, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { promisify } from 'node:util'; import { Client } from '@modelcontextprotocol/client'; @@ -137,23 +137,26 @@ it('publishes the MCP App example service readiness across targets and returns d }, state: 'ready', }); - for (const target of ['portable', 'codex', 'claude'] as const) { - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'SKILL.md'), 'utf8')) + // One plugin root (#555): Codex and Claude read the skills and assets at + // the root, while the portable projection beside them is the namespaced + // `portable/` view. Scripts are shared by every projection at the root. + for (const projection of ['', 'portable'] as const) { + await expect(readFile(join(output, projection, 'skills', 'service-readiness', 'SKILL.md'), 'utf8')) .resolves.toContain('# Service readiness'); - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'references', 'status-policy.md'), 'utf8')) + await expect(readFile(join(output, projection, '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')) + await expect(readFile(join(output, projection, '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')) + await expect(readFile(join(output, projection, 'assets', 'evals', 'fixtures', 'status', 'result.json'), 'utf8')) .resolves.toContain('"Compiler service is ready for release."'); } + await expect(readFile(join(output, 'scripts', 'check-service-fixture.mjs'), 'utf8')) + .resolves.toContain('Compiler fixture is healthy.'); 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 +166,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,21 +178,34 @@ 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']); - await Promise.all(built.build.compiledMcpEntries.map(({ output: mcpOutput }) => - expect(readFile(mcpOutput, 'utf8')).resolves.toContain('payments-api'), - )); - expect(built.build.compiledHooks.map(({ target }) => target).sort()).toEqual(['claude', 'codex']); - await Promise.all(built.build.compiledHooks.map(({ output: hookOutput }) => - expect(readFile(hookOutput, 'utf8')).resolves.toContain('Service readiness session'), - )); + // One root compiles each MCP server and app once; every host document + // points at the same `mcp/` entry (the portable view through its shim). + expect(built.build.compiledMcpApps).toMatchObject([{ name: 'status' }]); + expect(built.build.compiledMcpEntries).toHaveLength(1); + const [mcpEntry] = built.build.compiledMcpEntries; + expect(relative(output, mcpEntry!.output)).toMatch(/^mcp\/mcp-status-[a-f\d]{8}\.mjs$/u); + await expect(readFile(mcpEntry!.output, 'utf8')).resolves.toContain('payments-api'); + await expect(readFile(join(output, 'portable', relative(output, mcpEntry!.output)), 'utf8')) + .resolves.toBe(`import '../../${relative(output, mcpEntry!.output)}';\n`); + // Claude Code and Codex share one host-detecting wrapper, which the hook + // index lists once per host it serves. + expect(built.build.compiledHooks).toHaveLength(1); + const wrapperPath = relative(output, built.build.compiledHooks[0]!.output); + expect(wrapperPath).toMatch(/^hooks\/session-start-[^/]+\.mjs$/u); + await expect(readFile(built.build.compiledHooks[0]!.output, 'utf8')).resolves.toContain('Service readiness session'); + const hookIndex = JSON.parse(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')) as { + readonly hooks: readonly { readonly path: string; readonly target: string }[]; + }; + expect(hookIndex.hooks.map(({ path, target }) => ({ path, target }))).toEqual([ + { path: wrapperPath, target: 'claude' }, + { path: wrapperPath, target: 'codex' }, + ]); await expect(runEvals({ artifact: output, caseIds: ['status-is-healthy'], @@ -226,9 +242,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 +264,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 +322,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..17f3732f0 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 @@ -19,5 +19,5 @@ export default { }, skills: ['src/skills/probe'], // `plugin` is the unified bundle whose Cursor manifest names `hooks/hooks-cursor.json` (#438). - targets: ['claude', 'codex', 'cursor', 'plugin'], + 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..798332309 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -138,7 +138,8 @@ 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]!); + // The plugin root is the artifact itself (#555): `${PLUGIN_ROOT}/mcp/.mjs`. + 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 +284,8 @@ 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]); + // Every target reads the one plugin root (#555): the entry sits at the root. + const entry = join(output, server.args[0]); const connection = await connectGeneratedEntry(entry); return { client: connection.client, @@ -379,7 +381,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 +389,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 +486,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 +929,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 }); @@ -1070,12 +1072,21 @@ it('renders one tool/after event route through two native thin clients', { retry const output = join(root, 'artifact'); const compiled = await build({ output, root, targets: ['claude', 'cursor'] }); expect(compiled.model.hooks.filter((hook) => hook.eventRoute !== undefined)).toHaveLength(2); + // One composite root (#555): Claude Code's wrapper and Cursor's dedicated + // `.cursor.mjs` wrapper both dispatch into the one shared compiled server. expect(compiled.build.compiledHooks.filter((hook) => hook.id === 'hook:event-route:tool-after')).toHaveLength(2); + expect(compiled.build.compiledMcpEntries).toEqual([expect.objectContaining({ + hosts: ['claude', 'cursor'], + target: 'claude+cursor', + })]); + const mcp = compiled.build.compiledMcpEntries[0]!; + expect(dirname(mcp.output)).toBe(join(output, 'mcp')); + await expect(readFile(mcp.output, 'utf8')).resolves.toContain('agent-bundle-event-'); + // The event endpoint is named after the root's identity, not one host. + const endpointId = `${compiled.build.manifest.project.revision}:${mcp.target}:${dirname(dirname(resolve(mcp.output)))}`; 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); @@ -1087,7 +1098,6 @@ 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}:${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 }); @@ -1166,7 +1176,8 @@ 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'] });", + // The composite root of every concrete host that dispatches events (#555). + "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 +1195,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 +1207,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 +1219,27 @@ 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 compiled = await build({ output, root, targets: ['claude', 'codex', 'cursor'] }); + // One server serves every host; Claude Code and Codex share one + // host-detecting wrapper while Cursor gets its own `.cursor.mjs` wrapper. + expect(compiled.build.compiledMcpEntries).toEqual([expect.objectContaining({ + hosts: ['claude', 'codex', 'cursor'], + target: 'claude+codex+cursor', + })]); + const mcp = compiled.build.compiledMcpEntries[0]!; 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'))!; + expect(sharedAfter).toMatchObject({ hosts: ['claude', 'codex'] }); + expect(sharedSession).toMatchObject({ hosts: ['claude', '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}:${mcp.target}:${dirname(dirname(resolve(mcp.output)))}`; await expect(requestEventRuntime({ artifactEpoch: compiled.build.manifest.project.revision, endpointId, @@ -1450,12 +1469,21 @@ it('replays Claude and Codex subagent fixtures through standalone event-route wr const output = join(root, 'artifact'); const compiled = await build({ output, root, targets: ['claude', 'codex'] }); - expect(compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStart')).toHaveLength(2); - expect(compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStop')).toHaveLength(2); + // A hook selecting Claude Code and Codex compiles to one host-detecting + // wrapper that both host documents reference (#555). + const [start, ...moreStarts] = compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStart'); + const [stop, ...moreStops] = compiled.build.compiledHooks.filter((hook) => hook.event === 'agentStop'); + expect(moreStarts).toEqual([]); + expect(moreStops).toEqual([]); + expect(start).toMatchObject({ hosts: ['claude', 'codex'] }); + expect(stop).toMatchObject({ hosts: ['claude', 'codex'] }); for (const target of ['claude', 'codex'] as const) { - const start = compiled.build.compiledHooks.find((hook) => hook.target === target && hook.event === 'agentStart')!; - const stop = compiled.build.compiledHooks.find((hook) => hook.target === target && hook.event === 'agentStop')!; + // Codex exports PLUGIN_ROOT into hook processes and Claude Code does not, + // which is how the shared wrapper tells the invoking host apart. + const env = target === 'codex' + ? { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output } + : { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }; const startInput = JSON.parse(await readFile( new URL(`./fixtures/events/${target}-subagent-start.json`, import.meta.url), 'utf8', @@ -1465,13 +1493,13 @@ it('replays Claude and Codex subagent fixtures through standalone event-route wr 'utf8', )) as Record; - await expect(runHook(start.output, startInput)).resolves.toEqual({ + await expect(runHook(start!.output, startInput, env)).resolves.toEqual({ hookSpecificOutput: { additionalContext: `${String(startInput.session_id)}:${String(startInput.agent_id)}:${String(startInput.agent_type)}`, hookEventName: 'SubagentStart', }, }); - await expect(runHook(stop.output, stopInput)).resolves.toEqual({ + await expect(runHook(stop!.output, stopInput, env)).resolves.toEqual({ decision: 'block', reason: `Review ${String(stopInput.agent_id)} once more.`, }); @@ -1494,13 +1522,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 +1537,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 +1546,12 @@ 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 compiled = await build({ output, root, targets: ['claude', 'codex'] }); + // The composite root's one wrapper per event serves both hosts (#555). const start = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStart')!; const stop = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStop')!; + expect(compiled.build.compiledHooks).toHaveLength(2); + expect(start).toMatchObject({ hosts: ['claude', 'codex'] }); for (const target of ['claude', 'codex'] as const) { const input = JSON.parse(await readFile( diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index d12f11701..2a309e265 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -170,12 +170,12 @@ 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))), - ]), - ))); + // Every target reads the same plugin root (#555): one digest per target, + // each computed over the root's file listing and keyed by the target name. + const files = await listArtifactFiles(artifact); + const targetDigests = Object.freeze(Object.fromEntries( + ['claude', 'codex'].map((target) => [target, digest({ files, target })]), + )); 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 }))); @@ -282,6 +282,8 @@ it('uses the injected adapter hook contract for custom manifests, mappings, matc mkdir(dirname(join(sourceArtifact, manifestPath)), { recursive: true }), mkdir(dirname(join(sourceArtifact, hook.path)), { recursive: true }), ]); + // The synthetic artifact is the plugin root itself (#555): its hook + // document and wrapper sit beside the artifact manifest at the root. await Promise.all([ writeFile(join(sourceArtifact, manifestPath), `${JSON.stringify({ hooks: { @@ -292,16 +294,17 @@ 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', { + synthetic: digest({ files: await listArtifactFiles(sourceArtifact), target: 'synthetic' }), + }), targets: ['synthetic'], }); - await Promise.all([ - cp(sourceArtifact, join(staging.root, 'synthetic'), { recursive: true }), - writeFile(join(staging.root, 'agent-bundle.manifest.json'), '{}\n'), - ]); + await Promise.all((await readdir(sourceArtifact)).map((entry) => + cp(join(sourceArtifact, entry), join(staging.root, entry), { recursive: true }))); await staging.publish(async () => undefined); const hookService = { @@ -417,7 +420,8 @@ 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`, + // One host-detecting wrapper serves Claude Code and Codex from the root (#555). + wrapperPath: `hooks/${epochOne.hooks.beforeTool.name}.mjs`, }, nativeInput: { cwd: '/workspace', @@ -542,7 +546,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}.mjs`, }); await expect(runNativeHook( join(root, '.agent-bundle', 'epochs', 'epoch-1', trace.hostMapping.wrapperPath), @@ -583,7 +587,7 @@ 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`; + const wrapperPath = `hooks/${epoch.hooks.beforeTool.name}.mjs`; const wrapper = join(epochRoot, wrapperPath); const tamperedWrapper = "process.stdout.write('');\n"; await writeFile(wrapper, tamperedWrapper); @@ -689,7 +693,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.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..f59440eba 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -9,7 +9,7 @@ import { expect, it, rs } from '@rstest/core'; import { rspack } from '@rslib/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; -import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; +import { hostDetectionSource, 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'; @@ -159,12 +159,7 @@ it('keeps the Claude and Codex native wrapper codecs byte-identical apart from i 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 hostDetectionLines = new Set(hostDetectionSource); const universalWithoutHostDetection = universalSource .split('\n') .filter((line) => !hostDetectionLines.has(line)) @@ -178,6 +173,13 @@ const runPublishedHook = async (wrapper: string, input: string) => runNodeScript const runNativeHook = async (wrapper: string, input: Record) => runNodeScript({ args: [wrapper], input: JSON.stringify(input) }); +/** + * A root projecting Claude Code and Codex compiles one host-detecting wrapper + * per hook (#555); the harness pin runs it as one host deterministically. + */ +const runNativeHookAs = async (target: 'claude' | 'codex', wrapper: string, input: Record) => + runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_HOOK_HOST: target }, input: JSON.stringify(input) }); + const importPublishedHook = async (wrapper: string) => runNodeScript({ args: ['--input-type=module', '--eval', `await import(${JSON.stringify(pathToFileURL(wrapper).href)}); process.exit(0);`], @@ -710,7 +712,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.mjs'))).resolves.toEqual({ code: 0, stderr: '', stdout: '', @@ -727,9 +729,12 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a expect.objectContaining({ event: 'sessionStart', target: 'codex' }), 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', - }); + // One shared wrapper serves both hosts, so the index lists the same path per host. + for (const target of ['claude', 'codex'] as const) { + expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.target === target)).toMatchObject({ + path: 'hooks/session-start-session-start-7ab7e8a5.mjs', + }); + } const epochMarker = join(artifact, '.agent-bundle-epoch-stage.json'); await writeFile(epochMarker, '{"token":"00000000-0000-4000-8000-000000000000"}\n'); await expect(service.list({ artifact })).rejects.toThrow(/artifact files do not match/i); @@ -773,7 +778,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.mjs'), 'broken'); await expect(service.simulate({ artifact, hook: 'hook:session-start:session-start:7ab7e8a5', @@ -1028,16 +1033,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); - 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'); - expect(wrapper).toContain('compiled from local TypeScript'); - expect(wrapper).not.toMatch(/from\s+['"](?:agent-bundle|@rstackjs\/|@rspack\/)[^'"]*['"]/); - } + // Claude Code and Codex share one wrapper per hook at the root's hooks/. + const hooksRoot = join(outputRoot, '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'); + expect(wrapper).toContain('compiled from local TypeScript'); + expect(wrapper).not.toMatch(/from\s+['"](?:agent-bundle|@rstackjs\/|@rspack\/)[^'"]*['"]/); } expect(await readdir(join(root, 'src', 'hooks'))).toEqual([ 'check-command.ts', @@ -1085,17 +1089,19 @@ 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; - for (const target of ['codex', 'claude']) { - const pluginRoot = join(outputRoot, target); - const wrapper = join(pluginRoot, 'hooks', 'session-start-session-start-7ab7e8a5.mjs'); + // The root is the plugin root for both hosts; one shared wrapper serves them. + const pluginRoot = outputRoot; + const wrapper = join(pluginRoot, 'hooks', 'session-start-session-start-7ab7e8a5.mjs'); + for (const target of ['codex', 'claude'] as const) { + const hostPin = { AGENT_BUNDLE_HOOK_HOST: target }; // No file: the wrapper is a no-op and the handler sees the host environment only. - const withoutFile = await runNativeHook(wrapper, event); + const withoutFile = await runNativeHookAs(target, wrapper, event); expect(withoutFile).toMatchObject({ code: 0, stderr: '' }); expect(context(withoutFile)).toBe('unset=unset:unset'); // `/.env` fills the gap; an exported variable still wins. await writeFile(join(pluginRoot, '.env'), 'OPERATOR_TOKEN=from-file\nHOST_WINS=from-file\n'); - const withFile = await runNodeScript({ args: [wrapper], env: { HOST_WINS: 'host' }, input: JSON.stringify(event) }); + const withFile = await runNodeScript({ args: [wrapper], env: { ...hostPin, HOST_WINS: 'host' }, input: JSON.stringify(event) }); expect(withFile).toMatchObject({ code: 0, stderr: '' }); expect(context(withFile)).toBe('from-file=from-file:host'); @@ -1103,15 +1109,16 @@ it('applies the operator .env layer of the installed pack before a hook handler const elsewhere = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-operator-env-anchor-')); try { await writeFile(join(elsewhere, '.env'), 'OPERATOR_TOKEN=from-anchor\n'); - const anchored = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_PLUGIN_ROOT: elsewhere }, input: JSON.stringify(event) }); + const anchored = await runNodeScript({ args: [wrapper], env: { ...hostPin, AGENT_BUNDLE_PLUGIN_ROOT: elsewhere }, input: JSON.stringify(event) }); expect(context(anchored)).toBe('from-anchor=from-anchor:unset'); // An explicit AGENT_BUNDLE_ENV_FILE replaces the convention; `none` disables the layer. - const explicit = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_ENV_FILE: join(elsewhere, '.env') }, input: JSON.stringify(event) }); + const explicit = await runNodeScript({ args: [wrapper], env: { ...hostPin, AGENT_BUNDLE_ENV_FILE: join(elsewhere, '.env') }, input: JSON.stringify(event) }); expect(context(explicit)).toBe('from-anchor=from-anchor:unset'); - const disabled = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_ENV_FILE: 'none' }, input: JSON.stringify(event) }); + const disabled = await runNodeScript({ args: [wrapper], env: { ...hostPin, AGENT_BUNDLE_ENV_FILE: 'none' }, input: JSON.stringify(event) }); expect(context(disabled)).toBe('unset=unset:unset'); } finally { await rm(elsewhere, { force: true, recursive: true }); + await rm(join(pluginRoot, '.env'), { force: true }); } } } finally { @@ -1144,16 +1151,16 @@ 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'); + for (const target of ['codex', 'claude'] as const) { + await expect(runNativeHookAs(target, join(hooksRoot, 'session-start-session-start-7ab7e8a5.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(runNativeHookAs(target, join(hooksRoot, 'before-tool-check-command-1f5b5818.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 +1170,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(runNativeHookAs(target, join(hooksRoot, 'before-tool-check-command-1f5b5818.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(runNativeHookAs(target, join(hooksRoot, 'before-tool-check-command-1f5b5818.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(runNativeHookAs(target, join(hooksRoot, 'after-tool-record-87785f02.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(runNativeHookAs(target, join(hooksRoot, 'stop-stop-bb2d7935.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 +1195,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(runNativeHookAs('codex', join(hooksRoot, 'after-tool-record-87785f02.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, @@ -1311,8 +1318,11 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + // Claude Code keeps the conventional hooks/hooks.json; Codex beside it + // reads .codex-plugin/hooks.json. Both name the same shared wrappers. + const hookDocuments = { claude: join('hooks', 'hooks.json'), codex: join('.codex-plugin', 'hooks.json') } as const; for (const target of ['codex', 'claude'] as const) { - const manifest = JSON.parse(await readFile(join(outputRoot, target, 'hooks', 'hooks.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(outputRoot, hookDocuments[target]), 'utf8')) as { readonly hooks: Readonly>; }; expect(manifest.hooks.SubagentStart).toHaveLength(1); @@ -1327,7 +1337,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(runNativeHookAs(target, join(outputRoot, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1337,7 +1347,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(runNativeHookAs(target, join(outputRoot, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1403,7 +1413,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, 'hooks', 'hooks.json'), 'utf8')) as { readonly hooks: Readonly>; readonly version: number; }; @@ -1420,7 +1430,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 +1441,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 +1449,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 +1473,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 +1489,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 +1498,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 +1507,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 +1520,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: '', @@ -1537,14 +1547,14 @@ 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'); + for (const target of ['codex', 'claude'] as const) { + await expect(runNativeHookAs(target, join(hooksRoot, 'session-start-session-start-7ab7e8a5.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(runNativeHookAs(target, join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), { ...common, hook_event_name: 'SessionStart', })).resolves.toEqual({ code: 1, @@ -1555,7 +1565,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(runNativeHookAs(target, join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { ...common, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'use-1', ...(target === 'codex' ? {} : { tool_input: [] }), })).resolves.toEqual({ @@ -1563,14 +1573,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(runNativeHookAs(target, join(hooksRoot, 'after-tool-record-87785f02.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(runNativeHookAs(target, join(hooksRoot, 'stop-stop-bb2d7935.mjs'), { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: 'false', })).resolves.toEqual({ code: 1, @@ -1614,7 +1624,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 +1673,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 +1693,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, diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 1e8ab93a1..5a419122e 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -1196,7 +1196,7 @@ 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); @@ -3024,7 +3024,7 @@ it('reports malformed remote MCP URLs through independently validated host schem expect(claude.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); }); -it('filters host components and builds portable, Codex, and Claude target roots', async () => { +it('filters host components and builds one root projecting portable, Codex, and Claude', async () => { const filtered = { ...plugin, mcpServers: plugin.mcpServers.map((server) => ({ ...server, targets: ['claude'] })), @@ -3076,9 +3076,13 @@ it('filters host components and builds portable, Codex, and Claude target roots' try { await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + // One root: Claude Code and Codex manifests live at the root; the Agent + // Plugins pack beside other hosts is the namespaced `portable/` view. 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'); + await expect(readFile(join(outputRoot, '.codex-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); + await expect(readFile(join(outputRoot, '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); + await expect(readFile(join(outputRoot, 'skills', 'review', 'SKILL.md'), 'utf8')).resolves.toBe(skillMarkdown); + await expect(readFile(join(outputRoot, 'portable', 'skills', 'review', 'SKILL.md'), 'utf8')).resolves.toBe(skillMarkdown); 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 }[]; @@ -3086,9 +3090,12 @@ it('filters host components and builds portable, Codex, and Claude target roots' 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', + '.codex-plugin/plugin.json', + '.claude-plugin/plugin.json', + 'AGENTS.md', + 'INSTALL.md', ])); + expect(manifest.files.some((file) => file.path.startsWith('codex/') || file.path.startsWith('claude/'))).toBe(false); } 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..665239572 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')); @@ -233,19 +233,15 @@ it('accepts an installed artifact whose manifest declares no resource components const artifactManifest = JSON.parse(await readFile(artifactManifestPath, 'utf8')) as { readonly files: readonly { readonly path: string }[]; }; + // Resource directories sit at the top of the one plugin root (#555). 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, }); @@ -371,6 +367,7 @@ codexPluginIt( version: '1.0.0', }, skill: 'plugins/cache/host-install-proof-marketplace/host-install-proof/1.0.0/skills/probe/SKILL.md', + // One root shares a portable SKILL.md with every host (#555): no Codex sidecar. skillSidecar: { matchesBuiltArtifact: true, path: 'skills/probe/agents/openai.yaml', 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 3a754e9d9..f696ea2a0 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -42,14 +42,29 @@ const modelFor = (target: string): NormalizedPlugin => ({ }], }); +/** + * The install surface of a root projecting `target`: one host's own plan, or + * the composite root of several hosts (#555) — the retired `plugin` target's + * successor is `'claude+codex+cursor'`. + */ const writesFor = (target: string): ReadonlyMap => { - const plan = createDefaultRegistry().get(target).plan(modelFor(target)); + const hosts = target.split('+'); + const registry = createDefaultRegistry(); + const model = hosts.length === 1 ? modelFor(target) : { + ...modelFor(hosts[0]!), + targets: hosts.map((name) => ({ + id: `target:${name}`, + name, + provenance: { kind: 'config' as const, sourcePath: '/project/agent-bundle.config.ts' }, + })), + }; + const plan = registry.root(hosts).adapter.plan(model); return new Map(plan.entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); }; -it.each(['claude', 'codex', 'cursor', 'portable', 'plugin'])( +it.each(['claude', 'codex', 'cursor', 'portable', 'claude+codex+cursor'])( 'emits a concrete INSTALL.md for the %s target', (target) => { const install = writesFor(target).get('INSTALL.md'); @@ -117,7 +132,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', 'claude+codex+cursor']) { const writes = writesFor(target); expect(writes.get('INSTALL.md')).toContain('node ./install.mjs'); expect(writes.get('install.mjs')).toContain("join(cursorRoot, 'plugins', 'local')"); @@ -391,8 +406,8 @@ 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'); +it('documents every real host path from the composite root', () => { + const install = writesFor('claude+codex+cursor').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'); @@ -412,7 +427,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', 'claude+codex+cursor']) { const install = writesFor(target).get('INSTALL.md') ?? ''; expect(install).toContain(installReceiptFile); expect(install).toContain('--replace'); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index fbb29b065..a790c1c86 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -567,20 +567,25 @@ it('honours --replace for Codex through remove + add and fails closed without a } }); -it('accepts an artifact root containing the requested host target', async () => { +it('refuses --from that names a directory above the plugin root instead of probing into it (#555)', async () => { + // The bundle sits under `/claude`: every host reads the one root it is + // given, so nothing nested is probed and the refusal names the manifest. const fixture = await createHostBundle('claude', { artifactRoot: true }); const { calls, runner } = recordingRunner(); try { - const result = await installBundle({ + await expect(installBundle({ ...isolated(fixture), commandRunner: runner, from: fixture.from, host: 'claude', scope: 'user', + })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: expect.stringContaining('No claude bundle manifest ".claude-plugin/plugin.json" was found'), + })], }); - - expect(result.bundleRoot).toBe(fixture.bundleRoot); - expect(calls[0]).toMatchObject({ cwd: fixture.bundleRoot }); + expect(calls).toEqual([]); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index d3fcffe79..138103474 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 targets: readonly ('claude' | 'codex' | 'cursor' | 'portable')[]; }): 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(options.targets)},`, '};', '', ].join('\n')), @@ -72,7 +72,7 @@ const run = async ( }; it('builds a package-relative installer with fallback naming and built-host argv validation', async () => { - const root = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); + const root = await fixture({ bin: ['installer-fixture'], targets: ['cursor'] }); const result = await build({ output: 'nested/non-default-host-packs', packageOutputs: true, @@ -199,7 +199,7 @@ it('builds a package-relative installer with fallback naming and built-host argv it('chooses an unused installer name when both primary candidates are bins', async () => { const root = await fixture({ bin: ['installer-fixture', 'installer-fixture-install'], - target: 'cursor', + targets: ['cursor'], }); const result = await build({ output: 'host-packs', packageOutputs: true, root }); @@ -213,7 +213,7 @@ it('chooses an unused installer name when both primary candidates are bins', asy }, 120_000); it('handles installer help when the project path contains a percent sign', async () => { - const originalRoot = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); + const originalRoot = await fixture({ bin: ['installer-fixture'], targets: ['cursor'] }); const root = `${originalRoot}%build`; await rename(originalRoot, root); roots.splice(roots.indexOf(originalRoot), 1, root); @@ -226,11 +226,11 @@ it('handles installer help when the project path contains a percent sign', async }, 120_000); it('uses the plugin name when free and skips portable-only artifacts', async () => { - const cursorRoot = await fixture({ author: 'Fixture Owner', bin: false, target: 'cursor' }); + const cursorRoot = await fixture({ author: 'Fixture Owner', bin: false, targets: ['cursor'] }); const cursor = await build({ output: 'host-packs', packageOutputs: true, root: cursorRoot }); expect(cursor.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); - const portableRoot = await fixture({ bin: false, target: 'portable' }); + const portableRoot = await fixture({ bin: false, targets: ['portable'] }); const portable = await build({ output: 'host-packs', packageOutputs: true, root: portableRoot }); expect(portable.packageBuild?.files.map((file) => file.path)) .not.toContain('bin/installer-fixture.js'); @@ -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); @@ -248,23 +248,24 @@ 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'); + const cursorPack = join(cursorRoot, 'host-packs'); await mkdir(join(cursorHome, '.cursor'), { recursive: true }); - await mkdir(join(cursorRoot, 'host-packs', 'cursor', 'vendor', '.git'), { recursive: true }); + await mkdir(join(cursorPack, 'vendor', '.git'), { recursive: true }); const nestedGit = await run( process.execPath, - [join(cursorRoot, 'host-packs', 'cursor', 'install.mjs'), '--mode', 'marketplace'], + [join(cursorPack, '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(cursorPack, '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(cursorPack, 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, ); expect(stagedByScript).toMatchObject({ code: 0, stderr: '' }); @@ -274,14 +275,15 @@ 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: cursorPack, home: cursorHome, host: 'cursor', mode: 'marketplace', }); expect(rerunByCli).toMatchObject({ mode: 'marketplace', state: 'already-installed' }); - const pluginRoot = await fixture({ bin: false, target: 'plugin' }); + // A multi-host root installs every projected host from one package bin. + const pluginRoot = await fixture({ bin: false, targets: ['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..e045c03bf 100644 --- a/packages/agent-bundle/tests/integration-matrix.test.ts +++ b/packages/agent-bundle/tests/integration-matrix.test.ts @@ -52,8 +52,11 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { 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'); + // One plugin root (#555): scripts are shared by every projection at the + // root; the portable projection beside Codex and Claude is the namespaced + // `portable/` view with its own documents, skills, and MCP shims. + 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 +64,32 @@ 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( - 'fixture reference\n', - ); - await expect(readFile(join(output, 'portable', 'skills', 'review', 'assets', 'binary.bin'))).resolves.toEqual( - await readFile(join(root, 'src', 'skills', 'review', 'assets', 'binary.bin')), - ); + for (const projection of ['', 'portable'] as const) { + await expect(readFile(join(output, projection, 'skills', 'review', 'references', 'guide.txt'), 'utf8')).resolves.toBe( + 'fixture reference\n', + ); + await expect(readFile(join(output, projection, 'skills', 'review', 'assets', 'binary.bin'))).resolves.toEqual( + await readFile(join(root, 'src', 'skills', 'review', 'assets', 'binary.bin')), + ); + } + // Claude Code owns `.mcp.json` and `hooks/hooks.json`; Codex beside it + // reads its relocated documents under `.codex-plugin/`. 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)), + readFile(join(output, '.codex-plugin', 'mcp.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.codex-plugin', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.agents', 'plugins', 'marketplace.json'), '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' }, @@ -118,8 +125,12 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { ])); } + // The portable document points at a `portable/mcp/.mjs` shim that + // re-exports the one compiled entry at the root. const localMcpPath = portableMcp.mcpServers.local.args[0] as string; - await expect(readFile(join(output, 'portable', localMcpPath), 'utf8')).resolves.toContain('ordinary local import'); + expect(localMcpPath).toMatch(/^mcp\/[^/]+\.mjs$/u); + await expect(readFile(join(output, 'portable', localMcpPath), 'utf8')).resolves.toBe(`import '../../${localMcpPath}';\n`); + 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' } }, @@ -195,13 +206,18 @@ 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( + // A portable-only root is the Agent Plugins pack itself (#555): no + // `portable/` namespace, `plugin.json` and `skills/` at the root. + await expect(readFile(join(output, 'plugin.json'), 'utf8').then(JSON.parse)).resolves.toMatchObject({ + name: 'portable-skills-only', + }); + 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..5724ae188 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,22 +224,22 @@ it('composes the root and server layouts around every rendered surface of one bu stdout: '', }); - // The artifact-hosted executable (`/bin/.mjs`) composes the - // same chains as the package-built one, and its worker lists the layouts - // among its source inputs. + // The artifact-hosted executable (`bin/.mjs` at the plugin 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'); expect(hostedBin?.workerSourceInputs).toEqual(expect.arrayContaining([ 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/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/mcp-probe-dev-server.test.ts b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts index 6713f7f72..820be43cc 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,8 @@ 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')); + // A single-host root is the plugin root itself (#555). + 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..10c822a09 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, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -55,6 +55,15 @@ const epochFor = ( targetDigests: Object.fromEntries(targets.map((target) => [target, `${target}-digest`])), }); +/** + * Stages one built plugin root as an epoch the way the artifact service does + * (#555): the epoch is the root itself, so every top-level entry moves over. + */ +const copyArtifactIntoStaging = async (artifact: string, stagingRoot: string): Promise => { + await Promise.all((await readdir(artifact)).map((entry) => + cp(join(artifact, entry), join(stagingRoot, entry), { recursive: true }))); +}; + const textFrom = (value: { readonly content: readonly { readonly type: string }[] }): string => { const content: unknown = value.content[0]; if ( @@ -163,11 +172,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 copyArtifactIntoStaging(artifact, staging.root); await staging.publish(async () => undefined); const epochRoot = join(root, '.agent-bundle', 'epochs', id); await expect(validateArtifact({ @@ -203,11 +208,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 copyArtifactIntoStaging(artifact, staging.root); await staging.publish(async () => undefined); return store; }; @@ -223,11 +224,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 copyArtifactIntoStaging(sourceRoot, staging.root); await staging.publish(async () => undefined); }; @@ -265,7 +262,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 +425,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 +461,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 +534,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 +605,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 +675,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..3087e705c 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -535,11 +535,14 @@ 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 server once for every selected host + // (#555): the entry names the hosts it serves and the root's identity. expect(result.compiledMcpEntries).toEqual([ { + hosts: ['portable', 'codex', 'claude'], 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,28 @@ 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'); + // Beside other hosts the portable projection is the namespaced view + // `portable/`, whose `mcp/` holds a shim onto the shared compiled server. + expect(await readdir(join(outputRoot, 'portable', 'mcp'))).toEqual([outputName]); + expect(await readFile(join(outputRoot, 'portable', 'mcp', outputName), 'utf8')).toBe( + `import '../../mcp/${outputName}';\n`, + ); + // Codex relocates its MCP document under `.codex-plugin/` beside Claude Code. 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, '.codex-plugin', 'mcp.json'), 'utf8'), + readFile(join(outputRoot, '.mcp.json'), 'utf8'), ]); expect(JSON.parse(portable)).toMatchObject({ mcpServers: { @@ -633,9 +613,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 +637,18 @@ 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)); + // Every host document references the one shared server, and the portable + // view's shim imports it, so removing it breaks each of them at once. + await rm(join(secondOutput, 'mcp', outputName)); expect(await validateArtifact({ artifactRoot: secondOutput })).toMatchObject([ { code: 'AB6004' }, - { code: 'AB6014', generatedPath: 'portable/mcp', target: 'portable' }, + { code: 'AB6014', generatedPath: 'mcp' }, + { code: 'AB6007', generatedPath: '.mcp.json' }, + { code: 'AB6007', generatedPath: '.codex-plugin/mcp.json' }, { code: 'AB6007', generatedPath: 'portable/mcp.json' }, + { code: 'AB6005', generatedPath: `portable/mcp/${outputName}` }, ]); - const previousBundle = bundles[0]!; await writeFile(join(root, 'src', 'local server.ts'), 'export const = ;\n'); await expect(build({ model, @@ -674,9 +656,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(bundle); } finally { await rm(root, { force: true, recursive: true }); } @@ -725,8 +705,8 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply expect(bundle).toContain('AGENT_BUNDLE_ENV_FILE'); // `/.env` is one directory above `mcp/`; it fills the gap and - // an exported variable still wins. - const pluginRoot = join(outputRoot, 'portable'); + // an exported variable still wins. A single-host root is the artifact itself. + 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 +768,8 @@ 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'); + // Every target reads the one plugin root (#555). + 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 +969,36 @@ 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, under the root's identity (#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']); + // The portable view shims onto the shared server; it carries no App copy. + expect(await readdir(join(outputRoot, 'portable'))).not.toContain('mcp-apps'); + 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 +1009,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 +1085,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 +1152,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 +1353,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 +1532,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 +1540,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 +1717,8 @@ 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')); + // A single-host root is the plugin root itself (#555). + 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 2b3f4da9f..f685bc1b0 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 @@ -201,9 +201,10 @@ it('enumerates lsp components with unambiguous ids for any server name (#100)', 'lsp:claude:typescript', ])); expect(model.lspServers).toHaveLength(4); - // Only adapters that lower the `claude` extension are targeted. + // Only the host that lowers the `claude` extension is targeted; Cursor + // shares the root (#555) but never reads `.lsp.json`. 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); }); @@ -295,11 +296,13 @@ it('enumerates claude.bin relative to the config file into immutable executable expect(Object.isFrozen(model.hostBins?.[0]?.files)).toBe(true); expect(Object.isFrozen(model.hostBins?.[0]?.files[0])).toBe(true); - const pluginModel = await normalizeProject({ + // A root shared with Codex (#555) still keys the bin payload to the + // host that declared it. + const compositeModel = await normalizeProject({ ...loaded, - config: { ...loaded.config, targets: ['plugin'] }, + config: { ...loaded.config, targets: ['claude', 'codex'] }, }, { skills: [] }, createDefaultRegistry()); - expect(pluginModel.hostBins?.[0]?.target).toBe('plugin'); + expect(compositeModel.hostBins?.map((bin) => bin.target)).toEqual(['claude']); } finally { await rm(root, { force: true, recursive: true }); } @@ -365,12 +368,14 @@ it('enumerates Claude workflows and output styles relative to the config file in expect(Object.isFrozen(model.hostOutputStyles)).toBe(true); expect(Object.isFrozen(model.hostOutputStyles?.[0]?.files[0])).toBe(true); - const pluginModel = await normalizeProject({ + // A root shared with Codex (#555) still keys the payloads to the host + // that declared them. + const compositeModel = await normalizeProject({ ...loaded, - config: { ...loaded.config, targets: ['plugin'] }, + config: { ...loaded.config, targets: ['claude', 'codex'] }, }, { skills: [] }, createDefaultRegistry()); - expect(pluginModel.hostWorkflows?.[0]?.target).toBe('plugin'); - expect(pluginModel.hostOutputStyles?.[0]?.target).toBe('plugin'); + expect(compositeModel.hostWorkflows?.map((entry) => entry.target)).toEqual(['claude']); + expect(compositeModel.hostOutputStyles?.map((entry) => entry.target)).toEqual(['claude']); } finally { await rm(root, { force: true, recursive: true }); } @@ -982,6 +987,25 @@ it('reports unknown targets, duplicate IDs, and portable output collisions', asy expect(diagnostics.find(({ code }) => code === 'AB4100')).toMatchObject({ target: 'future-host', }); + expect(diagnostics.find(({ code }) => code === 'AB4100')).not.toHaveProperty('recovery'); + + // The retired `plugin` composite (#555) names its replacement instead of + // reading as a typo. + const retired = validateModel( + await normalizeProject( + loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' }, targets: ['plugin'] }), + { skills: [] }, + registry, + ), + registry, + ); + expect(retired).toEqual([expect.objectContaining({ + code: 'AB4100', + message: expect.stringContaining('the plugin target was retired'), + recovery: expect.stringContaining("targets: ['claude', 'codex']"), + severity: 'error', + target: 'plugin', + })]); expect(diagnostics.filter(({ code }) => code === 'AB4102')).toMatchObject([ { generatedPath: 'portable/skills/duplicate/SKILL.md' }, { generatedPath: 'future-host/skills/duplicate/SKILL.md' }, diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 2e7067c25..8203df0f7 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,8 @@ 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')); + // A portable-only build is the plugin root itself: the server runs from the artifact root. + expect(launches[0]!.cwd).toBe(artifact); await expect(stat(join(launches[0]!.cwd, launches[0]!.args[0]!))).resolves.toMatchObject({}); }, 120_000); @@ -383,6 +384,8 @@ describe('mcp run', () => { expect(bare.env.FROM_DOTENV).toBe('dotenv'); expect(bare.env.SHARED).toBe('dotenv'); // args/cwd stay artifact-rooted: args[0] is the content-hashed bundle. + // Beside Codex, the Agent Plugins pack is the namespaced `portable/` view + // of the root, so that view is the portable server's plugin root. expect(bare.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); expect(bare.cwd).toBe(join(artifact, 'portable')); // Loading never leaks .env values into the runner's own environment. @@ -476,7 +479,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..f76a8246e 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -206,10 +206,18 @@ it('uses only an installed tarball after source deletion', async () => { agentBundleImport, ); } + // Beside Claude and Codex the portable pack is the namespaced `portable/` + // view of the one plugin root (#555): its `mcp.json` names a + // `portable/mcp/.mjs` shim that re-exports the compiled entry the + // root hosts share at `mcp/.mjs`. const localServer = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly local: { readonly args: readonly [string, ...string[]] } }; }; - const localServerBundle = join(artifact, 'portable', localServer.mcpServers.local.args[0]); + const localServerPath = localServer.mcpServers.local.args[0]; + expect(localServerPath).toMatch(/^mcp\/[^/]+\.mjs$/u); + const localServerBundle = join(artifact, 'portable', localServerPath); + await expect(readFile(localServerBundle, 'utf8')).resolves.toBe(`import '../../${localServerPath}';\n`); + await expect(readFile(join(artifact, localServerPath), 'utf8')).resolves.toContain('ordinary local import'); await Promise.all([ rm(join(projectRoot, 'agent-bundle.config.ts')), @@ -253,23 +261,27 @@ 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'); + // Scripts are emitted once at the top of the plugin root every selected + // host shares (#555); the portable view carries no copy of its own. + const scriptsRoot = join(artifact, 'scripts'); + await expect(access(join(artifact, 'portable', 'scripts'))).rejects.toMatchObject({ code: 'ENOENT' }); + const bundlePath = join(scriptsRoot, '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(scriptsRoot, '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(scriptsRoot, '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(scriptsRoot, 'shell.sh'))).mode & 0o777).toBe(sourceShellMode); + expect((await stat(join(scriptsRoot, 'python.py'))).mode & 0o777).toBe(sourcePythonMode); const { stdout: hooks } = await runInstalled(cli, projectRoot, [ 'hooks', 'list', '--json', '--root', projectRoot, '--artifact', artifact, '--target', 'codex', @@ -379,10 +391,16 @@ 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'); + // The portable view's `mcp.json` names its `portable/mcp/.mjs` shim; + // the compiled entry the shim re-exports is the root's `mcp/.mjs` + // (#555), and that is where the packaged lifecycle shell lives. const greeterManifest = JSON.parse(await readFile(join(frameworkArtifact, 'portable', 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly greeter: { readonly args: readonly [string, ...string[]] } }; }; - const greeterEntry = join(frameworkArtifact, 'portable', greeterManifest.mcpServers.greeter.args[0]); + const greeterPath = greeterManifest.mcpServers.greeter.args[0]; + expect(greeterPath).toMatch(/^mcp\/[^/]+\.mjs$/u); + await expect(readFile(join(frameworkArtifact, 'portable', greeterPath), 'utf8')).resolves.toBe(`import '../../${greeterPath}';\n`); + const greeterEntry = join(frameworkArtifact, greeterPath); 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..4e22cda38 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -10,6 +10,7 @@ import { buildHostInstallFixture, disposeHostInstallFixture, expectedCodexInterfaceFields, + hostInstallFixtureManifests, runClaudeHostInstallProof, runCodexHostInstallProof, runCursorHostInstallProof, @@ -102,12 +103,11 @@ beforeAll(async () => { const installedPackageRoot = join(consumer, 'node_modules', packageName); const installedArtifactRoot = join(installedPackageRoot, 'artifact'); const installedBin = join(consumer, 'node_modules', '.bin', pluginName); + // The shipped artifact is one composite plugin root (#555): every host's + // manifest sits at its top, and there is no per-host directory to find. await Promise.all([ access(installedBin), - access(join(installedArtifactRoot, 'claude')), - access(join(installedArtifactRoot, 'codex')), - access(join(installedArtifactRoot, 'cursor')), - access(join(installedArtifactRoot, 'plugin')), + ...hostInstallFixtureManifests.map((path) => access(join(installedArtifactRoot, path))), ]); await rm(projectRoot, { force: true, recursive: true }); @@ -117,11 +117,11 @@ beforeAll(async () => { packedFixture = Object.freeze({ artifactRoot: installedArtifactRoot, + // Each host reads the installed root itself as its plugin root (#555). 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, diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 7a0042ae6..de82d28ef 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -87,14 +87,16 @@ it('serves compiled routes and durable state across packed process restarts', as ], { cwd: project, env: installedEnvironment() }); // The fixture selects `claude`, the only target whose capabilities cover - // its event route; Claude Code reads `.mcp.json` at the plugin root. + // its event route. The built artifact is the plugin root itself (#555): + // Claude Code reads `.mcp.json` at its top, beside `bin/`, `scripts/`, + // and the compiled `mcp/` entries. const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); await execFile(cli, ['build', '--root', project, '--output', artifact], { cwd: project, 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/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 1713d0022..1afd5324d 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { hostDetectionSource } from '../src/adapters/hook-contract.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { runNodeScript } from './support/run-node-script.ts'; import { build } from './support/build.ts'; @@ -11,6 +12,10 @@ import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; const configPath = '/workspace/agent-bundle.config.ts'; +// One plugin root projecting every real host at once (#555): the old `plugin` +// composite target is gone, so a multi-host selection is the composition. +const compositeHosts = Object.freeze(['claude', 'codex', 'cursor']); + const bundleModel = Object.freeze({ extensions: Object.freeze({}), hooks: Object.freeze([ @@ -20,7 +25,7 @@ const bundleModel = Object.freeze({ name: 'session-start', provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), source: '/workspace/src/hooks/session-start.ts', - targets: Object.freeze(['plugin']), + targets: compositeHosts, tools: Object.freeze([]), }), Object.freeze({ @@ -29,7 +34,7 @@ const bundleModel = Object.freeze({ name: 'record-write', provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), source: '/workspace/src/hooks/record-write.ts', - targets: Object.freeze(['plugin']), + targets: compositeHosts, tools: Object.freeze(['file.write' as const]), }), ]), @@ -42,7 +47,7 @@ const bundleModel = Object.freeze({ id: 'mcp:status', name: 'status', provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - targets: Object.freeze(['plugin']), + targets: compositeHosts, transport: 'stdio' as const, }), ]), @@ -69,15 +74,15 @@ const bundleModel = Object.freeze({ 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: compositeHosts, }), ]), - targets: Object.freeze([ - Object.freeze({ id: 'target:plugin', name: 'plugin', provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }) }), - ]), + targets: Object.freeze(compositeHosts.map((name) => + Object.freeze({ id: `target:${name}`, name, provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }) }))), } satisfies NormalizedPlugin); -const planBundle = (model: NormalizedPlugin) => createDefaultRegistry().get('plugin').plan(model); +// `build()` plans a multi-host root through the registry, so the tests do too. +const planBundle = (model: NormalizedPlugin) => createDefaultRegistry().root(compositeHosts).adapter.plan(model); const writeContents = (model: NormalizedPlugin): Record => Object.fromEntries( planBundle(model).entries @@ -85,7 +90,7 @@ const writeContents = (model: NormalizedPlugin): Record => Objec .map((entry) => [entry.relativePath, entry.content]), ); -it('lays both host manifests over one shared bundle root', () => { +it('lays every host manifest over one shared plugin root', () => { const plan = planBundle(bundleModel); expect(plan.diagnostics).toEqual([]); const documents = writeContents(bundleModel); @@ -96,8 +101,12 @@ it('lays both host manifests over one shared bundle root', () => { // the same file as a duplicate hooks file, so the manifest never names it. expect(claudePlugin).not.toHaveProperty('hooks'); + // Beside Claude Code, Codex reads its hooks and MCP documents through + // manifest pointers at `.codex-plugin/`, leaving the conventional slots + // (`hooks/hooks.json`, `.mcp.json`) to Claude Code. const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; expect(codexPlugin).toMatchObject({ + hooks: './.codex-plugin/hooks.json', mcpServers: './.codex-plugin/mcp.json', name: 'bundle-example', skills: './skills/', @@ -117,11 +126,17 @@ it('lays both host manifests over one shared bundle root', () => { expect(codexMcp.mcpServers['status']!.cwd).toBe('./'); expect(codexMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: './' }); + // Both Claude-format documents reference the same host-detecting wrappers, + // each with its own host's native tool matcher. 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(hooks).toContain('^(?:Write|Edit)$'); + const codexHooks = documents['.codex-plugin/hooks.json']!; + expect(codexHooks).toContain('hooks/session-start.mjs'); + expect(codexHooks).toContain('hooks/record-write.mjs'); + expect(codexHooks).toContain('apply_patch|Edit|Write'); + expect(documents).not.toHaveProperty('hooks/hooks-codex.json'); expect(documents['.claude-plugin/marketplace.json']).toContain('bundle-example-marketplace'); expect(documents['.agents/plugins/marketplace.json']).toContain('bundle-example-marketplace'); @@ -134,16 +149,20 @@ it('lays both host manifests over one shared bundle root', () => { source: './', }], }); - expect(documents['AGENTS.md']).toContain('multi-host agent plugin bundle'); + expect(documents['AGENTS.md']).toContain('multi-host agent plugin root'); 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('See `INSTALL.md` for exact Claude Code, Codex, Cursor commands'); expect(documents['AGENTS.md']).toContain('`node ./install.mjs`'); + expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); + expect(documents['AGENTS.md']).toContain('share one host-detecting wrapper per hook'); + // One install surface for the whole root, with a section per selected host. 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.md']).toContain('Cursor'); expect(documents['install.mjs']).toContain("join(cursorRoot, 'plugins', 'local')"); - expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); + expect(plan.entries.filter((entry) => entry.relativePath === 'INSTALL.md')).toHaveLength(1); const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; expect(cursorPlugin).toMatchObject({ @@ -171,7 +190,7 @@ it('lays both host manifests over one shared bundle root', () => { }); }); -it('keeps the Claude marketplace overlay host-specific in the unified bundle', () => { +it('keeps the Claude marketplace overlay host-specific in the composite root', () => { const model: NormalizedPlugin = { ...bundleModel, extensions: { @@ -254,7 +273,7 @@ it('emits Cursor logo and omits it from Claude and Codex manifests', () => { })); }); -it('bundles subagent hooks at Codex default hooks/hooks.json location', () => { +it('relocates Codex subagent hooks beside Claude Code through the manifest pointer', () => { const model: NormalizedPlugin = { ...bundleModel, hooks: [ @@ -276,15 +295,22 @@ it('bundles subagent hooks at Codex default hooks/hooks.json location', () => { }; const documents = writeContents(model); const codexManifest = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const hooks = JSON.parse(documents['hooks/hooks.json']!) as { + const claudeHooks = JSON.parse(documents['hooks/hooks.json']!) as { + readonly hooks: Readonly>; + }; + const codexHooks = JSON.parse(documents['.codex-plugin/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); + // Claude Code owns the conventional hooks/hooks.json, so Codex reads its + // own document through the manifest pointer; both name the same wrappers. + expect(codexManifest).toHaveProperty('hooks', './.codex-plugin/hooks.json'); + expect(claudeHooks.hooks.SubagentStart).toHaveLength(1); + expect(claudeHooks.hooks.SubagentStop).toHaveLength(1); + expect(codexHooks.hooks.SubagentStart).toHaveLength(1); + expect(codexHooks.hooks.SubagentStop).toHaveLength(1); + expect(documents['.codex-plugin/hooks.json']).toContain('hooks/agent-start.mjs'); + expect(documents['.codex-plugin/hooks.json']).toContain('hooks/agent-stop.mjs'); }); it('emits Claude-only LSP configuration at the shared composite root', () => { @@ -325,7 +351,7 @@ it('emits Claude-only LSP configuration at the shared composite root', () => { expect(documents['AGENTS.md']).toContain('claude --debug'); }); -it('emits Claude userConfig from the unified plugin target only into the Claude manifest', () => { +it('emits Claude userConfig from the composite root only into the Claude manifest', () => { const model = { ...bundleModel, extensions: { @@ -367,7 +393,7 @@ it('emits Claude userConfig from the unified plugin target only into the Claude .toContain('/workspace/claude.config.ts'); }); -it('emits Claude manifest metadata from the unified target only into the Claude manifest', () => { +it('emits Claude manifest metadata from the composite root only into the Claude manifest', () => { const model = { ...bundleModel, extensions: { @@ -405,7 +431,7 @@ it('emits Claude manifest metadata from the unified target only into the Claude .toContain('/workspace/claude-metadata.config.ts'); }); -it('emits Claude channels from the unified target only into the Claude manifest', () => { +it('emits Claude channels from the composite root only into the Claude manifest', () => { const model = { ...bundleModel, extensions: { @@ -452,7 +478,7 @@ it('emits Claude channels from the unified target only into the Claude manifest' .toContain('/workspace/channels.config.ts'); }); -it('emits the Claude bin directory from the unified plugin target', () => { +it('emits the Claude bin directory at the composite root', () => { const model: NormalizedPlugin = { ...bundleModel, hostBins: [{ @@ -464,7 +490,7 @@ it('emits the Claude bin directory from the unified plugin target', () => { }], provenance: { kind: 'config', sourcePath: configPath }, source: '/workspace/tools', - target: 'plugin', + target: 'claude', }], }; const plan = planBundle(model); @@ -481,7 +507,7 @@ it('emits the Claude bin directory from the unified plugin target', () => { expect(writeContents(model)['AGENTS.md']).toContain('`bin/`'); }); -it('emits Claude workflows and output styles from the unified plugin target', () => { +it('emits Claude workflows and output styles at the composite root', () => { const model: NormalizedPlugin = { ...bundleModel, hostOutputStyles: [{ @@ -493,7 +519,7 @@ it('emits Claude workflows and output styles from the unified plugin target', () }], provenance: { kind: 'config', sourcePath: configPath }, source: '/workspace/styles', - target: 'plugin', + target: 'claude', }], hostWorkflows: [{ files: [{ @@ -504,7 +530,7 @@ it('emits Claude workflows and output styles from the unified plugin target', () }], provenance: { kind: 'config', sourcePath: configPath }, source: '/workspace/workflows', - target: 'plugin', + target: 'claude', }], }; const plan = planBundle(model); @@ -612,7 +638,7 @@ it('emits Claude-only experimental themes and monitors at the shared composite r expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('experimental'); }); -it('emits Claude-only dependencies from the unified plugin target', () => { +it('emits Claude-only dependencies at the composite root', () => { const model = { ...bundleModel, extensions: { @@ -649,6 +675,8 @@ it('emits each shared surface exactly once with no duplicate artifact paths', () expect(paths.filter((path) => path === 'skills/review/references/guide.md')).toHaveLength(1); expect(new Set(paths).size).toBe(paths.length); + // One host-detecting wrapper per hook for Claude Code and Codex, one + // dedicated Cursor wrapper per hook; every wrapper records the hosts it serves. const hookEntries = plan.hookEntries ?? []; expect(hookEntries.map((entry) => entry.relativePath).sort()).toEqual([ 'hooks/record-write.cursor.mjs', @@ -656,7 +684,13 @@ it('emits each shared surface exactly once with no duplicate artifact paths', () 'hooks/session-start.cursor.mjs', 'hooks/session-start.mjs', ]); - expect(new Set(hookEntries.map((entry) => entry.target))).toEqual(new Set(['plugin'])); + expect(Object.fromEntries(hookEntries.map((entry) => [entry.relativePath, [...(entry.hosts ?? [])].sort()]))).toEqual({ + 'hooks/record-write.cursor.mjs': ['cursor'], + 'hooks/record-write.mjs': ['claude', 'codex'], + 'hooks/session-start.cursor.mjs': ['cursor'], + 'hooks/session-start.mjs': ['claude', 'codex'], + }); + expect(new Set(hookEntries.map((entry) => entry.artifactTarget))).toEqual(new Set(['claude+codex+cursor'])); }); it('emits Cursor-only rules once at the shared root and documents the honest host boundary', () => { @@ -672,7 +706,7 @@ it('emits Cursor-only rules once at the shared root and documents the honest hos name: 'focused', provenance: { kind: 'conventional', sourcePath: '/workspace/src/rules/focused.mdc' }, source: '/workspace/src/rules/focused.mdc', - targets: ['plugin'], + targets: ['cursor'], }], }; const plan = planBundle(model); @@ -685,9 +719,7 @@ it('emits Cursor-only rules once at the shared root and documents the honest hos 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.', - ); + expect(documents['AGENTS.md']).toContain('- `rules/` — Cursor rules (`.mdc`). Cursor only.'); const ruleFree = planBundle(bundleModel); expect(ruleFree.entries.some((entry) => entry.relativePath.startsWith('rules/'))).toBe(false); @@ -696,22 +728,19 @@ it('emits Cursor-only rules once at the shared root and documents the honest hos }); 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 command = { + 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' as const, sourcePath: '/workspace/src/commands/review.md' }, + source: '/workspace/src/commands/review.md', }; + const model: NormalizedPlugin = { ...bundleModel, commands: [{ ...command, targets: ['claude'] }] }; const plan = planBundle(model); const documents = writeContents(model); @@ -725,9 +754,17 @@ it('emits Claude-format commands without pointing Cursor at the shared directory '', ].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.', - ); + expect(documents['AGENTS.md']).toContain('- `commands/` — Claude Code command prompts. Claude Code only.'); + + // Cursor reads plain Markdown prompts from the same commands/ directory, so + // a command cannot select Cursor beside Claude Code in one root (AB4104). + const overlapping = planBundle({ ...bundleModel, commands: [{ ...command, targets: ['claude', 'cursor'] }] }); + expect(overlapping.diagnostics).toEqual([expect.objectContaining({ + code: 'AB4104', + severity: 'error', + sourcePath: '/workspace/src/commands/review.md', + })]); + expect(overlapping.entries).toEqual([]); const commandFree = planBundle(bundleModel); expect(commandFree.entries.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); @@ -738,7 +775,7 @@ it('emits Claude-format commands without pointing Cursor at the shared directory 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"'); + for (const line of hostDetectionSource) expect(wrapper?.virtualSource).toContain(line); expect(wrapper?.virtualSource).toContain('AGENT_BUNDLE_HOOK_HOST'); }); @@ -760,10 +797,11 @@ it('reports a bundle-target conflict instead of silently overwriting an entry', ], }; const plan = planBundle(model); - expect(plan.diagnostics).toEqual([expect.objectContaining({ code: 'plugin.artifact.conflict', severity: 'error' })]); + expect(plan.diagnostics).toEqual([expect.objectContaining({ code: 'AB4105', severity: 'error' })]); + expect(plan.entries.filter((entry) => entry.relativePath === 'skills/review/SKILL.md')).toHaveLength(1); }); -it('builds the unified bundle root on disk with a compiled universal hook wrapper', async () => { +it('builds the composite plugin 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'); @@ -799,21 +837,27 @@ it('builds the unified bundle root on disk with a compiled universal hook wrappe ], source: join(skillRoot, 'SKILL.md'), }], - targets: [ - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') } }, - ], + targets: compositeHosts.map((name) => ({ + id: `target:${name}`, + name, + provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, + })), }; try { await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const bundleRoot = join(outputRoot, 'plugin'); + // Every host manifest and compiled file lives directly at the output root. + const bundleRoot = outputRoot; 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'); + const codexPlugin = JSON.parse(await readFile(join(bundleRoot, '.codex-plugin', 'plugin.json'), 'utf8')) as Record; + expect(codexPlugin).toMatchObject({ hooks: './.codex-plugin/hooks.json', skills: './skills/' }); + await expect(readFile(join(bundleRoot, 'AGENTS.md'), 'utf8')).resolves.toContain('multi-host agent plugin root'); + await expect(readFile(join(bundleRoot, 'INSTALL.md'), 'utf8')).resolves.toContain('claude plugin install bundle-example'); 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'); + await expect(readFile(join(bundleRoot, '.codex-plugin', 'hooks.json'), 'utf8')).resolves.toContain('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', @@ -846,17 +890,31 @@ it('builds the unified bundle root on disk with a compiled universal hook wrappe readonly files: readonly { readonly path: string }[]; readonly targets: readonly { readonly name: string }[]; }; - expect(manifest.targets.map(({ name }) => name)).toEqual(['plugin']); + expect(manifest.targets.map(({ name }) => name)).toEqual(['claude', 'codex', 'cursor']); 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', + '.claude-plugin/plugin.json', + '.codex-plugin/hooks.json', + '.codex-plugin/plugin.json', + '.cursor-plugin/marketplace.json', + '.cursor-plugin/plugin.json', + 'AGENTS.md', + 'INSTALL.md', + 'hooks/hooks-cursor.json', + 'hooks/hooks.json', + 'hooks/session-start.cursor.mjs', + 'hooks/session-start.mjs', + 'skills/review/SKILL.md', ])); + expect(manifest.files.some((file) => file.path.startsWith('plugin/') || file.path.startsWith('claude/'))).toBe(false); + // The hook index lists the shared wrapper once per host it serves. + const hookIndex = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.hooks.json'), 'utf8')) as { + readonly hooks: readonly { readonly path: string; readonly target: string }[]; + }; + expect(hookIndex.hooks.map(({ path, target }) => ({ path, target }))).toEqual([ + { path: 'hooks/session-start.mjs', target: 'claude' }, + { path: 'hooks/session-start.mjs', target: 'codex' }, + { path: 'hooks/session-start.cursor.mjs', target: 'cursor' }, + ]); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/plugin-logo.test.ts b/packages/agent-bundle/tests/plugin-logo.test.ts index dea68878a..0be42e83c 100644 --- a/packages/agent-bundle/tests/plugin-logo.test.ts +++ b/packages/agent-bundle/tests/plugin-logo.test.ts @@ -6,7 +6,7 @@ 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 { createCompositeAdapter } from '../src/adapters/composite.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 +42,9 @@ const loadedProject = async ( }; }; -const logoModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ +const allHosts = ['claude', 'codex', 'cursor'] as const; + +const logoModel = (target: 'cursor' | readonly string[]): NormalizedPlugin => ({ extensions: {}, hooks: [], metadata: { @@ -61,11 +63,11 @@ const logoModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ runtime: { node: '22.12.0' }, scripts: [], skills: [], - targets: [{ - id: `target:${target}`, - name: target, + targets: (typeof target === 'string' ? [target] : target).map((name) => ({ + id: `target:${name}`, + name, provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, - }], + })), }); it('rejects a missing or invalid plugin.logo with AB4012', async () => { @@ -166,8 +168,8 @@ it('emits Cursor plugin.json logo and copies the image into the artifact', () => }); it('omits logo from Claude and Codex manifests while still emitting it for Cursor', () => { - const model = logoModel('plugin'); - const plan = pluginAdapter.plan(model); + const model = logoModel(allHosts); + const plan = createCompositeAdapter(allHosts).plan(model); expect(plan.diagnostics).toEqual([]); const documents = Object.fromEntries( plan.entries 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/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index ebba15aa2..46b86ec81 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -81,24 +81,27 @@ 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')) + // Payload bytes land verbatim at their stable relative paths: once at the + // root Claude Code and Codex share, once more in the portable view that + // sits beside them (#555). + for (const hostRoot of ['', 'portable']) { + expect(await readFile(join(root, 'out', hostRoot, 'runtime', 'chunks', '417.js'), 'utf8')) .toBe('module.exports = require("./418.js");\n'); - expect(await readFile(join(root, 'out', target, 'app', 'index.html'), 'utf8')) + expect(await readFile(join(root, 'out', hostRoot, 'app', 'index.html'), 'utf8')) .toBe('widget\n'); } - // Adapter lowering: the same token expansion as compiled entries. + // Adapter lowering: the same token expansion as compiled entries. Codex + // reads its MCP document from `.codex-plugin/` beside Claude Code's `.mcp.json`. 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', @@ -113,9 +116,10 @@ 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. + // declared arguments; nothing is compiled or indexed for them. Claude Code + // owns `hooks/hooks.json`; Codex reads `.codex-plugin/hooks.json` beside it. 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 +127,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 +139,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 +167,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 6a961eb19..0d49927c0 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'); }); @@ -156,7 +156,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..cf0d1c60a 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -195,10 +195,12 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-consumer-')); try { + // A portable-only artifact is the Agent Plugins pack itself (#555): its + // plugin.json, mcp.json, mcp/, and installer documents sit at the root. 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 +221,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 +241,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..7f253c8fe 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -147,7 +147,9 @@ 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')) + // A composite root's identity (the hosts joined with '+', #555) is never a + // projection target: the wrapper resolves the calling host first. + expect(() => projectEventDocument(feedback.document, 'agent/stop', 'claude+codex', 'SubagentStop')) .toThrow(/must resolve the invoking host/u); }); 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/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 5834b7920..b6f263106 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -11,8 +11,11 @@ import { parse as parseYaml } from 'yaml'; import portableMcpSchema from '../../src/adapters/schemas/portable/mcp.schema.json' with { type: 'json' }; import portablePluginSchema from '../../src/adapters/schemas/portable/plugin.schema.json' with { type: 'json' }; +import { claudeArtifactPaths } from '../../src/adapters/claude.ts'; import { codexArtifactPaths, codexInterfaceFields, codexPluginDocumentValidator } from '../../src/adapters/codex.ts'; +import { compositeHookContract, compositeMcpRuntime } from '../../src/adapters/composite.ts'; import { + cursorArtifactPaths, cursorHooksValidator, cursorMcpValidator, cursorPluginValidator, @@ -67,7 +70,28 @@ const portableMcpSchemaIdentifier = const portablePluginSchemaIdentifier = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; const skillSidecarPath = join('skills', 'probe', 'agents', 'openai.yaml'); +const claudeManifestPath = claudeArtifactPaths.plugin; const codexManifestPath = codexArtifactPaths.plugin; +const cursorManifestPath = cursorArtifactPaths.plugin; +/** The hosts the host-install fixture projects into its one composite root (#555). */ +const fixtureHosts: readonly InstallHost[] = Object.freeze(['claude', 'codex', 'cursor']); +/** + * Root-relative host manifests the built host-install root must carry: one + * per projected host, all at the top of the same root (#555). + */ +export const hostInstallFixtureManifests: readonly string[] = Object.freeze([ + claudeManifestPath, + codexManifestPath, + cursorManifestPath, +]); +/** The MCP document one host reads at the fixture root: Codex's relocates under `.codex-plugin/` beside Claude. */ +const hostMcpDocument = (host: InstallHost): string => + compositeMcpRuntime(fixtureHosts, host)?.manifestPath + ?? fail(`Host ${host} declares no MCP runtime contract in the composite fixture root.`); +/** Cursor's hooks document at the fixture root: `hooks/hooks-cursor.json` beside another host. */ +const cursorHooksDocument = (): string => + compositeHookContract(fixtureHosts, 'cursor')?.manifestPath + ?? fail('Cursor declares no hook contract in the composite fixture root.'); const validateCodexPluginManifest = codexPluginDocumentValidator(codexArtifactPaths.mcp); const portableSchemaValidator = createAdapterValidator(); @@ -149,8 +173,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 directory each host reads as its plugin root. Every host reads the + * one composite root itself (#555): three host manifests, one root. + */ + readonly bundles: Readonly>; } export interface BuiltHostInstallTokenFixture extends BuiltFixtureProject { @@ -244,6 +271,11 @@ export interface CodexHostInstallReport { readonly version: '1.0.0'; }; readonly skill: string; + /** + * A multi-host root shares one `skills//SKILL.md` with every host + * (#555); Codex's `agents/openai.yaml` is its own file beside it, read by no + * other host, so the shared root still emits it. + */ readonly skillSidecar: { readonly matchesBuiltArtifact: true; readonly path: string; @@ -582,9 +614,10 @@ const proveSameVersionRebuild = async (options: { */ const buildFixtureProject = async (options: { readonly buildCommand?: 'build' | 'prepack'; - readonly bundleNames: readonly string[]; readonly environment: Readonly; readonly fixture: string; + /** Root-relative host manifests the built plugin root must carry (#555). */ + readonly hostManifests: readonly string[]; readonly prepareProject?: (projectRoot: string) => Promise; }): Promise => { const root = await mkdtemp(join(tmpdir(), `agent-bundle-${options.fixture}-build-`)); @@ -607,7 +640,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.hostManifests.map((path) => access(join(artifactRoot, path)))); return Object.freeze({ artifactRoot, cli, root }); } catch (error) { await rm(root, { force: true, recursive: true }); @@ -622,18 +655,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, fixture: 'host-install', + hostManifests: hostInstallFixtureManifests, ...(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 +680,11 @@ export const buildHostInstallTokenFixture = async (options: { readonly environment: Readonly; }): Promise => { const built = await buildFixtureProject({ - bundleNames: ['claude'], environment: options.environment, fixture: 'host-install-tokens', + hostManifests: [claudeManifestPath], }); - const claudeBundle = join(built.artifactRoot, 'claude'); + const claudeBundle = built.artifactRoot; return Object.freeze({ ...built, claudeBundle, @@ -664,13 +696,13 @@ export const buildPortableHostInstallFixture = async (options: { readonly environment: Readonly; }): Promise => { const built = await buildFixtureProject({ - bundleNames: ['portable'], environment: options.environment, fixture: 'host-install-portable', + hostManifests: ['plugin.json'], }); return Object.freeze({ ...built, - portableBundle: join(built.artifactRoot, 'portable'), + portableBundle: built.artifactRoot, }); }; @@ -746,7 +778,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 +806,12 @@ 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')); + // The epoch is the plugin root itself (#555): its hooks/ and skills/ are the host's. + const hookName = (await readdir(join(epoch2Root, 'hooks'))).find((name) => name.endsWith('.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); @@ -1038,6 +1071,15 @@ export const runCodexHostInstallProof = async ( const skillPath = join(cachePath, 'skills', 'probe', 'SKILL.md'); await access(skillPath).catch(() => fail('Codex cache did not contain skills/probe/SKILL.md.')); + // The root shares one SKILL.md with every host (#555); Codex's + // agents/openai.yaml sidecar is its own file beside it and still ships. + assertProof( + await readText(skillPath, 'Codex installed skill') === await readText( + join(fixture.bundles.codex, 'skills', 'probe', 'SKILL.md'), + 'Codex built skill', + ), + 'Codex install did not copy skills/probe/SKILL.md byte-identically from the built root.', + ); const installedSidecar = await readText(join(cachePath, skillSidecarPath), 'Codex installed skill sidecar'); const builtSidecar = await readText( join(fixture.bundles.codex, skillSidecarPath), @@ -1045,7 +1087,7 @@ export const runCodexHostInstallProof = async ( ); assertProof( installedSidecar === builtSidecar, - 'Codex install did not copy skills/probe/agents/openai.yaml byte-identically from the built bundle.', + 'Codex install did not copy skills/probe/agents/openai.yaml byte-identically from the built root.', ); const sidecarDocument = parseYaml(installedSidecar) as unknown; const sidecar = record(sidecarDocument); @@ -1284,10 +1326,11 @@ 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 - * validator that ignored the manifest would report AB7320/AB6027 against a byte-for-byte install (#438). + * Installs the composite root itself as a Cursor local plugin in a fresh isolated home and asks Doctor + * for the static and registration verdicts. Beside Claude and Codex the root carries both + * `hooks/hooks.json` (Claude/Codex format) and `hooks/hooks-cursor.json` (Cursor format, named by + * `.cursor-plugin/plugin.json`) (#555), so a validator that ignored the manifest would report + * AB7320/AB6027 against a byte-for-byte install (#438). */ const assertUnifiedBundleCursorInstall = async ( fixture: BuiltHostInstallFixture, @@ -1297,8 +1340,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)}`); @@ -1378,7 +1421,8 @@ 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'); + // Beside Claude and Codex, Cursor's hooks document is hooks/hooks-cursor.json (#555). + const hooksText = await readText(join(destination, cursorHooksDocument()), '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'); @@ -1809,8 +1853,6 @@ 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 liveHostDestination = ( host: InstallHost, roots: { readonly claudeConfig: string; readonly codexHome: string; readonly home: string }, 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/agent-bundle/tests/support/packed-native-smoke.ts b/packages/agent-bundle/tests/support/packed-native-smoke.ts index 1fa576e37..f16b78eea 100644 --- a/packages/agent-bundle/tests/support/packed-native-smoke.ts +++ b/packages/agent-bundle/tests/support/packed-native-smoke.ts @@ -327,7 +327,8 @@ export const runPackedClaudePluginProof = async (options: { ], { cwd: project, environment }); if (built.exitCode !== 0) throw new Error('packed-claude-proof:build'); - const pluginDirectory = join(artifact, 'claude'); + // The built artifact is the one plugin root Claude Code loads (#555). + 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 af8ce63ce..e17788aae 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -8,12 +8,13 @@ import { expect, it } from '@rstest/core'; import { canonicalHookEventFor, + hostDetectionSource, planHooks, readStandardNativeHookCommands, readTargetNativeHookCommands, type TargetHookContract, } from '../src/adapters/hook-contract.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; +import { createCompositeAdapter } from '../src/adapters/composite.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'; @@ -254,10 +255,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: [{ @@ -367,21 +368,22 @@ 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', () => { +it('bakes the concrete Cursor target only into the composite root\'s Cursor event wrapper', () => { + const hosts = ['claude', 'codex', 'cursor']; const hook: NormalizedHook = { ...planningHook('afterTool', []), eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, - targets: ['plugin'], + targets: hosts, }; const model: NormalizedPlugin = { ...planningModel([hook]), - targets: [{ - id: 'target:plugin', - name: 'plugin', + targets: hosts.map((name) => ({ + id: `target:${name}`, + name, provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, - }], + })), }; - const plan = pluginAdapter.plan(model); + const plan = createCompositeAdapter(hosts).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')); @@ -391,7 +393,7 @@ it('bakes the concrete Cursor target only into the plugin Cursor event wrapper', 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"'); + for (const line of hostDetectionSource) expect(shared?.virtualSource).toContain(line); 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'); diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 6c7db528b..ed6db214c 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -389,28 +389,33 @@ it('delegates one-shot and persistent MCP operations to an injected target runti expect(calls).toEqual(['list', 'native-tool']); expect(stdio).toHaveLength(1); + // The single-target root is the plugin root itself (#555): `$SYNTHETIC_ROOT` + // resolves to the artifact directory, not to a `/` namespace in it. 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'] }); + // An epoch is the plugin root itself: its documents and compiled + // directories sit beside the manifest, without a `/` directory. 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 }), + cp(join(artifact, 'native'), join(staging.root, 'native'), { recursive: true }), + cp(join(artifact, 'scripts'), join(staging.root, 'scripts'), { recursive: true }), ]); await staging.publish(async () => undefined); @@ -447,14 +452,15 @@ it('delegates one-shot and persistent MCP operations to an injected target runti serverName: 'stdio', target: 'synthetic-mcp', }); + const epochRoot = join(root, '.agent-bundle', 'epochs', 'synthetic-epoch'); 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(epochRoot, 'scripts', 'server.mjs'), + join(epochRoot, 'scripts', 'resource.mjs'), ], command: stdio[0]!.command, - cwd: join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp'), + cwd: epochRoot, env: { SESSION: expect.any(String) }, }); await Promise.all([session.close(), persistent.close()]); 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/scaffold-packed-matrix.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts index b425bd3c6..ee149a15a 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 @@ -52,13 +52,17 @@ it.concurrent('scaffolds the mcp-server template and serves the conventional ent expect(projection).toContain('projects the rendered document into the protocol result the server returns'); expect(projection).toContain('"failedTests": 0'); + // Beside Claude Code and Codex the portable pack is the `portable/` view of + // the one plugin root (#555): its mcp.json names a shim onto the compiled + // entry every host shares at the root's `mcp/.mjs`. const artifact = join(projectRoot, 'artifact'); const manifest = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly status: { readonly args: readonly [string, ...string[]] } }; }; - const entry = join(artifact, 'portable', manifest.mcpServers.status.args[0]); + const entryPath = manifest.mcpServers.status.args[0]; + await expect(readFile(join(artifact, 'portable', entryPath), 'utf8')).resolves.toBe(`import '../../${entryPath}';\n`); // The factory export was wrapped in the framework stdio lifecycle shell. - await expect(readFile(entry, 'utf8')).resolves.toContain('stdio heartbeat'); + await expect(readFile(join(artifact, entryPath), 'utf8')).resolves.toContain('stdio heartbeat'); const cli = join(projectRoot, 'node_modules', '.bin', 'agent-bundle'); const { stdout: listed } = await execFile(cli, [ @@ -120,9 +124,10 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif expect(library.greet('World').message).toBe('Hello, World!'); await expect(readFile(join(projectRoot, 'dist', 'index.d.ts'), 'utf8')).resolves.toContain('Greeting'); - // 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'); + // The conventional plain script shipped once at the top of the plugin root + // every host shares (#555), with the framework process envelope around its + // `main` export. + 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 +142,11 @@ 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'); + // One composite root (#555): the portable pack is its `portable/` view + // beside the Claude Code and Codex manifests at the root. 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/.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/workbench/tests/discovery.e2e.test.ts b/packages/workbench/tests/discovery.e2e.test.ts index 1bcb98b7d..4db29745e 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'; @@ -99,23 +99,29 @@ e2e( }, prepare: async ({ configSource, root }) => { 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.'); - await writeFile(configSource, source.replace(anchor, ` servers: { + const serversAnchor = ' servers: {\n timeline: {'; + const outputAnchor = ' portable: {},\n'; + if (!source.includes(serversAnchor) || !source.includes(outputAnchor)) { + throw new Error('Discovery probe fixture config anchor is missing.'); + } + // The built artifact is one plugin root carrying every host's manifest + // (#555); declaring it as the artifact output makes it the bundle the + // Doctor inspects, with no per-host copy. + await writeFile(configSource, source + .replace(serversAnchor, ` servers: { 'probe-down': { args: ['-e', 'process.exit(0)'], command: 'node', targets: ['portable', 'claude', 'codex'], transport: 'stdio', }, - timeline: {`)); - const output = join(root, 'dist', 'plugins'); - const result = await build({ output, root }); + timeline: {`) + .replace(outputAnchor, ` output: { distPath: 'dist/plugins' },\n${outputAnchor}`)); + const result = await build({ output: join(root, 'dist', 'plugins'), 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..777fc3ed7 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -223,9 +223,13 @@ 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 }); + // One plugin root shares its scripts/ directory with every projected host + // (#555), so each target catalogues both scripts and the picker defaults + // to the first by id. + await page.waitForFunction(() => document.querySelector('#playground-script-id')?.value === 'script:detect-risk', undefined, { 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 }); @@ -355,7 +359,15 @@ 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 }); + // Beside Claude and Codex the portable projection is the namespaced + // `portable/` view (#555): its own pack whose mcp.json reaches the shared + // compiled server through a shim, while the compiled MCP App HTML is a + // shared root surface listed under the root hosts. await page.locator('#artifact-target').selectOption('portable'); + await expect(page.locator('.artifact-table').first()).toContainText('portable/mcp.json', { timeout: browserTimeout }); + await expect(page.locator('.artifact-table').first()).toContainText('portable/mcp/mcp-status-', { timeout: browserTimeout }); + await expect(page.locator('.artifact-table').first()).not.toContainText('mcp-apps/status.html'); + await page.locator('#artifact-target').selectOption('claude'); 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/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index aad78d72c..4def4f6e2 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -1145,7 +1145,9 @@ 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 { + // The epoch is one plugin root (#555): portable's MCP manifest sits at its top level. + const epochRoot = join(project.root, '.agent-bundle', 'epochs', epochId); + const manifest = JSON.parse(await readFile(join(epochRoot, 'mcp.json'), 'utf8')) as { readonly mcpServers: Readonly<{ readonly fixture: Readonly<{ readonly args?: readonly string[]; readonly command: string }>; }>; @@ -1239,7 +1241,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: epochRoot, env: { NO_COLOR: '1' }, kind: 'stdio', }, diff --git a/scripts/measure-hook-cold-start.mjs b/scripts/measure-hook-cold-start.mjs index 3fb204f3a..3e2d7f642 100644 --- a/scripts/measure-hook-cold-start.mjs +++ b/scripts/measure-hook-cold-start.mjs @@ -65,7 +65,8 @@ const measureOnce = async (hookPath) => { }; const findGeneratedHook = async (artifactRoot) => { - const claudeHooks = join(artifactRoot, 'claude', 'hooks'); + // The built artifact is the plugin root itself (#555): hooks/ sits at its top. + const claudeHooks = join(artifactRoot, '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}`); @@ -109,7 +110,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/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 852e29c72..545de12cc 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -86,9 +86,9 @@ 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 plugin root at `artifact/` that +Claude Code and Codex both read — each host's plugin metadata over the 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..b0c0b24ca 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -22,15 +22,16 @@ an authored script. runs it. 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 + because it selects a host: it is emitted only into a root that projects `portable`, into the + shared `scripts/` every host of that root reads. The example keeps both modes covered on purpose. - **The framework owns the process envelope.** Both scripts export `main` and return an exit code. Argv handling, awaiting, and exit-code adoption belong to the generated envelope, so a 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 into the plugin root's `assets/`, 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 @@ -39,7 +40,7 @@ an authored script. | --- | --- | | `src/hooks/session-start.ts` | The `sessionStart` handler that directs a release session through both checks. | | `src/scripts/verify-release.ts` | The manifest-backed packaging check, discovered by convention. | -| `src/scripts/detect-risk.ts` | The risk-register check, explicitly configured to restrict its targets. | +| `src/scripts/detect-risk.ts` | The risk-register check, explicitly configured to select the `portable` host. | | `release/release-manifest.json` | The packaged release manifest, copied as an asset. | | `release/risk-register.json` | The risk register the blocking check reads. | diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 6132ab1b0..b38cceb11 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -288,13 +288,16 @@ 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 +Both shapes share the emitted hooks-document wiring, and both compile into a wrapper the host +invokes as `node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"` (or the host's own root token). In a +root that projects several hosts, Claude Code owns the conventional `hooks/hooks.json`; Codex +beside it reads `.codex-plugin/hooks.json` through the `hooks` pointer in `.codex-plugin/plugin.json`, +and a hook selecting both compiles to one host-detecting `hooks/.mjs` that both documents +name. Cursor beside another host reads `hooks/hooks-cursor.json` over its own +`hooks/.cursor.mjs` wrappers (`preToolUse` / `postToolUse` camelCase). Claude Code loads +`hooks/hooks.json` by convention and never scans `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 diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx index 1a7b73a4b..b3c697fcc 100644 --- a/website/docs/en/guide/authoring/index.mdx +++ b/website/docs/en/guide/authoring/index.mdx @@ -38,15 +38,29 @@ identity from `agent-bundle/meta` instead of a hand-maintained version module. ## Targets -`targets` selects the artifact layouts the build emits: +`targets` selects the hosts the build projects into one plugin root: | Target | What it emits | | --- | --- | -| `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/plugin.json` and `marketplace.json`, `.mcp.json`, `hooks/hooks.json`. | +| `codex` | The Codex plugin layout: `.codex-plugin/plugin.json` and `.agents/plugins/marketplace.json`. | +| `cursor` | The Cursor plugin layout: `.cursor-plugin/plugin.json` (plus `marketplace.json` with `marketplace: true`) and the root `mcp.json`. | +| `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0): `plugin.json` and `mcp.json` at the root, or the namespaced `portable/` pack beside other hosts. Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients. | + +Select one target and the root is that host's layout unchanged. Select several and their +projections compose into the same root: `skills/`, `scripts/`, `mcp/`, `mcp-apps/`, `bin/`, and +`assets/` are emitted once and shared, each host's manifests sit in their own directory, and the +build adds an `AGENTS.md` describing how each host reads the root beside one `INSTALL.md` with a +section per host. Where two hosts would read one conventional document, the composition either +relocates the document behind an explicit manifest pointer — Codex beside Claude Code reads +`.codex-plugin/hooks.json` and `.codex-plugin/mcp.json`, Cursor beside another host reads +`hooks/hooks-cursor.json` — or reports a diagnostic (`AB4104` when a skill or command selects +only some of the hosts that share one directory, `AB4105` when two projections emit different +bytes for one path); it never widens a declaration's host scope silently. Only these built-in +hosts compose: an adapter registered on an advanced `TargetRegistry` is built alone, one target per +`--output`, and selecting it beside a built-in host is refused (`AB4106`). Omitting `targets` +selects `portable`. `plugin` is not a target name and is rejected as unknown (`AB4100`). The +emitted layout is in [Project structure](../start/project-structure.mdx#host-artifacts). 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 +71,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 hosts the plugin root is projected for. | | `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 +120,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 plugin-root `.lsp.json` by any root that projects + `claude`. 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..e43588414 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -153,9 +153,10 @@ 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 +ship with the host artifact. So the build also emits the same compiled command graph once into the +plugin root as `bin/.mjs` (plus `bin/-flight.mjs` when any command +renders), shared by every selected target. 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 diff --git a/website/docs/en/guide/authoring/scripts-assets.mdx b/website/docs/en/guide/authoring/scripts-assets.mdx index 88688766b..d4d8c60ff 100644 --- a/website/docs/en/guide/authoring/scripts-assets.mdx +++ b/website/docs/en/guide/authoring/scripts-assets.mdx @@ -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 plugin root's `assets/` +directory, shared by every selected target. 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..8b9d0691a 100644 --- a/website/docs/en/guide/authoring/skills.mdx +++ b/website/docs/en/guide/authoring/skills.mdx @@ -87,6 +87,18 @@ 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. +One plugin root holds one `skills//SKILL.md`, and every host the root projects discovers it +conventionally. When the project selects several targets, a Skill therefore lowers to the one +document all of them accept: the authored Markdown unchanged when every selected host would pass +it through as is — no host extension keys, no path tokens — and otherwise the portable document, +whose frontmatter is the portable metadata alone (the `targets.` keys above are not written +into the shared file) and which admits no placeholder syntax, so a canonical token in such a Skill +reports `AB3008`. A Skill that needs one host's extension keys or tokens belongs in a root built +for that host alone (a separate `--output`). The exception is a file no other host reads: Codex's +`agents/openai.yaml` sidecar, lowered from `targets.codex`, is still written beside the shared +`SKILL.md`. The portable projection beside other hosts carries its own copy under +`portable/skills/`. + ## 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 42ad8a501..88cdc92c9 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -134,7 +134,7 @@ 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 plugin root holding every selected target's 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 host publishes a validator. | +| Install | `agent-bundle install --from ` | The root registered in Claude, Codex, or Cursor. | 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 the root first — every selected host's projection composed into one layout, with +the shared `skills/`, `scripts/`, `mcp/`, and `bin/` content planned once — then lowers its +outputs in at most two stages into one staged directory, 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. + 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. 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. -## What ships inside a target directory - -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. - -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. - -`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). +## What ships inside the root + +The root contains 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. A root that projects two or more hosts also carries a generated `AGENTS.md` explaining +how each host reads it: Claude Code owns the conventional `hooks/hooks.json` and `.mcp.json`, +Codex beside it reads `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json` through pointers in +its manifest, Cursor beside another host reads `hooks/hooks-cursor.json` and the root `mcp.json`, +and the portable projection is the namespaced `portable/` pack whose `portable/mcp/.mjs` +shims run the shared compiled servers. + +A root that projects Claude Code or Codex always includes their local marketplace manifests +(`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`), which is what lets their +public CLIs install the root directly. A root that projects Cursor or portable includes a +standalone `install.mjs`, because Cursor exposes marketplace management but no non-interactive +plugin install verb. + +`agent-bundle.manifest.json` records every emitted file, root-relative, with its SHA-256, so +validation compares real bytes rather than checking that a path exists; its `targets[]` lists the +hosts the root projects. `agent-bundle.hooks.json` indexes the hook wrappers with one entry per +hook and host, so a wrapper shared by Claude Code and Codex appears once per host. The layout +itself is covered in [Project structure](../start/project-structure.mdx#host-artifacts). 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 diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 9c34fa9c2..cb894abca 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -4,51 +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 built root 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 is installed from the same root: ```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 **source-free plugin root** — the directory holding the selected host's +manifest (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, or a portable `plugin.json`). +For the portable view beside other hosts, `install cursor --from /portable` installs that pack +(Cursor loads the Agent Plugins format natively; there is no `portable` install host). Nothing nested +under the root is probed: `--from` must name the directory that holds the manifest itself. ## 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` 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); a multi-host root's `hooks/hooks-cursor.json` and `.codex-plugin/hooks.json` 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 reads the hooks and MCP documents its `.codex-plugin/plugin.json` points at — `hooks/hooks.json` and `.mcp.json` when it is the only host that owns them, `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json` beside Claude Code. | `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 hooks document the plugin manifest names (`hooks/hooks.json` when Cursor is the only host, `hooks/hooks-cursor.json` beside another host), 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 a root that projects Claude Code or Codex always ships their 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 that projects Cursor or portable includes an `install.mjs` that copies the Cursor-loadable +pack into `~/.cursor/plugins/local/` without overwriting collisions — the root itself when +`cursor` is selected, otherwise the Agent Plugins pack (`portable/` beside other hosts, the root +for a portable-only build): ```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 emitted root: 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 +For a `portable` (Agent Plugins 1.0.0) pack 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 @@ -61,9 +67,8 @@ Plugins clients; the pre-expansion document is kept in the install receipt (`cur 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. -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 @@ -163,11 +168,12 @@ 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 +# claude plugin uninstall --keep-data, then marketplace remove +npx agent-bundle uninstall claude --from artifact +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 +239,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, so a multi-host root'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`. | ## Next diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index d910ebeeb..f5c04534c 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -47,19 +47,19 @@ 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: +over a root that projects `claude`. `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 +72,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 +103,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 +114,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 +139,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 (`hooks/hooks.json` when Cursor is the only projected host, `hooks/hooks-cursor.json` beside another host, 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..64840441a 100644 --- a/website/docs/en/guide/start/index.mdx +++ b/website/docs/en/guide/start/index.mdx @@ -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. +With `targets: ['claude', 'codex', 'cursor']`, that single command emits one plugin root at +`artifact/`: `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared +`skills/`, `hooks/`, `mcp/`, and `scripts/` directories, a generated `AGENTS.md` explaining how +each host reads the root, and an `INSTALL.md` with a section per host. The targets are `claude`, +`codex`, `cursor`, and `portable` (the Agent Plugins open standard): select one and the root is +that host's layout, select several and they compose into the same root. ## 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' } } }, diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index e39ad813a..f5f9aa312 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 └── 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 the root's `scripts/.mjs` and shared by 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/.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 root's `assets/` directory. | 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 @@ -103,33 +103,44 @@ shadows it. The `bin: false` and `lib: false` opt-outs stay silent. ### Host artifacts -One directory per selected target under the artifact root. The CLI defaults that root to -`artifact/`, so it never collides with the package build below; `output.distPath` or `--output` -moves it: +One plugin root that every selected target is projected into. The CLI defaults it to `artifact/`, +so it never collides with the package build below; `output.distPath` or `--output` moves it. Host +manifests sit in their own directories at the root, and the compiled content the hosts share is +emitted once: ```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 +artifact/ # targets: ['claude', 'codex', 'cursor'] +├── .claude-plugin/ # plugin.json and marketplace.json +├── .codex-plugin/ # plugin.json; hooks.json and mcp.json beside Claude Code +├── .agents/plugins/marketplace.json +├── .cursor-plugin/plugin.json # plus marketplace.json when marketplace: true +├── .mcp.json # Claude Code's MCP document +├── mcp.json # Cursor's MCP document +├── bin/.mjs # the routed CLI, when src/cli/** exists +├── skills/ +├── hooks/ # hooks.json, hooks-cursor.json, and the wrappers +├── mcp/ +├── mcp-apps/ # when App routes exist +├── scripts/ +├── assets/ +├── AGENTS.md # roots that project two or more hosts +├── INSTALL.md # one section per selected host +├── install.mjs # when cursor or portable is selected +├── agent-bundle.hooks.json # the hook index: one entry per hook and host +└── agent-bundle.manifest.json # every emitted file, root-relative, with its SHA-256 ``` -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 — +A single-target root is that host's layout unchanged — `targets: ['claude']` emits +`.claude-plugin/` and Claude Code's conventional documents and nothing for any other host, and a +portable-only root is an Agent Plugins pack with `plugin.json` and `mcp.json` at the root. Beside +other hosts the `portable` projection is the namespaced `portable/` directory: a complete pack +whose `portable/mcp/.mjs` shims run the shared compiled servers under `mcp/`. `plugin` is +not a target name and is rejected as unknown (`AB4100`); omitting `targets` selects `portable`. +`agent-bundle.manifest.json` records every emitted file with its SHA-256, so artifact validation +is content-addressed rather than a guess. + +`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. diff --git a/website/docs/en/guide/start/quick-start.mdx b/website/docs/en/guide/start/quick-start.mdx index 679b86bd1..fd8775b93 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' } } }, @@ -81,12 +81,12 @@ deprecated compatibility axis, but a value that disagrees with `package.json` re ## Build, or work interactively ```sh -npx agent-bundle build --root . # write installable artifacts to artifact/ +npx agent-bundle build --root . # write the 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 one plugin root that every selected target reads, 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. @@ -104,22 +104,25 @@ 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 built root contains one generated `INSTALL.md`, a section per selected host, with commands +that use the bundle's real plugin and marketplace names. With the `portable`, `codex`, and +`claude` targets built above, the host installs all point at the root: ```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 ``` -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 `npx agent-bundle install cursor --from artifact` installs the same +root. The portable pack — read natively by Cursor, Codex, VS Code, GitHub Copilot, Kiro, and +ChatGPT — is the root itself when `portable` is the only target and the root's `portable/` +directory beside other hosts; the root's `install.mjs` copies it into Cursor's local plugin +directory without the framework CLI. 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..2a6988d12 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -60,19 +60,19 @@ 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 for every host + details: A build is one plugin root — the unit you install — carrying every selected host's manifests over the Skills, hook wrappers, and MCP servers they share, a generated INSTALL.md with a section per host, 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 +The input is one config file and a conventional `src/` tree. The output is one plugin root that +every selected host reads — each host's own manifests over the compiled Skills, wrappers, and +servers they share, installation instructions written with the bundle's real names, and the `agent-bundle.manifest.json` that the whole artifact is validated against.
@@ -115,71 +115,49 @@ 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 │ └── marketplace.json -├── .mcp.json -├── hooks/ +├── .codex-plugin/ +│ ├── plugin.json # points at the two documents below │ ├── 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 +│ └── mcp.json ├── .agents/plugins/marketplace.json -├── .mcp.json +├── .mcp.json # Claude Code's MCP document ├── 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/portable/" -artifact/portable/ -├── plugin.json -├── mcp.json -├── mcp/mcp-status-….mjs +│ ├── hooks.json # Claude Code's hooks document +│ └── session-start-….mjs # one wrapper, named by both hooks documents +├── mcp/mcp-status-….mjs # compiled once, run by every host ├── scripts/check-service.mjs ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md +├── portable/ # the Agent Plugins view of the same root +│ ├── plugin.json +│ ├── mcp.json +│ ├── mcp/mcp-status-….mjs # a shim that runs the shared server above +│ └── skills/release-review/… +├── AGENTS.md +├── INSTALL.md ├── install.mjs -└── INSTALL.md +├── agent-bundle.hooks.json +└── agent-bundle.manifest.json ``` -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. - - - +Every selected host reads this one directory. Claude Code owns the conventional `hooks/hooks.json` +and `.mcp.json`; Codex beside it reads `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json` +through explicit pointers in its manifest, and a hook selecting both compiles to one host-detecting +wrapper. 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 without a +diagnostic (naming it explicitly, `targets: ['portable']`, is `AB4204` at build time). Beside other +hosts its projection is the namespaced `portable/` pack, whose `mcp.json` reaches the shared +compiled server through a shim. 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. +their contents. `agent-bundle.manifest.json` records every emitted file, root-relative, with its +SHA-256, so later validation compares real bytes rather than checking that a path exists.
@@ -208,9 +186,9 @@ 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. -[Validation](/guide/distribution/validation) checks the artifact against its manifest, and -[installation](/guide/distribution/installation) uses each host's own install path. +`agent-bundle build` validates the project and writes one plugin root that every selected host +reads. [Validation](/guide/distribution/validation) checks the artifact against its manifest, and +[installation](/guide/distribution/installation) points each host's own install path at the root. @@ -221,14 +199,15 @@ from route-unit tests up to a bundle installed through the real host CLI — and | `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. | - -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). +| `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. Beside other hosts it is the root's `portable/` directory. | The generated `install.mjs`. | + +Select one target and the root is that host's layout unchanged; select several and their +projections compose into the same root. 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/` pack above shows — rather than +failing. 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 2a6015505..740259fe4 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -37,12 +37,13 @@ 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 ` | all configured | Host target to project into the root: `claude`, `codex`, `cursor`, or `portable`. Repeatable; the selected targets share one root. `plugin` is not a target (`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 plugin root and `--target ` to select which projected host's view of it to +operate. `--target` is **required** for `mcp list`, `mcp invoke`, `mcp run`, and `hooks simulate`; `serve-app` defaults it to `portable` and takes no `--json`. @@ -118,23 +119,23 @@ 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 root is written, run the installed Claude Code developer validator and load check over it when it projects `claude` — 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 -with a `load` verdict (`loaded`, `refused`, `unregistered`, or `failed`) when the load check ran. +The JSON document carries the report under `hostValidation`, 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 built plugin root — the directory holding the host's manifest (`agent-bundle install claude --from artifact`). For the portable pack beside other hosts, pass `/portable` to `install cursor`. Nothing nested under the root is probed. | | `--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 built plugin root that identifies the plugin (name, version, marketplace); resolved 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 built plugin root to compare installed copies against; resolved as for `install`. | | `--json` | off | Write one machine-readable JSON document. | Doctor exits `1` when it reports any error diagnostic, and never modifies anything. With `--from`, @@ -216,8 +217,8 @@ 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). | +| `--artifact ` | — | Validate exactly this built plugin root instead of project source. | +| `--host-validation` / `--no-host-validation` | on | Run the host checks for every host the root projects: when it projects `claude`, 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); the pinned-schema document checks for `codex`, `cursor`, and `portable` (over `/portable` when the portable view sits beside other hosts). | | `--strict` | off | Promote host-tool warnings to errors. | ## eval diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 8f56a74d0..ef79ecf1b 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[]` — `claude`, `codex`, `cursor`, `portable`, projected into one root | `['portable']`. | | `skills` | `string[]` | The `src/skills/*` convention. | | `hooks` | `Partial>` | The `src/hooks/*` convention. | | `mcp` | `{ servers: Record }` | The `src/mcp/*` convention. | diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 03c56e578..ed1a496ac 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -94,8 +94,9 @@ 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. +it would fragment that state on every rebuild. Pass `--plugin-root ` pointing at the host's +plugin root inside the artifact — the root itself, or `/portable` for the portable view +beside other hosts — 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 467ee1ca9..276de7992 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -9,36 +9,43 @@ commands, and hooks — is in [Configuration model](../guide/authoring/index.mdx layouts are in [Project structure](../guide/start/project-structure.mdx). This page is the artifact contract those layouts have to satisfy. -## 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 built plugin root is the unit of distribution: no packaging step follows the build, and there +is no per-host repackaging. One root carries every selected host's manifests, the compiled content +they share, a generated `INSTALL.md` with a section per host written with the bundle's real +plugin and marketplace names, and the install surface each selected target requires. -| Target | Marketplace manifests | Install surface | +| Target | Host manifests at the root | 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`. | -| `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 +| `claude` | `.claude-plugin/plugin.json` and the local marketplace `.claude-plugin/marketplace.json`. | `claude plugin marketplace add` + `claude plugin install`. | +| `codex` | `.codex-plugin/plugin.json` and the local marketplace `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` + `codex plugin add`. | +| `cursor` | `.cursor-plugin/plugin.json`, plus `.cursor-plugin/marketplace.json` when `marketplace: true`. | `install.mjs`. | +| `portable` | `plugin.json` for a portable-only root; the namespaced `portable/` pack beside other hosts. | `install.mjs`. | + +A root that projects two or more hosts also carries a generated `AGENTS.md`. Where two hosts would +read one conventional document, the composition relocates the one a host reads through an +explicit pointer — Codex beside Claude Code reads `.codex-plugin/hooks.json` and +`.codex-plugin/mcp.json`, Cursor beside another host reads `hooks/hooks-cursor.json` — and a hook +selecting both Claude Code and Codex compiles to one host-detecting `hooks/.mjs` that both +documents name. `plugin` is not a target name (`AB4100`). + +Artifact validation errors when the root 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`). ## agent-bundle.manifest.json -One manifest is emitted per artifact and is the input to every later integrity check — +One manifest is emitted at the 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 | | --- | --- | | `producer` | `{ name: 'agent-bundle', version }`. | | `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. | +| `files` | Every emitted file: root-relative `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 projected host — `claude`, `codex`, `cursor`, `portable`: `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. | @@ -51,6 +58,14 @@ a path exists, and a hand-edited generated file fails. That contract is what mak `validate --artifact`, `mcp`, and `hooks` work against an artifact whose project sources have been deleted. +## agent-bundle.hooks.json + +The hook index beside the manifest lists one entry per hook **and** per host it serves — `event`, +`id`, `name`, the root-relative wrapper `path`, `target`, and an optional `timeout` — ordered by +`(target, id)`. A wrapper shared by Claude Code and Codex therefore appears once per host with the +same `path`. `agent-bundle hooks list` and `hooks simulate` read this index, and `--target` filters +it by host. + ## Versions and revisions Four version axes are tracked separately and are expected to agree: diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index 20a1cddbe..968b0b14b 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 Code 与 +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..93abec999 100644 --- a/website/docs/zh/examples/hooks-and-scripts.mdx +++ b/website/docs/zh/examples/hooks-and-scripts.mdx @@ -19,13 +19,13 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个输出 - **钩子是写成处理函数,而不是宿主文档。** `src/hooks/session-start.ts` 只是一个模块。构建会把它降级为 各宿主自己的钩子文档形状,并输出运行它的包装器。见[钩子](../guide/authoring/hooks.mdx)。 - **两种脚本声明方式都在。** `verify-release` 按约定发布——`src/scripts/` 下任何未被声明占用的普通脚本 - 都会被发现——而 `detect-risk` 保持显式配置,因为它要把自己的 target 限制为 `portable`。示例故意让两种 + 都会被发现——而 `detect-risk` 保持显式配置,因为它选择了宿主:只有投影 `portable` 的根目录才会输出它,并且像每个脚本一样输出到该根目录所有宿主共享的 `scripts/`。示例故意让两种 方式都有覆盖。 - **进程外壳属于框架。** 两个脚本都导出 `main` 并返回退出码。argv 处理、等待与退出码采纳都归生成的外壳 所有,因此一个非零返回值会变成真正的阻断性退出,而不是被吞掉的返回值。见 [脚本与资源](../guide/authoring/scripts-assets.mdx)。 -- **输出的脚本自行解析自己的资源。** `assets: ['release/*.json']` 把发布清单与风险登记表复制进每个 - target,而每个输出脚本都相对自己所在的模块去读取它们——而不是相对 shell 的工作目录。 +- **输出的脚本自行解析自己的资源。** `assets: ['release/*.json']` 把发布清单与风险登记表复制进插件 + 根目录的 `assets/`,而每个输出脚本都相对自己所在的模块去读取它们——而不是相对 shell 的工作目录。 - **失败的重建保留上一个可用产物。** 这就是下面那段可逆演练。 ## 编写了什么 @@ -34,7 +34,7 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个输出 | --- | --- | | `src/hooks/session-start.ts` | `sessionStart` 处理函数,把发布会话导向那两项检查。 | | `src/scripts/verify-release.ts` | 以清单为依据的打包检查,按约定被发现。 | -| `src/scripts/detect-risk.ts` | 风险登记表检查,显式配置以限制它的 target。 | +| `src/scripts/detect-risk.ts` | 风险登记表检查,显式配置为选择 `portable` 宿主。 | | `release/release-manifest.json` | 被作为资源复制的打包发布清单。 | | `release/risk-register.json` | 阻断性检查读取的风险登记表。 | diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index d7f276dc6..f11d646cf 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -255,12 +255,14 @@ 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` 与为 +两种形态共享输出的钩子文档接线,并且都编译成宿主以 +`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"`(或宿主自己的根令牌)调用的包装层。在投影多个宿主的 +根目录中,Claude Code 拥有约定位置的 `hooks/hooks.json`;与它并列的 Codex 通过 `.codex-plugin/plugin.json` +中的 `hooks` 指针读取 `.codex-plugin/hooks.json`,而同时选择两者的钩子会编译成一个能识别宿主的 +`hooks/.mjs`,被两份文档同时引用。与其他宿主并列的 Cursor 读取 `hooks/hooks-cursor.json`,使用自己的 +`hooks/.cursor.mjs` 包装层(`preToolUse` / `postToolUse` 小驼峰)。Claude Code 按约定加载 +`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 发送了小驼峰。配置声明的处理器 diff --git a/website/docs/zh/guide/authoring/index.mdx b/website/docs/zh/guide/authoring/index.mdx index d3d88a05e..fb5281373 100644 --- a/website/docs/zh/guide/authoring/index.mdx +++ b/website/docs/zh/guide/authoring/index.mdx @@ -36,15 +36,25 @@ export default defineConfig({ ## Targets -`targets` 选择构建要输出的产物布局: +`targets` 选择构建要投影到同一个插件根目录中的宿主: | 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/plugin.json` 与 `marketplace.json`、`.mcp.json`、`hooks/hooks.json`。 | +| `codex` | Codex 插件布局:`.codex-plugin/plugin.json` 与 `.agents/plugins/marketplace.json`。 | +| `cursor` | Cursor 插件布局:`.cursor-plugin/plugin.json`(`marketplace: true` 时还有 `marketplace.json`)与根目录的 `mcp.json`。 | +| `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0):根目录的 `plugin.json` 与 `mcp.json`,与其他宿主并列时则是带命名空间的 `portable/` 包。Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 都是原生客户端。 | + +只选一个 target,根目录就是该宿主原本的布局。选多个,它们的投影就组合进同一个根目录:`skills/`、`scripts/`、 +`mcp/`、`mcp-apps/`、`bin/` 与 `assets/` 只输出一次并共享,每个宿主的清单位于各自的目录中,构建还会加上一份 +说明各宿主如何读取根目录的 `AGENTS.md`,以及按宿主分节的一份 `INSTALL.md`。当两个宿主会读取同一份约定位置的 +文档时,组合要么把某个宿主读取的文档搬到显式清单指针之后——与 Claude Code 并列的 Codex 读取 +`.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`,与其他宿主并列的 Cursor 读取 `hooks/hooks-cursor.json`—— +要么报告诊断(某个 skill 或命令只选择了共享同一目录的部分宿主时是 `AB4104`,两个投影对同一路径输出不同字节时 +是 `AB4105`);它绝不会悄悄扩大声明的宿主范围。只有这些内置宿主可以组合:注册在高级 `TargetRegistry` 上的 +适配器单独构建,每个 target 一个 `--output`,将它与内置宿主一起选中会被拒绝(`AB4106`)。省略 `targets` 时选中 +`portable`。`plugin` 不是 target 名称,会作为未知 target 被拒绝(`AB4100`)。输出的布局见 +[项目结构](../start/project-structure.mdx#宿主产物)。 可移植标准只打包 Skills 与 MCP 服务器,因此规则、命令与钩子在该 target 上是诚实地不可用,而不是被 悄悄丢弃。Claude Code 只能通过 CLI 转译消费该标准,这正是它仍需要专属 target 的原因。 @@ -54,7 +64,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 +108,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..95c25e9ff 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 共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 `node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 包 bin 一致;唯一的区别是,在宿主未提供 `AGENT_BUNDLE_PLUGIN_ROOT` 时,工作区级持久状态锚定在产物根目录 diff --git a/website/docs/zh/guide/authoring/scripts-assets.mdx b/website/docs/zh/guide/authoring/scripts-assets.mdx index f59650a9d..65dfc58d4 100644 --- a/website/docs/zh/guide/authoring/scripts-assets.mdx +++ b/website/docs/zh/guide/authoring/scripts-assets.mdx @@ -84,7 +84,7 @@ react-server worker。 ## 资源 -根 `assets/` 目录下的文件按字节复制到每个 target 产物的 `assets/` 目录。这个约定不需要任何配置。 +根 `assets/` 目录下的文件按字节复制到插件根目录的 `assets/` 目录,由所有所选 target 共享。这个约定不需要任何配置。 顶层 `assets` 列表会**取代**该约定,改为显式条目——字面文件路径、整个目录,或 glob,全部相对项目 根目录解析: diff --git a/website/docs/zh/guide/authoring/skills.mdx b/website/docs/zh/guide/authoring/skills.mdx index 7aa6e624a..5952df859 100644 --- a/website/docs/zh/guide/authoring/skills.mdx +++ b/website/docs/zh/guide/authoring/skills.mdx @@ -82,6 +82,14 @@ export default defineConfig({ Skill frontmatter 中的 `targets` 就是上述按宿主划分的对象,别无他用;写成列表(如 `targets: ['claude']`)是 `AB3006`。Skill 会输出到项目选择的每一个 target——不存在按 Skill 限制产物的机制。 +一个插件根目录只有一份 `skills//SKILL.md`,根目录投影的每个宿主都按约定发现它。因此当项目选择了多个 +target 时,Skill 会降低为它们全都接受的那一份文档:当每个所选宿主都会原样透传时,就是未经改动的原始 +Markdown——没有宿主扩展键,也没有路径 token——否则就是可移植文档,其 frontmatter 只含可移植元数据(上面的 +`targets.` 键不会写入共享文件),且不接受任何占位符语法,因此这样的 Skill 中出现规范 token 会报告 +`AB3008`。需要某个宿主的扩展键或 token 的 Skill,应放进只为该宿主构建的根目录(单独的 `--output`)。 +例外是没有其他宿主会读取的文件:由 `targets.codex` 降低而来的 Codex `agents/openai.yaml` sidecar 仍会写在 +共享的 `SKILL.md` 旁边。与其他宿主并列的可移植投影在 `portable/skills/` 下带有自己的一份副本。 + ## 路径 token 各宿主的占位符语法并不相同——`${CLAUDE_PLUGIN_ROOT}` 并不是 Cursor 或可移植标准所使用的写法。请改为 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index ac9b24a63..ca2e9a5ee 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -118,7 +118,7 @@ agent-bundle dev proxy --root --server --target --strict` | 内容寻址地证明输出字节与清单一致,外加在宿主发布了校验器时的宿主工具结论。 | -| 安装 | `agent-bundle install --from ` | 把捆绑包注册进 Claude、Codex 或 Cursor。 | +| 构建 | `agent-bundle build` | 一个容纳所有所选 target 投影的插件根目录;当项目声明了 `bin`/`lib` 时,还有 `dist/` 包构建。 | +| 校验 | `agent-bundle validate --artifact --strict` | 内容寻址地证明输出字节与清单一致,外加在宿主发布了校验器时的宿主工具结论。 | +| 安装 | `agent-bundle install --from ` | 把根目录注册进 Claude、Codex 或 Cursor。 | 构建在写出任何东西之前就已经校验过项目,因此针对源码单独运行一次 `validate` 更像是快速的预检,而不是 必需的阶段。真正有意思的是校验**产物**,因为它完全不需要项目源码。 -### 一个 target 如何编译 +### 根目录如何编译 -构建先为每个 target 做规划,然后把该 target 的输出最多分两个阶段降级到同一个暂存根目录,待产物校验通过后 -原子地发布: +构建先规划根目录——把所有所选宿主的投影组合成一个布局,共享的 `skills/`、`scripts/`、`mcp/` 与 `bin/` +内容只规划一次——然后把它的输出最多分两个阶段降级到同一个暂存目录,待产物校验通过后原子地发布: 1. **MCP Apps**——浏览器环境,通过 `@rsbuild/core` 编译。只有当项目声明了 App 路由时这一阶段才存在,并且 始终最先运行:MCP 入口会内嵌它产出的 HTML。 2. **智能体宿主面**——路由式 CLI bin、打包的脚本、hook 包装器、MCP stdio 入口,以及每个面各自的 - react-server Flight worker,全部一起通过**每个 target 一个 Rslib 实例**降级(每个输出一个 Rsbuild + react-server Flight worker,全部一起通过**一个 Rslib 实例**降级(每个输出一个 Rsbuild environment,一个 Rspack 多编译器)。宿主面在运行时按文件名找到它的 worker,因此阶段内二者无需排序; 每个面为清单保留各自的源码证据。 @@ -38,18 +38,24 @@ npx agent-bundle build --root . --output artifact 框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。`agent-bundle inspect --bundler` 会 打印合成结果。 -## 一个 target 目录里发布了什么 +## 根目录里发布了什么 -每个已构建 target 都包含一份生成的 `INSTALL.md`,其中使用捆绑包**真实的**插件名与市场名——而不是 -占位符——因此这份文件可以逐字照做。多宿主的 `plugin` target 还带有一份生成的 `AGENTS.md`,说明如何把它 -安装到各个宿主。 +根目录包含一份生成的 `INSTALL.md`,按所选宿主分节,其中使用捆绑包**真实的**插件名与市场名——而不是 +占位符——因此这份文件可以逐字照做。投影两个或更多宿主的根目录还带有一份生成的 `AGENTS.md`,说明各宿主 +如何读取它:Claude Code 拥有约定位置的 `hooks/hooks.json` 与 `.mcp.json`,与它并列的 Codex 通过自己清单中的 +指针读取 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`,与其他宿主并列的 Cursor 读取 +`hooks/hooks-cursor.json` 与根目录的 `mcp.json`,而可移植投影是带命名空间的 `portable/` 包,其 +`portable/mcp/.mjs` 垫片运行共享的已编译服务器。 -Claude 与 Codex target 始终包含本地市场清单,正是这一点让它们的公开 CLI 能够直接安装输出的目录。 -Cursor、portable 与组合 target 则包含一个独立的 `install.mjs`,因为 Cursor 提供了市场管理能力,却没有 -非交互式的插件安装动词。 +投影了 Claude Code 或 Codex 的根目录始终包含它们的本地市场清单(`.claude-plugin/marketplace.json`、 +`.agents/plugins/marketplace.json`),正是这一点让它们的公开 CLI 能够直接安装根目录。投影了 Cursor 或 +portable 的根目录则包含一个独立的 `install.mjs`,因为 Cursor 提供了市场管理能力,却没有非交互式的插件 +安装动词。 -`agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此校验比对的是真实字节,而不是检查某个 -路径是否存在。各 target 自身的布局在[项目结构](../start/project-structure.mdx)中介绍。 +`agent-bundle.manifest.json` 以相对根目录的路径记录每个输出文件及其 SHA-256,因此校验比对的是真实字节, +而不是检查某个路径是否存在;它的 `targets[]` 列出根目录投影的宿主。`agent-bundle.hooks.json` 为钩子包装脚本 +建立索引,每个钩子、每个宿主一条,因此 Claude Code 与 Codex 共享的包装脚本会按宿主各出现一次。布局本身在 +[项目结构](../start/project-structure.mdx#宿主产物)中介绍。 构建是可复现的:对同一份未改动的源码树构建两次,会得到逐字节相同的产物——相同的清单、相同的摘要—— 无论 `--output` 叫什么名字,也无论每次构建的暂存目录叫什么名字。打包器写进编译入口里的模块标识只由 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 463cda04d..c216f0ef6 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -4,46 +4,50 @@ 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 为拥有它的开发者执行同样的操作: +构建出的根目录包含一份生成的 `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/`、`.codex-plugin/`、 +`.cursor-plugin/` 或可移植 `plugin.json`)的那个目录。对于与其他宿主并列的可移植视图,用 +`install cursor --from /portable` 安装该包(Cursor 原生加载 Agent Plugins 格式;没有 `portable` 这个安装 +宿主)。不会探测根目录之下的任何嵌套目录:`--from` 必须指向直接包含清单的那个目录。 ## 各宿主接受什么 | 宿主 | 机制 | 作用域 | | --- | --- | --- | -| 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`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);多宿主根目录中的 `hooks/hooks-cursor.json` 与 `.codex-plugin/hooks.json` 从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | +| Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。Codex 读取其 `.codex-plugin/plugin.json` 所指向的 hooks 与 MCP 文档——当它是唯一拥有这两份文档的宿主时是 `hooks/hooks.json` 与 `.mcp.json`,与 Claude Code 并列时是 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`。 | `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`,与其他宿主并列时为 `hooks/hooks-cursor.json`),因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | -由于 Claude 与 Codex target 始终随行本地市场清单,它们的公开 CLI 可以直接安装输出的目录。当所选宿主 +由于投影了 Claude Code 或 Codex 的根目录始终随行它们的本地市场清单,它们的公开 CLI 可以直接安装根目录。当所选宿主 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 `AB700x` 家族:捆绑包标识、宿主可用性、作用域、命令失败与冲突检查。 ## 独立安装器 -Cursor、portable 与组合 target 包含一个 `install.mjs`,它把捆绑包复制到 -`~/.cursor/plugins/local/`,且不会覆盖冲突内容: +投影了 Cursor 或 portable 的根目录包含一个 `install.mjs`,它把 Cursor 可加载的包复制到 +`~/.cursor/plugins/local/`,且不会覆盖冲突内容——选择了 `cursor` 时复制根目录本身,否则复制 +Agent Plugins 包(与其他宿主并列时是 `portable/`,只含 portable 的构建则是根目录): ```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 副本中展开该标准的占位符, +对于 `portable`(Agent Plugins 1.0.0)包,安装器还会在 `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 副本会得到绝对的 @@ -52,7 +56,7 @@ node ./install.mjs 客户端仍然符合规范;展开前的文档保存在安装回执中(`cursorExpansion`),`agent-bundle doctor --host cursor` 以 `AB7326` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而 `cursor` target 的捆绑包绝不会被重写。 -产物校验会拒绝缺少必需安装表面的内置 target,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 +产物校验会拒绝缺少必需安装表面的根目录,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 ## 相对包的安装器 bin @@ -135,11 +139,12 @@ 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/ +# 先 claude plugin uninstall --keep-data,再 marketplace remove +npx agent-bundle uninstall claude --from artifact +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` 字段所指定的那个,因此多宿主根目录中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `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..addc1dfe1 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` 会对刚写出的产物运行同样的检查 +当 Claude Code 位于 `PATH` 上时,产物校验会对投影了 `claude` 的根目录运行已安装的 +`claude plugin validate`。`agent-bundle build` 会对刚写出的根目录运行同样的检查 (`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而 -`agent-bundle doctor --host claude --from ` 会对该 bundle 以及 Claude 列出的每一份已安装副本运行 +`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 是唯一投影宿主时为 `hooks/hooks.json`,与其他宿主并列时为 `hooks/hooks-cursor.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..73e98a194 100644 --- a/website/docs/zh/guide/start/index.mdx +++ b/website/docs/zh/guide/start/index.mdx @@ -22,10 +22,11 @@ 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 提供。 +当 `targets: ['claude', 'codex', 'cursor']` 时,这一条命令会在 `artifact/` 生成一个插件根目录: +`.claude-plugin/`、`.codex-plugin/` 与 `.cursor-plugin/` 三份清单位于共享的 `skills/`、`hooks/`、`mcp/` +与 `scripts/` 目录之上,外加一份说明各宿主如何读取该根目录的生成 `AGENTS.md`,以及按宿主分节的 `INSTALL.md`。 +可选的 target 是 `claude`、`codex`、`cursor` 与 `portable`(Agent Plugins 开放标准):只选一个,根目录就是 +该宿主的布局;选多个,它们就组合进同一个根目录。 ## 配置负责什么 @@ -36,7 +37,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' } } }, diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 43867f81a..213c68863 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`,由所有所选 target 共享。嵌套模块是硬错误(`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` 能告诉你某个文件 @@ -99,30 +99,40 @@ export default defineConfig({ ### 宿主产物 -在产物根目录下,每个所选 target 一个目录。命令行把该根目录默认为 `artifact/`,因此它永远不会与下文的包构建 -冲突;`output.distPath` 或 `--output` 可以移动它: +一个插件根目录,所有所选 target 都被投影到其中。命令行把它默认为 `artifact/`,因此它永远不会与下文的包构建 +冲突;`output.distPath` 或 `--output` 可以移动它。宿主清单位于根目录下各自的目录中,各宿主共享的已编译内容 +只输出一次: ```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 +artifact/ # targets: ['claude', 'codex', 'cursor'] +├── .claude-plugin/ # plugin.json 与 marketplace.json +├── .codex-plugin/ # plugin.json;与 Claude Code 并列时还有 hooks.json 与 mcp.json +├── .agents/plugins/marketplace.json +├── .cursor-plugin/plugin.json # marketplace: true 时还有 marketplace.json +├── .mcp.json # Claude Code 的 MCP 文档 +├── mcp.json # Cursor 的 MCP 文档 +├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 +├── skills/ +├── hooks/ # hooks.json、hooks-cursor.json 与各包装脚本 +├── mcp/ +├── mcp-apps/ # 存在 App 路由时出现 +├── scripts/ +├── assets/ +├── AGENTS.md # 投影两个或更多宿主的根目录才有 +├── INSTALL.md # 每个所选宿主一节 +├── install.mjs # 选择了 cursor 或 portable 时出现 +├── agent-bundle.hooks.json # 钩子索引:每个钩子、每个宿主一条 +└── agent-bundle.manifest.json # 每个产出文件(相对根目录)及其 SHA-256 ``` -单宿主布局由 `claude`、`codex`、`cursor` 与 `portable` 这几个 target 提供。 -`agent-bundle.manifest.json` 位于产物根目录、与各 target 目录并列,记录了每个产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 +单 target 的根目录就是该宿主原本的布局——`targets: ['claude']` 只输出 `.claude-plugin/` 与 Claude Code +约定位置的文档,不为任何其他宿主输出任何内容;只含 portable 的根目录则是一个 Agent Plugins 包,`plugin.json` +与 `mcp.json` 位于根目录。与其他宿主并列时,`portable` 投影是带命名空间的 `portable/` 目录:一个完整的包, +其 `portable/mcp/.mjs` 垫片运行 `mcp/` 下共享的已编译服务器。`plugin` 不是 target 名称,会作为 +未知 target 被拒绝(`AB4100`);省略 `targets` 时选中 `portable`。`agent-bundle.manifest.json` 记录了每个 +产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 -`output.distPath` 只移动产物根目录;它从不改变每个 target 内部由框架拥有的布局。优先级是 CLI +`output.distPath` 只移动根目录;它从不改变根目录内部由框架拥有的布局。优先级是 CLI `--output`,然后 `output.distPath`,最后是默认值——对同时输出包构建的 `agent-bundle build` 是 `artifact`,对不带 `packageOutputs` 的编程式 `build()` 是 `dist`。取值必须是非空、限定在项目根目录内的 相对 POSIX 路径。 diff --git a/website/docs/zh/guide/start/quick-start.mdx b/website/docs/zh/guide/start/quick-start.mdx index 07fd8cfbc..3c6aa33f1 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' } } }, @@ -74,11 +74,11 @@ export default defineConfig({ ## 构建,或交互式开发 ```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` 会校验项目并写出一个所有所选 target 共同读取的插件根目录;声明了 `bin`/`lib` 时还会一并完成包构建。`dev` 在 loopback 上提供 开发者 Workbench,并随输入变化持续重建:项目概览与诊断、Skill 文档、带 provenance 与 epoch 对比的 产物树、绑定到产物、带原始协议轨迹的 MCP playground、运行真实生成包装层的钩子 playground,以及 eval 运行。 @@ -95,21 +95,23 @@ npx agent-bundle validate --root . # 检查项目源码 ## 安装结果 -每个构建出的 target 目录中都包含生成的 `INSTALL.md`,其中的命令使用该捆绑包真实的插件名与市场名。按上文构建出 -`portable`、`codex` 与 `claude` 三个 target 后,宿主安装命令是: +构建出的根目录中包含一份生成的 `INSTALL.md`,按所选宿主分节,其中的命令使用该捆绑包真实的插件名与市场名。 +按上文构建出 `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 ``` -把 `cursor` 加入 `targets` 就会构建出 `artifact/cursor`,然后用 `npx agent-bundle install cursor --from -artifact/cursor` 以同样方式安装。`plugin` 构建则只有一份覆盖所有宿主的 `INSTALL.md`。 +把 `cursor` 加入 `targets`,`npx agent-bundle install cursor --from artifact` 就会安装同一个根目录。 +可移植包——Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 原生读取——在 `portable` 是唯一 target +时就是根目录本身,与其他宿主并列时则是根目录下的 `portable/` 目录;根目录中的 `install.mjs` 无需框架 CLI +即可把它复制到 Cursor 的本地插件目录。 若想在 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..de8ab39d9 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -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/` 目录树。输出是一个所有所选宿主共同读取的插件根目录—— +每个宿主自己的清单位于它们共享的已编译 Skill、包装脚本与服务器之上,附带用捆绑包真实名称写成的安装说明, +以及整个产物据以校验的 `agent-bundle.manifest.json`。
@@ -113,69 +113,46 @@ Skill、MCP 服务器与脚本都按约定被发现。只有钩子需要声明 ### 编译器输出 - - - -```text title="artifact/claude/" -artifact/claude/ +```text title="artifact/" +artifact/ ├── .claude-plugin/ │ ├── plugin.json │ └── marketplace.json -├── .mcp.json -├── hooks/ +├── .codex-plugin/ +│ ├── plugin.json # 指向下面两份文档 │ ├── 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 +│ └── mcp.json ├── .agents/plugins/marketplace.json -├── .mcp.json +├── .mcp.json # Claude Code 的 MCP 文档 ├── 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/portable/" -artifact/portable/ -├── plugin.json -├── mcp.json -├── mcp/mcp-status-….mjs +│ ├── hooks.json # Claude Code 的钩子文档 +│ └── session-start-….mjs # 一个包装脚本,被两份钩子文档同时引用 +├── mcp/mcp-status-….mjs # 只编译一次,所有宿主共同运行 ├── scripts/check-service.mjs ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md +├── portable/ # 同一根目录的 Agent Plugins 视图 +│ ├── plugin.json +│ ├── mcp.json +│ ├── mcp/mcp-status-….mjs # 运行上面共享服务器的垫片 +│ └── skills/release-review/… +├── AGENTS.md +├── INSTALL.md ├── install.mjs -└── INSTALL.md +├── agent-bundle.hooks.json +└── agent-bundle.manifest.json ``` -Agent Plugins 开放标准没有钩子这一表面。上面的钩子没有声明 `targets`,因此只继承所选 target 中支持钩子的那些,`portable` -在这里会被跳过且不产生诊断。若显式写出 `targets: ['portable']`,构建时则会得到 `AB4204`。 - - - +所有所选宿主读取的都是这一个目录。Claude Code 拥有约定位置的 `hooks/hooks.json` 与 `.mcp.json`; +与它并列的 Codex 通过自己清单中的显式指针读取 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`, +而同时选择两者的钩子会编译成一个能识别宿主的包装脚本。Agent Plugins 开放标准没有钩子这一表面—— +上面的钩子没有声明 `targets`,因此只继承所选 target 中支持钩子的那些,`portable` 会被跳过且不产生诊断 +(若显式写出 `targets: ['portable']`,构建时则会得到 `AB4204`)。与其他宿主并列时,它的投影是带命名空间的 +`portable/` 包,其 `mcp.json` 通过一个垫片到达共享的已编译服务器。 -生成的包装脚本文件名以一段短摘要结尾,摘要来自编译它的声明,而非文件内容。`artifact/agent-bundle.manifest.json` -记录每个输出文件及其 SHA-256,因此后续校验比对的是真实字节,而不是检查某个路径是否存在。 +生成的包装脚本文件名以一段短摘要结尾,摘要来自编译它的声明,而非文件内容。`agent-bundle.manifest.json` +以相对根目录的路径记录每个输出文件及其 SHA-256,因此后续校验比对的是真实字节,而不是检查某个路径是否存在。
@@ -202,8 +179,8 @@ CLI 安装的捆绑包——并运行结果为通过、失败或不确定的[评 ### 交付 -`agent-bundle build` 校验项目并为每个 target 写出一个目录。[校验](/zh/guide/distribution/validation) -依据清单检查产物,[安装](/zh/guide/distribution/installation)则走每个宿主自己的安装路径。 +`agent-bundle build` 校验项目并写出一个所有所选宿主共同读取的插件根目录。[校验](/zh/guide/distribution/validation) +依据清单检查产物,[安装](/zh/guide/distribution/installation)则让每个宿主自己的安装路径指向这个根目录。 @@ -214,13 +191,12 @@ CLI 安装的捆绑包——并运行结果为通过、失败或不确定的[评 | `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。 | +| `portable` | [Agent Plugins](https://agent-plugins.org) 开放标准——Skill 与 MCP 服务器——Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 原生读取。与其他宿主并列时,它就是根目录下的 `portable/` 目录。 | 生成的 `install.mjs`。 | -各宿主能加载的内容不同,编译器会在构建时明确指出:你为某个 target 选择了它无法表达的表面,就会得到一条 -被报告的诊断,绝不会被悄悄省略。唯一有意为之的例外是没有自己的 `targets` 的钩子:它只继承支持钩子的 -宿主——正如上面的 `portable` 标签页所示——而不是让构建失败。每条诊断都有稳定的 `AB` 代码,记录在 -[诊断参考](/zh/reference/diagnostics)中。 +只选一个 target,根目录就是该宿主原本的布局;选多个,它们的投影就组合进同一个根目录。各宿主能加载的内容不同, +编译器会在构建时明确指出:你为某个 target 选择了它无法表达的表面,就会得到一条被报告的诊断,绝不会被悄悄省略。 +唯一有意为之的例外是没有自己的 `targets` 的钩子:它只继承支持钩子的宿主——正如上面的 `portable/` 包所示—— +而不是让构建失败。每条诊断都有稳定的 `AB` 代码,记录在[诊断参考](/zh/reference/diagnostics)中。 ## 从这里开始 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index e74e56c53..91cc1d6d6 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 ` | 全部已配置 | 要投影进根目录的宿主 target:`claude`、`codex`、`cursor` 或 `portable`。可重复;所选 target 共享同一个根目录。`plugin` 不是 target(`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` +`--root`、`--config`、`--mode` 与 `--json`,外加 `--artifact `(精确使用某一个已构建的插件根目录)与 +`--target `(选择要操作的是哪个被投影宿主对它的视图)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` 来说,`--target` 是**必填**的;`serve-app` 默认取 `portable`,且不接受 `--json`。 ## dev @@ -113,23 +113,23 @@ 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 一份;加载检查运行过时,每份报告带有 +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()` | 已构建的插件根目录——包含该宿主清单的那个目录(`agent-bundle install claude --from artifact`)。对于与其他宿主并列的可移植包,把 `/portable` 传给 `install cursor`。不会探测根目录之下的任何嵌套目录。 | | `--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 +144,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 +175,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | -| `--from ` | —— | 用于比对的 target 捆绑包目录或产物根目录。 | +| `--from ` | —— | 用于比对已安装副本的已构建插件根目录;解析方式与 `install` 相同。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | 当 Doctor 报告任何 error 级诊断时以 `1` 退出,并且绝不修改任何东西。带 `--from` 时,它按宿主把已安装副本报告为 @@ -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` | 开启 | 对根目录投影的每个宿主运行宿主检查:投影了 `claude` 时,是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`);对 `codex`、`cursor` 与 `portable` 则是固定 schema 的文档检查(可移植视图与其他宿主并存时,检查的是 `/portable`)。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | ## eval diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index 55ffc84f0..5207c7a90 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[]`——`claude`、`codex`、`cursor`、`portable`,投影进同一个根目录 | `['portable']`。 | | `skills` | `string[]` | `src/skills/*` 约定。 | | `hooks` | `Partial>` | `src/hooks/*` 约定。 | | `mcp` | `{ servers: Record }` | `src/mcp/*` 约定。 | diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 8dd13a4fe..85b8a1fcd 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -86,7 +86,8 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后 -启动」,请传入指向产物 target 根目录的 `--plugin-root `。 +启动」,请传入指向产物中该宿主插件根目录的 `--plugin-root `——即根目录本身,或者当可移植视图与 +其他宿主并列时的 `/portable`。 当服务器名是单个安全路径段时,逐服务器状态目录直接使用该名字;其他任何名字都会变成内容寻址的 `server-` 段,因此像 `../shared` 这样的名字绝不可能穿出状态根目录。 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index ff086ff47..fec8a489e 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -8,34 +8,40 @@ target 表格——各 target 输出哪种布局,以及 portable 标准为何 [配置模型](../guide/authoring/index.mdx)中。目录布局在 [项目结构](../guide/start/project-structure.mdx)中。本页讲的是这些布局必须满足的产物契约。 -## 每个 target 目录都可分发 +## 根目录即可分发 -已构建的 target 目录就是分发单位:构建之后没有打包步骤。每个目录都携带宿主要读取的组件、一份使用捆绑包 -真实插件名与市场名写成的生成式 `INSTALL.md`,以及该 target 所需的安装表面。 +已构建的插件根目录就是分发单位:构建之后没有打包步骤,也没有逐宿主的重新打包。一个根目录携带所有所选宿主 +的清单、它们共享的已编译内容、一份按宿主分节且使用捆绑包真实插件名与市场名写成的生成式 `INSTALL.md`, +以及每个所选 target 所需的安装表面。 -| Target | 市场清单 | 安装表面 | +| 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`。 | -| `portable` | —— | `install.mjs`。 | -| `plugin` | Claude、Codex 与 Cursor 三份清单,外加一份生成的 `AGENTS.md`。 | `install.mjs` 与各宿主 CLI。 | - -当某个 target 缺少必需的安装表面文件时,产物校验会报错,因此捆绑包不可能在缺少其 `INSTALL.md` 所承诺的 +| `claude` | `.claude-plugin/plugin.json` 与本地市场清单 `.claude-plugin/marketplace.json`。 | `claude plugin marketplace add` + `claude plugin install`。 | +| `codex` | `.codex-plugin/plugin.json` 与本地市场清单 `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` + `codex plugin add`。 | +| `cursor` | `.cursor-plugin/plugin.json`,`marketplace: true` 时还有 `.cursor-plugin/marketplace.json`。 | `install.mjs`。 | +| `portable` | 只含 portable 的根目录为 `plugin.json`;与其他宿主并列时为带命名空间的 `portable/` 包。 | `install.mjs`。 | + +投影两个或更多宿主的根目录还带有一份生成的 `AGENTS.md`。当两个宿主会读取同一份约定位置的文档时,组合会把 +某个宿主读取的文档搬到显式指针之后——与 Claude Code 并列的 Codex 读取 `.codex-plugin/hooks.json` 与 +`.codex-plugin/mcp.json`,与其他宿主并列的 Cursor 读取 `hooks/hooks-cursor.json`——而同时选择 Claude Code +与 Codex 的钩子会编译成一个能识别宿主的 `hooks/.mjs`,被两份文档同时引用。`plugin` 不是 target +名称(`AB4100`)。 + +当根目录缺少必需的安装表面文件时,产物校验会报错,因此捆绑包不可能在缺少其 `INSTALL.md` 所承诺的 安装器的情况下发布。npm pack 清单会检查同样的路径(`AB7010`)。 ## agent-bundle.manifest.json -每份产物输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 +根目录输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 `doctor --from`,以及 packed 与 installed-host 证明级别。 | 字段 | 内容 | | --- | --- | | `producer` | `{ name: 'agent-bundle', version }`。 | | `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、经过校验的 `packageName` 与 `packageVersion`(未打包的开发项目中不存在),以及 `sourceInputs`。 | -| `files` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | +| `files` | 每个输出文件:相对根目录的 `path`、`bytes`、`sha256`、`kind`、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | | `runtime` | `{ node }` —— 所选的生成式可执行文件下限。 | -| `targets` | 每个 target 的:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | +| `targets` | 每个被投影宿主一行——`claude`、`codex`、`cursor`、`portable`:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | | `agentSkills` | Agent Skills 规范固定值:`specification`、`sourceRevision`、`schemaSha256`。 | | `validation` | `source`、`artifact` 与各 target 的已记录状态。 | @@ -46,6 +52,13 @@ target 表格——各 target 输出哪种布局,以及 portable 标准为何 由于每个文件都携带摘要,校验比对的是**真实字节**,而不是检查路径是否存在,因此被手工改过的生成文件会 失败。正是这一契约,让 `validate --artifact`、`mcp` 与 `hooks` 能在项目源码已被删除的产物上工作。 +## agent-bundle.hooks.json + +清单旁边的钩子索引为每个钩子**以及**它服务的每个宿主列出一条——`event`、`id`、`name`、相对根目录的 +包装脚本 `path`、`target`,以及可选的 `timeout`——按 `(target, id)` 排序。因此 Claude Code 与 Codex 共享的 +包装脚本会按宿主各出现一次,`path` 相同。`agent-bundle hooks list` 与 `hooks simulate` 读取这份索引, +`--target` 按宿主过滤它。 + ## 版本与修订 有四条版本轴被分别追踪,并且被期望彼此一致: