diff --git a/.changeset/555-composite-plugin-root.md b/.changeset/555-composite-plugin-root.md new file mode 100644 index 000000000..73d1a9b98 --- /dev/null +++ b/.changeset/555-composite-plugin-root.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"create-agent-bundle": minor +--- + +Emit one composite plugin root: `agent-bundle build` writes a single directory at the artifact output and `targets` (`claude`, `codex`, `cursor`, `portable`; default `portable`) selects which host projections it carries, so there is no `artifact/` partition — host manifests sit in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `plugin.json`), Codex and Cursor hook/MCP documents move beside their manifests (`.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/hooks.json`, `.cursor-plugin/mcp.json`; `AB6027`, `AB6032`, and `AB7320` name those paths), and `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `INSTALL.md` are emitted once, with `install.mjs` beside them whenever `cursor` or `portable` is selected. A hook shared by several selected hosts compiles to `hooks/..mjs` per host, each wrapper baking its own host, so generated hook wrappers no longer read `AGENT_BUNDLE_HOOK_HOST`; compiled MCP entries, scripts, and CLI bins are attributed to the sorted composite identity (`claude+codex`), and reordering `targets` yields byte-identical output. Remove the `plugin` target: `targets: ['plugin']` and `--target plugin` fail with `AB4100`, the generated `AGENTS.md` is gone, `create-agent-bundle --target` accepts only the four hosts, and the advanced-registry hooks `TargetAdapter.lowersConfigExtensions`, `TargetRegistry.lowersConfigExtension()`, and `NormalizationTargetRegistry.lowersConfigExtension()` are removed. Two selected projections planning one path with different bytes fail with `AB4103`; a command or rule scoped to a subset of the selected hosts that another selected host discovers conventionally (`commands/`, `rules/`) fails with `AB4105`; a selection that mixes an adapter registered on an advanced `TargetRegistry` with any other target fails with `AB4106` — `validate`, `inspect`, and `build` report all three on the same composite root. `agent-bundle install --from ` and `doctor --from ` read the host manifest directly under the root (no `/` lookup; `AB7001` when it is absent), `mcp run`, `serve-app`, and `dev proxy --target ` resolve that host's MCP document in the same root, the `agent-bundle/test` `openInstalledHostMcpServer` harness reads the composite root as every host's bundle root, the warm event runtime's endpoint is identified by the artifact alone (its epoch and root directory, never the selection) with the invoking host carried on each hook request, each selected host reaching it through the first generated MCP server its own MCP document lists, the composite root's `INSTALL.md`/`install.mjs`, the `AB4106` refusal, and `--host-validation` judge the shipped host adapters by identity (an advanced `TargetRegistry` adapter named like a built-in host earns no install surface and is held to no host validator; `TargetRegistry.builtInHost()`/`builtInHosts()` expose the judgment), and `agentBundleBrowserRstest()` compiles every MCP App once for the project's whole selection while its `target` option names the host each app mounts as (default: the app's first declared target the project selects), `inspect --bundler` reports `distPath.root` as `` with root-relative output paths and the composite identity as `target`, and the dev server, Workbench, and eval harnesses stage the composite root as one epoch (#578) diff --git a/.gitignore b/.gitignore index d80cae77d..e8bc03e1c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage/ # Playwright traces @rstest/playwright keeps for failed browser tests on CI (packages/workbench/tests/support/workbench-e2e.ts) .rstest/ artifacts/ +!packages/*/src/**/artifacts/ *.log # Every example's `pnpm build` (and `pnpm examples:check`) writes here. examples/*/artifact/ diff --git a/README.md b/README.md index b27a07c28..800c4e6d8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # agent-bundle -agent-bundle compiles an agent plugin — skills, hooks, MCP servers, and scripts, described by one typed config — into installable artifacts for Claude Code, Codex, and Cursor, plus a portable layout. You write the plugin once; the compiler emits each host's manifests and wrappers. +agent-bundle compiles an agent plugin — skills, hooks, MCP servers, and scripts, described by one typed config — into one installable plugin root that Claude Code, Codex, Cursor, and portable Agent Plugins clients load. You write the plugin once; the compiler emits each selected host's manifests and wrappers into that root. Documentation: [scriptedalchemy.github.io/agent-bundle](https://scriptedalchemy.github.io/agent-bundle/) (English and 简体中文; guide, reference, generated type API, host capability matrices). @@ -37,7 +37,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, // version comes from package.json - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -47,38 +47,38 @@ export default defineConfig({ Then build, or work interactively: ```sh -npx agent-bundle build --root . --output dist # write installable artifacts +npx agent-bundle build --root . # write the plugin root to artifact/ npx agent-bundle dev --root . # local workbench with live rebuilds ``` -`targets: ['plugin']` emits one multi-host bundle at `dist/plugin/`: `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories. The bundle's generated `AGENTS.md` explains how to install it into each host. Per-host layouts are available as the `claude`, `codex`, `cursor`, and `portable` targets. +`agent-bundle build` writes one composite plugin root (`artifact/` by default; `--output` or `output.distPath` relocates it), and `targets` selects which host projections it carries: the `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests and the portable `plugin.json` sit at the root over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories, emitted once. Every selected host installs from that same directory, and the generated `INSTALL.md` explains how. Omitting `targets` emits only the `portable` projection. -The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) (specification 1.0.0) adapter — the default target, and the layout Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT load natively (Claude Code consumes it only through CLI translation). It emits the closed root `plugin.json` (canonical `$schema`, `name`, `version`, `description`, plus `author`, `homepage`, `repository`, `license`, `keywords`, and reverse-domain `extensions` authored under the `portable` config key), `skills//SKILL.md`, and `mcp.json` with stdio and Streamable HTTP servers whose `args`, `env` values, and `cwd` use the standard's `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders. Rules, commands, hooks, marketplaces, and client extension directories are honestly unavailable there because the v1 standard packages only skills and MCP servers. Both documents are validated against the vendored, hash-pinned 1.0.0 schemas and the normative text at plan time (`portable.mcp.*.standard`), after every ordinary build and `validate --artifact` (`AB6011`/`AB6012` plus the Agent Plugins byte lane `AB6035`–`AB6037`), under `validate --artifact --host-validation` (same lane with the `AB6038` provenance note), and by `agent-bundle doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`); see [Diagnostics](docs/diagnostics.md#agent-plugins-portable-validation-ab6035ab6038). Pins live in `packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json`; the capability table `packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json` carries a dated row for every standard feature. +The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) (specification 1.0.0) adapter — the default projection, and the layout Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT load natively (Claude Code consumes it only through CLI translation). It emits the closed root `plugin.json` (canonical `$schema`, `name`, `version`, `description`, plus `author`, `homepage`, `repository`, `license`, `keywords`, and reverse-domain `extensions` authored under the `portable` config key), `skills//SKILL.md`, and `mcp.json` with stdio and Streamable HTTP servers whose `args`, `env` values, and `cwd` use the standard's `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders. Rules, commands, hooks, marketplaces, and client extension directories are honestly unavailable there because the v1 standard packages only skills and MCP servers. Both documents are validated against the vendored, hash-pinned 1.0.0 schemas and the normative text at plan time (`portable.mcp.*.standard`), after every ordinary build and `validate --artifact` (`AB6011`/`AB6012` plus the Agent Plugins byte lane `AB6035`–`AB6037`), under `validate --artifact --host-validation` (same lane with the `AB6038` provenance note), and by `agent-bundle doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`); see [Diagnostics](docs/diagnostics.md#agent-plugins-portable-validation-ab6035ab6038). Pins live in `packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json`; the capability table `packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json` carries a dated row for every standard feature. -Claude Code language servers are declared under `claude.lspServers`; the `claude` target and the Claude half of `plugin` emit the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. +Claude Code language servers are declared under `claude.lspServers`; the `claude` projection emits the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. Claude Code plugin defaults are declared under `claude.settings` and emitted as plugin-root `settings.json`, which Claude Code applies when the plugin is enabled. The pinned contract supports only `agent` and `subagentStatusLine`; Agent Bundle rejects any other key rather than shipping a default Claude Code would silently ignore, and it expands no path tokens here because `settings.json` is absent from the host's placeholder-substitution table. Because the plugin `agents/` component is still deferred, declaring `agent` also raises a warning: the referenced agent has to reach the plugin root some other way, such as a prebuilt payload. -Cursor Plugin manifest metadata is declared under `cursor.*` and emitted verbatim into `.cursor-plugin/plugin.json` by the `cursor` target and the Cursor half of `plugin`: `author` (`name`, optional `email`), `homepage`, `repository`, `license`, `keywords`, plus the schema-admitted `publisher`, `category`, `tags`, and `minClientVersions` (for example `{ cursor: '3.13.0' }`). Every field is validated against the pinned `cursor/plugins` manifest schema before emission — `author.url`, non-HTTP URLs, empty strings, and loose semver are rejected with `cursor.manifest.*` errors, and an invalid block emits no partial metadata. The Cursor artifact never mixes Agent Plugin (`plugin.json`, `${PLUGIN_ROOT}`) paths or tokens into the Cursor Plugin format; the portable target owns that format. The full documented-surface contract matrix (every Cursor hook event, cloud availability, hook options, marketplace and team-distribution surfaces, canvases, agents) lives as dated `supported` / `unavailable` rows in `packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json`. +Cursor Plugin manifest metadata is declared under `cursor.*` and emitted verbatim into `.cursor-plugin/plugin.json` by the `cursor` projection: `author` (`name`, optional `email`), `homepage`, `repository`, `license`, `keywords`, plus the schema-admitted `publisher`, `category`, `tags`, and `minClientVersions` (for example `{ cursor: '3.13.0' }`). Every field is validated against the pinned `cursor/plugins` manifest schema before emission — `author.url`, non-HTTP URLs, empty strings, and loose semver are rejected with `cursor.manifest.*` errors, and an invalid block emits no partial metadata. The Cursor Plugin documents never mix Agent Plugin (`plugin.json`, `${PLUGIN_ROOT}`) paths or tokens into the Cursor Plugin format; the portable projection owns that format, even when both share one root. The full documented-surface contract matrix (every Cursor hook event, cloud availability, hook options, marketplace and team-distribution surfaces, canvases, agents) lives as dated `supported` / `unavailable` rows in `packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json`. -The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the host artifacts; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`src/skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. +The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the plugin root; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`src/skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. ## Commands -- `build` — validate the project and write an artifact (plus the `bin`/`lib` package build when declared) +- `build` — validate the project and write the plugin root (plus the `bin`/`lib` package build when declared) - `validate` — check project source, or a built artifact with `--artifact ` -- `inspect` — show the normalized configuration and per-target plans; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) +- `inspect` — show the normalized configuration and each selected host's projection plan; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) - `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change; `--install-host ` installs a development variant whose stable `dev proxy` MCP command hot-swaps epochs behind the host's open connection and re-syncs hooks and Skills on every adopted rebuild (see [Framework mode › Live development into hosts](docs/framework-mode.md#live-development-into-hosts)) - `mcp list` / `mcp invoke` / `mcp run` — list, invoke, or run an artifact's MCP servers locally - `hooks list` / `hooks simulate` — inspect and simulate generated hooks - `eval` — run eval suites against a built artifact -When validating a built `claude` or unified `plugin` target, Agent Bundle uses the installed -Claude Code developer toolchain in addition to its pinned schemas. Use -`agent-bundle validate --artifact dist --strict` in CI; Claude's `--strict` findings remain +When the `claude` projection is selected, validating the built root also runs the installed +Claude Code developer toolchain in addition to the pinned schemas. Use +`agent-bundle validate --artifact artifact --strict` in CI; Claude's `--strict` findings remain warnings locally unless Agent Bundle strict mode is requested. If `claude` is absent, validation reports an explicit informational skip. For the install-free development loop, run -`claude --plugin-dir dist/claude plugin list --json` after building. +`claude --plugin-dir artifact plugin list --json` after building. The [package README](packages/agent-bundle/README.md) is the full reference: configuration semantics, the workbench, the optional Agent API, evals, and limitations. diff --git a/docs/canvases/agent-bundle-walkthrough.canvas.tsx b/docs/canvases/agent-bundle-walkthrough.canvas.tsx index 2718cc806..518781cef 100644 --- a/docs/canvases/agent-bundle-walkthrough.canvas.tsx +++ b/docs/canvases/agent-bundle-walkthrough.canvas.tsx @@ -242,7 +242,7 @@ export default async function AfterFileEdit({ canonical, native, signal }: Agent ); }`; -const CLAUDE_HOOKS_JSON = `// claude/hooks/hooks.json - real emitted bytes (examples/hooks-and-scripts) +const CLAUDE_HOOKS_JSON = `// hooks/hooks.json - real emitted bytes (examples/hooks-and-scripts) { "hooks": { "SessionStart": [ @@ -935,9 +935,9 @@ ctx.actor.state === 'available' Every target directory is independently distributable with a generated INSTALL.md; diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d5bbc2ef5..310925cd5 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), rendered-skill compilation (`AB3003`: module failed to load, `AB3004`: missing/invalid default component or `frontmatter` export, `AB3005`: content outside the supported Markdown element subset), and the Skill IR (`AB3006`: unknown frontmatter field; `AB3008`–`AB3010`: per-host lowering of tokens and frontmatter); see below. | | `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project); see below. | -| `AB41xx` | Normalized model invariants (`AB4100`–`AB4102`: unknown targets, duplicate IDs and outputs; see below). | +| `AB41xx` | Normalized model invariants (`AB4100`–`AB4102`: unknown targets — the retired `plugin` name included — duplicate IDs and outputs; `AB4103`, `AB4105`, `AB4106`: the composite-root checks — same path with different bytes across selected projections, a host-scoped component leaking through conventional discovery, an advanced-registry adapter selected beside another target; see below). | | `AB42xx` | Hook configuration and native hook sources (`AB4200`–`AB4212`; see below). | | `AB43xx` | MCP server and MCP App configuration (`AB4300`–`AB4339`, see below; `AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; see below). | | `AB44xx` | Script configuration (`AB4400`–`AB4408`; see below). | @@ -109,7 +109,7 @@ backstop. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | -| `AB4100` | error | A selected target — `targets` in config, or the CLI's target selection — is not a registered adapter: `Unknown target "".` The built-in registry publishes `claude`, `codex`, `cursor`, `portable`, and `plugin`. Reported with `target`. | Select a registered target name. | +| `AB4100` | error | A selected target — `targets` in config, or the CLI's `--target` selection — is not a registered adapter: `Unknown target "".` The built-in registry publishes `claude`, `codex`, `cursor`, and `portable`; `plugin` is one of the unknown names — it used to name a merged multi-host output and now selects nothing, because every build already emits the composite root (see the composite plugin root section below). Reported with `target`. | Select host projections (`claude`, `codex`, `cursor`, `portable`); omit `targets` for the default `portable` projection. | | `AB4101` | error | Two normalized components share one `id` (for example two skills normalizing to `skill:`): `Normalized component ID "" is duplicated.` | Rename or remove one of the two components. | | `AB4102` | error | Two inputs of one target produce the same artifact path — a skill's `SKILL.md`, sidecar, or resource under `skills//`, an `assets/` file, `commands/.md`, `rules/.mdc`, a payload file, or a Claude `bin/`, `output-styles/`, or `workflows/` file: `Multiple inputs produce ""; first source is .` Reported with `generatedPath`, `sourcePath`, and `target`. | Rename or remove one of the inputs. | @@ -290,7 +290,7 @@ failures `AB4770`, and every validator code keeps its own number. ## Claude Code host validation (`AB6019`–`AB6022`, `AB7311`, `AB7325`) `agent-bundle validate --artifact ` and `agent-bundle build` run the -installed Claude Code validator for the `claude` and `plugin` targets when +installed Claude Code validator over the composite root when `claude` is selected and `--host-validation` is on (the default for both commands; `--no-host-validation` skips it, and programmatic `build()` calls skip it unless `hostValidation: true` is passed). `agent-bundle doctor --host claude --from ` runs the same @@ -327,7 +327,7 @@ readable `.claude-plugin/plugin.json` name (the validation runs already report that manifest). Doctor does not repeat it: its registration proof and the inventory rows' `errors` already carry the same verdicts. Without `claude` on `PATH`, `build` spawns once, reports one `AB6019`, and marks the remaining -`claude`/`plugin` targets `unavailable` without spawning again. +`claude` projection `unavailable` without spawning again. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | @@ -343,7 +343,7 @@ inventory rows' `errors` already carry the same verdicts. Without `claude` on | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB6026` | info | Every Cursor host-validation report states that Cursor publishes no plugin-validate devtools verb and names the vendored schema pin used for local validation. | Review the pinned Cursor schema provenance before changing the local validator contract. | -| `AB6027` | error | A required generated Cursor document is missing or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names — a plugin-root-relative file (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target, reported under that path) or an inline object (`.cursor-plugin/plugin.json#/hooks`) — falling back to `hooks/hooks.json` folder discovery only when the field is absent; a declared file that is missing or resolves outside the plugin root is an error, and any other `hooks/hooks.json` beside a named document is not read. | Repair the generated Cursor JSON document so it satisfies the vendored pinned schema, then rebuild. | +| `AB6027` | error | A required generated Cursor document is missing or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names — a plugin-root-relative file (`.cursor-plugin/hooks.json` in the composite root, reported under that path) or an inline object (`.cursor-plugin/plugin.json#/hooks`) — falling back to `hooks/hooks.json` folder discovery only when the field is absent; a declared file that is missing or resolves outside the plugin root is an error, and any other `hooks/hooks.json` beside a named document is not read. | Repair the generated Cursor JSON document so it satisfies the vendored pinned schema, then rebuild. | | `AB6028` | error | Generated bytes violate pinned Cursor loader evidence: manifest-candidate precedence selects a fallback manifest, a symlink resolves outside the bundle, or `CURSOR_PLUGIN_ROOT` appears outside loader-substituted fields. | Repair the generated Cursor layout, token locations, or symlinks to match the pinned loader evidence, then rebuild. | | `AB6029` | info / warning | The Cursor Agent version probe is unavailable (`ENOENT`, info) or cannot complete successfully (warning). Local pinned-schema validation still runs. | Install Cursor Agent or repair `cursor-agent --version` when local CLI version evidence is required, then rerun artifact validation. | @@ -358,7 +358,7 @@ as a separate drift signal, never as a substitute plugin contract. | --- | --- | --- | --- | | `AB6030` | info | The Codex CLI is unavailable, or the installed Codex release publishes no plugin validation command. | Install Codex and put it on `PATH`; until Codex publishes a validator, use the vendored pinned-schema diagnostics. | | `AB6031` | info / warning (error in strict mode) | The app-server schema-generation verb is unavailable, or its live output is missing or differs from the pinned generated hook schemas. | Review the attributable host schema source and update the pinned revision only when Codex publishes the matching contract. | -| `AB6032` | error | A required Codex bundle document is missing, unreadable, invalid JSON, or fails its vendored pinned schema. | Repair the named `.codex-plugin/plugin.json`, `hooks/hooks.json`, `.mcp.json`, or marketplace document and rebuild. | +| `AB6032` | error | A required Codex bundle document is missing, unreadable, invalid JSON, or fails its vendored pinned schema. | Repair the named `.codex-plugin/plugin.json`, `.codex-plugin/hooks.json`, `.codex-plugin/mcp.json`, or marketplace document and rebuild. | | `AB6033` | error | A bounded Codex version or schema-generation command could not start, failed, timed out, exceeded 1 MiB of output, or produced unreadable output. | Verify `codex --version` and `codex app-server generate-json-schema --out ` complete successfully, then rerun validation. | ## Agent Skills emitted spec lint (`AB6034`) @@ -824,6 +824,27 @@ skill's `AB3003`. | --- | --- | --- | --- | | `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | +## The composite plugin root (`AB4100`, `AB4103`, `AB4105`, `AB4106`) + +`build` emits **one** plugin root at the artifact directory (#555). The +`targets` list selects which host *projections* the root carries — +`claude`, `codex`, `cursor`, `portable` — and every selected host reads the +same directory as its plugin root: host manifests sit in their own dotfolders +(`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, or `plugin.json` +for the portable format), while `skills/`, `hooks/`, `mcp/`, `scripts/`, +`bin/`, and `INSTALL.md` are shared. There is no `//` partition +and no `plugin` target: the composite *is* the output. Only those four +built-in hosts share a root; an adapter registered on an advanced +`TargetRegistry` is built alone (`AB4106`). `validate` and `inspect` plan the +same composite root the build stages, so they report `AB4103` and `AB4105` +exactly where `build` would refuse. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). | +| `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | +| `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | + ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) A generated-mode `src/cli/**` surface compiles into the npm package bin @@ -1038,8 +1059,8 @@ reports the parse failure itself. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under -`src/scripts/` compiles to `scripts/.mjs` in every selected target -artifact with `provenance.kind: 'conventional'`. A rendered module +`src/scripts/` compiles once to `scripts/.mjs` in the plugin root, shared +by every selected host, with `provenance.kind: 'conventional'`. A rendered module (`src/scripts/.tsx`/`.jsx`, #102 stage 3) compiles to the same `scripts/.mjs` plus a sibling `scripts/-flight.mjs` react-server worker: its async default component receives `{ argv, signal }` and renders @@ -1255,7 +1276,7 @@ host CLI, repair a bundle, or perform a live protocol exchange. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so a unified `plugin` bundle's Claude-format `hooks/hooks.json` beside its `hooks/hooks-cursor.json` is not a finding), when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names — `.cursor-plugin/hooks.json` — so a Claude-format `hooks/hooks.json` beside it in a composite root is not a finding), when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | ## Install replacement and Doctor install comparison (`AB7005`, `AB7307`–`AB7309`, `AB7321`) @@ -1633,7 +1654,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 | @@ -1726,8 +1747,8 @@ names the host target namespace when the check is per target. | `AB6016` | error | `Emitted Skill reference "" escapes its Skill root.` or `Emitted Skill references missing regular resource "".` — a relative reference inside a `SKILL.md` body points outside the Skill directory or at a file the artifact does not contain as a regular file. | Copy every referenced Skill resource inside its Skill root, then rebuild the artifact. | | `AB6017` | error | Target MCP manifest coherence (`generatedPath` is the target's MCP manifest): the manifest is not strict JSON or does not contain only modern supported servers; a server's runtime values cannot be resolved; a `cwd`, `command`, or `args` path escapes the target, references a missing or unmanifested file, or names a non-executable command file; or a compiler-emitted MCP entry is referenced by no server or by more than one. | Rebuild the artifact so every target MCP manifest references its exact compiler outputs. | | `AB6018` | error | Hook coherence: `agent-bundle.hooks.json` is not strict canonical hook index data; an entry selects an undeclared or hook-incompatible target, or references a missing or invalid target wrapper; a target in the index is missing its native hook manifest, or that manifest is invalid for command enumeration; an indexed hook does not map to exactly one native command, or a native command is not indexed (or indexed more than once). | Rebuild the artifact so native hook commands and hook metadata agree. | -| `AB6023` | error | `Target "" is missing required install surface "INSTALL.md".` — a built-in target (`claude`, `codex`, `cursor`, `plugin`, `portable`) has no `INSTALL.md` in its namespace. | Rebuild the artifact so every built-in target includes its generated `INSTALL.md`. | -| `AB6024` | error | `Target "" is missing required install surface "install.mjs".` — a `cursor`, `plugin`, or `portable` target has no `install.mjs` in its namespace (`claude` and `codex` do not require one). | Rebuild the Cursor-compatible artifact so it includes its generated `install.mjs`. | +| `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | +| `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | ## Workbench artifact inspection (`AB6200`–`AB6202`) @@ -1761,7 +1782,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest in the `--from` directory, its `` directory, or its `plugin` directory; a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. | +| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/docs/effect-cold-start-baseline.json b/docs/effect-cold-start-baseline.json index e9fead948..39c187f48 100644 --- a/docs/effect-cold-start-baseline.json +++ b/docs/effect-cold-start-baseline.json @@ -1,6 +1,6 @@ { "effect": "4.0.0-rc.112", - "hookPath": "claude/hooks/.mjs", + "hookPath": "hooks/.mjs", "kind": "generated-stdio-hook-cold-start", "maxMs": 43.06, "measuredAt": "2026-09-01T11:12:47.007Z", diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 3d602a658..aef00c10b 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -11,32 +11,33 @@ rendering. ## The package build -`agent-bundle build` always emits host artifacts. When the project declares -`bin`/`lib` (or provides them by convention), the CLI build also produces the -node-consumable package build under `dist/` — the outputs `package.json` -`bin` and `exports` point at: +`agent-bundle build` always emits the composite plugin root. When the project +declares `bin`/`lib` (or provides them by convention), the CLI build also +produces the node-consumable package build under `dist/` — the outputs +`package.json` `bin` and `exports` point at: | Config | Output | Notes | | --- | --- | --- | | `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | | `lib: { entry: './src/index.ts', dts: true }` | `dist/.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. | -- When package outputs and at least one Claude, Codex, or Cursor host pack are - built inside the project, the framework also emits one self-contained +- When package outputs are built inside the project and the plugin root + carries at least one of the `claude`, `codex`, or `cursor` projections, the + framework also emits one self-contained package-relative installer. It is `dist/bin/.js` when that name is free, otherwise `dist/bin/-install.js`; if both are occupied, a numeric suffix (`-install-2`, `-install-3`, …) guarantees a free name. Declare the matching `package.json` `bin` value. Its grammar is `install [--scope ] [--json]`; help lists only built hosts. - The baked URL resolves the shipped artifact directory from `import.meta.url`, + The baked URL resolves the shipped plugin root from `import.meta.url`, never the caller's working directory, and delegates to the same `installBundle` implementation as `agent-bundle install`. - `agent-bundle prepack [--root ] [--output ] [--json]` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the exact package/artifact inventory, manifest hashes, package bin targets, and release-version agreement. With no `--output`, prepack uses - configured `output.distPath` when present and otherwise writes artifacts to - `artifact/`, leaving the package build in `dist/`. Use it as an npm `prepack` + configured `output.distPath` when present and otherwise writes the plugin + root to `artifact/`, leaving the package build in `dist/`. Use it as an npm `prepack` script; `--ignore-scripts` prevents recursion and npm install never runs the host installer. - The package build runs for `agent-bundle build` (CLI, or @@ -77,9 +78,9 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | | `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | -| `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | +| `src/scripts/.ts` | Plain script compiled once to `scripts/.mjs` in the plugin root, shared by every selected host — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | -| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | +| `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in the plugin root whenever a selected host publishes the `cli` capability (all built-in hosts do). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | | `src/events//.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. `canonical.payload` is the family's cross-host reading of the envelope (#466) — the fields at least two hosts report (`toolName`, `toolInput`, `toolResponse`, `sessionId`, `transcriptPath`, `cwd`, `prompt`, `agentId`/`agentType`, `reentry`, …), each as `{ value, nativeKey }` naming the host key it came from and absent when the host did not send it; `E` narrows it to the route's family. The per-family field table is `agentEventPayloadFields` and the per-host key table `agentEventPayloadNativeKeys` (`routes/events.ts`), mirrored under `hooks.eventRoutes..payload` in each pinned capability table so the generated events reference documents the mapping per host. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` | | `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` | | `src/providers/.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal, host, session, workspace, plugin, lineage, state?, notices? }` — the request's observed identity (plugin root included) and lineage plus read-only views of the mounted state (`read`) and notice (`inbox`, `published`) handles; its value is mounted at `(await agent()).providers.` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` | @@ -155,7 +156,7 @@ the worker derives the artifact root from the parent of its own `mcp/` directory. The npm package's routed CLI bin and rendered scripts use `$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise `$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin -(`/bin/.mjs`) derives the artifact root from the parent of its +(`bin/.mjs` in the plugin root) derives the artifact root from the parent of its own `bin/` directory instead, like the MCP worker. Each generated process resolves that anchor exactly once (`resolvePluginRoot` from `@agent-bundle/runtime`, #468): the state kernel, the notice ledger, the @@ -172,14 +173,15 @@ on the notice principal, so `recipient.conversation` / `recipient.root` are matched against `request.lineage` on every surface — while application authorization policy is deferred. -Each cross-request notice route is selected from the target host's pinned +Each cross-request notice route is selected from the host's pinned `noticeDelivery` table, exposed as `TargetAdapter.noticeDelivery` / `TargetRegistry.noticeDelivery(target)` (a local `NoticeDeliveryAdvertisement` shape, structurally identical to the runtime's so it types for `selectNoticeDeliveryRoutes` without making the optional `@agent-bundle/runtime` -peer a declaration dependency); the unified `plugin` target advertises the -intersection of its three hosts, and a target with no advertisement wires no -cross-request route. The `agent-bundle://notices/inbox` resource is registered +peer a declaration dependency). The shared MCP entries and every host's hook +wrappers in one composite root are wired from the intersection of the selected +hosts' advertisements, and a selection that includes a host with no +advertisement wires no cross-request route. The `agent-bundle://notices/inbox` resource is registered in the server and mounted in its worker only for stateful projects whose host advertises `mcp-inbox` (the worker still mounts the ledger so routes can publish; only the unadvertised read surface is withheld, and the reserved name @@ -716,7 +718,7 @@ Per surface, the value the generated request scope mounts: | Surface | `hostSurface` | `stdout` / `stderr` | Source | | --- | --- | --- | --- | -| Routed CLI executable (`dist/bin/.js`, `/bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | +| Routed CLI executable (`dist/bin/.js`, plugin-root `bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | | Rendered script (`scripts/.mjs` from `src/scripts/.tsx`) | `script` | Probed, as above. | `native` | | Generated MCP server (any transport) | `mcp` | `none` on both, `color: 'none'`, `sharesTarget: false` — stdout is the protocol wire and stderr the host's log. Never probed, whatever the descriptors are. | `derived` | | Event route (shared runtime or standalone hook process) | `hook` | `none` on both — stdout is the host's hook envelope. Never probed. | `derived` | @@ -883,17 +885,17 @@ machine output owns stdout. Rendered scripts (`src/scripts/.tsx`) share the same shell and output contract with `{ argv, signal }` component props and status-derived exit codes. -#### The routed CLI inside host artifacts +#### The routed CLI inside the plugin root The package bin only reaches users who install the npm package. Hooks, -skills, and script routes ship with the **host artifact**, so the build also -emits the same compiled command graph into every selected target whose -adapter publishes the `cli` capability — all built-in targets (`claude`, -`codex`, `cursor`, `portable`, `plugin`), because the artifact root is +skills, and script routes ship with the **plugin root**, so the build also +emits the same compiled command graph into that root whenever a selected host's +adapter publishes the `cli` capability — all built-in hosts do (`claude`, +`codex`, `cursor`, `portable`) — because the artifact root is already a plain directory Node executes `mcp/` and `scripts/` files from: ```text -artifact// +artifact/ bin/.mjs # the routed CLI: node bin/.mjs [args] bin/-flight.mjs # react-server worker, present when any command renders scripts/.mjs @@ -1304,8 +1306,8 @@ agent-bundle inspect --bundler [--target ] [--json] ``` Dumps the synthesized bundler configuration for every output the build -composes — artifact scripts, MCP entries, hook wrappers, the per-target MCP -Apps Rsbuild config, and the `dist/` package build — exactly as the build +composes — artifact scripts, MCP entries, hook wrappers, the composite root's +MCP Apps Rsbuild config, and the `dist/` package build — exactly as the build lowers it: in production mode whatever `NODE_ENV` says, the framework profile with the consumer `tools` hatch merged over it and the invariant hook appended last (functions render as `[function ]`). Entries the framework wraps also carry the generated @@ -1314,8 +1316,8 @@ functions the build uses, so the dump cannot drift from what compiles. Nothing is redacted (this is a local debugging surface), but two build-time values are replaced with stable tokens so output is deterministic for one -project: the artifact output root (chosen per build) appears as -`/`, and the synthesized declaration tsconfig (a temporary +project: the composite artifact root (chosen per build) appears as +``, and the synthesized declaration tsconfig (a temporary file generated per package build) appears as ``. The package build's output root appears as its published destination, `dist`, although each real build stages outputs before publishing them atomically. @@ -1453,8 +1455,8 @@ and plugin-root tokens in *env values* — including the injected Targets without token interpolation (Codex serializes the anchor as a `./` path) re-anchor their relative env values against the same durable root. `args` and `cwd` stay artifact-rooted (the first argument is the -content-hashed bundle inside the target root). `--plugin-root ` -overrides the env-anchor root, e.g. point it at `artifact/` for a +content-hashed bundle inside the composite root). `--plugin-root ` +overrides the env-anchor root, e.g. point it at a copy of `artifact/` for a byte-faithful rehearsal of a copied-artifact launch; under a host install the anchor still means the durable install root, exactly as before. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 98525fdf1..b473813c2 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -272,9 +272,9 @@ results and never renders JSX. Routed `src/cli/**` commands and Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` is plain. The routed CLI ships twice from one build: as the npm package bin (`dist/bin/.js`) for users who install the package, and as -`bin/.mjs` inside every host artifact so the plugin's own skills, +`bin/.mjs` inside the plugin root so the plugin's own skills, hooks, and scripts can run it with `node` from the installed plugin root -(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-host-artifacts)). +(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-the-plugin-root)). ## Release identity in source: `agent-bundle/meta` @@ -343,8 +343,8 @@ plugin ships. A hand-authored `SKILL.md` in the same directory always wins ### `output` -`output` controls where the host artifact root lives; it never changes the -framework-owned layout inside each target: +`output` controls where the composite plugin root lives; it never changes the +framework-owned layout inside it: ```ts export default defineConfig({ @@ -369,7 +369,7 @@ A malformed `output` block or non-string/empty `distPath` reports `AB4707`; absolute paths, backslashes, `.`, empty segments, and `..` traversal report `AB4708`; reserved first segments (`.agent-bundle`, `.git`, `node_modules`, and `src`) report `AB4709`. Projects with package -`bin` or `lib` entries must keep host artifacts separate from the npm package +`bin` or `lib` entries must keep the plugin root separate from the npm package build at `dist/` (`AB4706`); `output: { distPath: 'artifact' }` provides that separation without a CLI flag. @@ -377,8 +377,8 @@ The name follows Rsbuild/Rslib's `output.distPath`, but Agent Bundle accepts only the string shorthand, not Rsbuild 2.x's per-asset `DistPathConfig` for such paths as JavaScript, CSS, and SVG subdirectories. `output.filename` templates, `output.assetPrefix`, and `output.cleanDistPath` -are also deliberately deferred: host packs have a framework-owned -`/skills|mcp|scripts|bin|assets/...` layout content-addressed by the +are also deliberately deferred: the plugin root has a framework-owned +`skills|mcp|hooks|scripts|bin|assets/...` layout content-addressed by the artifact manifest. Unlike machine-local Rsbuild config, the hashed, portable release-identity config rejects absolute paths. The per-invocation CLI `--output` flag can override the configured relative artifact root, but it is @@ -466,7 +466,7 @@ exported from `agent-bundle/api`), and every kind that needs a host surface names the capability row a target adapter must publish for it. Adapters judge each row with the shared four-state contract — `supported` with pinned evidence, or `degraded` / `unavailable` / `prohibited` with a dated reason — -and `agent-bundle inspect` reports the judgment per target (see +and `agent-bundle inspect` reports the judgment per host (see [component accounting](entry-conventions.md#agent-bundle-inspect-component-accounting)). A host with no row for a kind reads as an honest `unavailable`, never a silent pass. The matrix below is the state of the pinned tables (Claude Code @@ -474,30 +474,30 @@ pass. The matrix below is the state of the pinned tables (Claude Code tables under `packages/agent-bundle/src/adapters/capabilities/` carry the evidence strings themselves. -| Kind | Source | Capability row | Claude | Codex | Cursor | portable | `plugin` (composite) | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `skill` | `src/skills//SKILL.{md,ts,tsx}` | `skills` | supported | supported | supported | supported | supported | -| `command` | `src/commands/*.md` | `commands` | supported | unavailable | supported | unavailable | emitted (Claude format; Cursor pointer omitted) | -| `rule` | `src/rules/*.mdc` | `rules` | unavailable | unavailable | supported | unavailable | emitted (Cursor half) | -| `hook` | `hooks` config block | `hooks` | supported | supported | supported | unavailable | supported | -| `event-route` | `src/events//.tsx` | `event:` per route | per host `hooks.eventRoutes` table (#258) | per table | per table | unavailable (no hooks) | three-host intersection | -| `mcp-server` | `src/mcp//**` or `mcp.servers` | `mcp` | supported | supported | supported | supported | supported | -| `mcp-app` | `src/mcp//apps/*` or `mcp.servers.*.apps` | `mcp` | supported | supported | supported | supported | supported | -| `lsp` | `claude.lspServers` (plugin-root `.lsp.json`) | `lsp` | supported | unavailable | unavailable | unavailable | emitted (Claude half); intersection unavailable | -| `native-diagnostics` | none | `nativeDiagnostics` | unavailable (LSP `diagnostics` option only) | unavailable | unavailable | unavailable | unavailable | -| `native-extension` | none | `nativeExtension` | unavailable | unavailable | unavailable | unavailable | unavailable | -| `agent` | `src/agents` (deferred) | `agents` | unavailable — G5 deferral ([#220](https://github.com/ScriptedAlchemy/agent-bundle/pull/220)) | no row | unavailable (G5) | no row | unavailable (G5) | -| `script` | `src/scripts/**`, `scripts` config | none | emitted | emitted | emitted | emitted | emitted | -| `cli` | routed `src/cli/**` bin (#387) | `cli` | supported | supported | supported | supported | supported | - -"Emitted" in the composite column means the multi-host `plugin` bundle writes -the surface for the hosts that support it while its own capability row stays -the honest three-host intersection; inspection judges the composite by what it -emits. `native-diagnostics` and `native-extension` have no authoring surface -at all: the rows exist so the compiler's answer to "can this bundle ship a -diagnostics provider or an editor extension?" is a dated *no* per host rather -than silence. The `agent` kind has no producer until the G5 gate admits it; -Claude's `agents` row and its per-field `agents.*` rows record the deferral. +| Kind | Source | Capability row | Claude | Codex | Cursor | portable | +| --- | --- | --- | --- | --- | --- | --- | +| `skill` | `src/skills//SKILL.{md,ts,tsx}` | `skills` | supported | supported | supported | supported | +| `command` | `src/commands/*.md` | `commands` | supported | unavailable | supported | unavailable | +| `rule` | `src/rules/*.mdc` | `rules` | unavailable | unavailable | supported | unavailable | +| `hook` | `hooks` config block | `hooks` | supported | supported | supported | unavailable | +| `event-route` | `src/events//.tsx` | `event:` per route | per host `hooks.eventRoutes` table (#258) | per table | per table | unavailable (no hooks) | +| `mcp-server` | `src/mcp//**` or `mcp.servers` | `mcp` | supported | supported | supported | supported | +| `mcp-app` | `src/mcp//apps/*` or `mcp.servers.*.apps` | `mcp` | supported | supported | supported | supported | +| `lsp` | `claude.lspServers` (plugin-root `.lsp.json`) | `lsp` | supported | unavailable | unavailable | unavailable | +| `native-diagnostics` | none | `nativeDiagnostics` | unavailable (LSP `diagnostics` option only) | unavailable | unavailable | unavailable | +| `native-extension` | none | `nativeExtension` | unavailable | unavailable | unavailable | unavailable | +| `agent` | `src/agents` (deferred) | `agents` | unavailable — G5 deferral ([#220](https://github.com/ScriptedAlchemy/agent-bundle/pull/220)) | no row | unavailable (G5) | no row | +| `script` | `src/scripts/**`, `scripts` config | none | emitted | emitted | emitted | emitted | +| `cli` | routed `src/cli/**` bin (#387) | `cli` | supported | supported | supported | supported | + +A build that selects several hosts writes each surface into the one composite +root for exactly the selected hosts whose row supports it; there is no +separate composite judgment. `native-diagnostics` and `native-extension` have +no authoring surface at all: the rows exist so the compiler's answer to "can +this bundle ship a diagnostics provider or an editor extension?" is a dated +*no* per host rather than silence. The `agent` kind has no producer until the +G5 gate admits it; Claude's `agents` row and its per-field `agents.*` rows +record the deferral. ### Component feature sets @@ -510,8 +510,7 @@ against every target that supports its kind: a target the author named in component minus the feature, reports the omission as a warning with the host's reason (`AB4908` / `AB4928`), and lists it under `omittedFeatures` on the selected component in `inspect` (human output: ` omits : -…`). The composite `plugin` bundle is judged by the half that emits the kind -(Claude for commands, Cursor for rules). Skills keep the closed per-host Skill +…`). Skills keep the closed per-host Skill IR schemas from #108 as their feature mechanism (`AB3006`, `AB3008`, `AB3010`); their rows below mirror that contract rather than adding a second check. @@ -523,11 +522,11 @@ their rows below mirror that contract rather than adding a second check. | `skill` | `skills.hostFrontmatter` (typed host extension / Codex `agents/openai.yaml` sidecar) | supported | supported | supported | unavailable (portable fields only) | | `skill` | `skills.markdownTokens` (`$ARGUMENTS`, `${CLAUDE_PLUGIN_ROOT}`, …) | supported | unavailable (`AB3008`) | unavailable (`AB3008`) | unavailable (`AB3008`) | -The composite `plugin` bundle ships one shared `skills/` tree and lowers any -skill that declares a host extension or token to the portable document, so -both skill feature rows are `unavailable` there and `inspect` reports the -dropped host frontmatter under `omittedFeatures`; per-host skill trees are -install-time selection (#101). +The composite root ships one shared `skills/` tree that every selected host +discovers by convention, so a skill whose host extension changes its lowered +bytes on one selected host collides with the other hosts' copy (`AB4103`); +drop the extension or build that host into its own artifact until per-host +views land (#555). Hook tool selectors a host cannot map still fail at plan time (`.hook.tool.`), and the per-host matcher tables live under @@ -535,25 +534,30 @@ Hook tool selectors a host cannot map still fail at plan time ## Distribution -### How a target compiles +### How the root compiles -`agent-bundle build` plans every target before it compiles anything, then -lowers each target's outputs in at most two stages into one staged root that -is published atomically once the artifact validates -(`src/build/target-stages.ts`): +`agent-bundle build` plans every selected host projection before it compiles +anything, merges the plans into one composite root, then lowers that root's +compiled outputs in at most two stages into one staged directory that is +published atomically once the artifact validates +(`src/build/compile-stages.ts`): 1. **MCP Apps** — the browser environment, compiled through the workspace - `@rsbuild/core`. This stage exists only for a target whose project - declares App routes and always runs first: the MCP entries embed its - emitted HTML. + `@rsbuild/core`. This stage exists only when the project declares App + routes and always runs first: the MCP entries embed its emitted HTML. 2. **Agent-host surfaces** — the routed CLI bin, bundled scripts, hook wrappers, MCP stdio entries, and each surface's react-server Flight worker. - All of them lower together through **one Rslib instance per target**: one + All of them lower together through **one Rslib instance for the root**: one Rsbuild environment per output, compiled by one Rspack multi-compiler. A host surface reaches its Flight worker by file name at run time, never through a build-time manifest, so nothing orders the two within the stage; each surface keeps its own authored-source evidence for the manifest. +Compiled surfaces are emitted once, not once per host: the manifest attributes +them to the composite identity — the selected host names sorted and joined +with `+`, such as `claude+codex`. Selection order never changes the output; +`['codex', 'claude']` and `['claude', 'codex']` build byte-identical roots. + Every synthesized bundler config — both stages plus the `dist/` package build — composes the same way: the framework profile, then the consumer's `tools.rsbuild` fragment, then the `tools.rspack` hatch, then the framework @@ -575,21 +579,22 @@ bundler `context` — on purpose: Rspack writes module identifiers relative to concatenated modules), so a namespace under the staging root would stamp the per-build token into the artifact. -`agent-bundle build` makes each target directory independently distributable. -Every target includes `INSTALL.md` generated with its real plugin and -marketplace names. Claude and Codex bundles include local marketplace manifests -and install through their public plugin CLIs; Cursor bundles use the documented +`agent-bundle build` writes one composite plugin root that every selected host +installs from as-is. The root carries one `INSTALL.md` with a section per +selected host, generated with the real plugin and marketplace names. Claude +and Codex read their local marketplace manifests from the root and install +through their public plugin CLIs; Cursor uses the documented `~/.cursor/plugins/local/` location because Cursor exposes marketplace management but no non-interactive plugin install verb. -The `portable` target emits the [Agent Plugins open standard](https://agent-plugins.org) +The `portable` projection emits the [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0), with schema hashes and the specification repository revision pinned in `src/adapters/schemas/portable/PROVENANCE.json`. Cursor loads this format natively alongside Cursor Plugins; Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients too. Claude Code consumes the standard -only through CLI translation, so its dedicated target remains necessary. The +only through CLI translation, so its dedicated projection remains necessary. The standard packages only skills and MCP servers, leaving rules, commands, and -hooks honestly unavailable on the portable target. The standard's manifest +hooks honestly unavailable on the portable projection. The standard's manifest metadata (`author`, `homepage`, `repository`, `license`, `keywords`) and reverse-domain `extensions` are authored under the `portable` config key and land in the root `plugin.json`; omitting them leaves the manifest exactly as @@ -606,7 +611,8 @@ gaps) is recorded in `docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md` and `docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md`. Because Cursor expands none of `${PLUGIN_ROOT}` / `${PLUGIN_DATA}`, provides no §9.1 variables, defaults an omitted `cwd` to the home directory and resolves `./` -commands against the workspace, the emitted portable `install.mjs` performs +commands against the workspace, the emitted `install.mjs` of a root without a +`.cursor-plugin/plugin.json` performs that expansion itself in the `~/.cursor/plugins/local/` copy of `mcp.json` (absolute plugin root, `~/.cursor/agent-bundle/plugin-data/` as `PLUGIN_DATA`, plugin-root `cwd`, resolved `./` command, `PLUGIN_ROOT` / @@ -618,13 +624,14 @@ document. The bundle stays spec-conformant; the provenance is `derived`. The framework CLI performs those same operations: ```sh -agent-bundle install claude --from artifact/claude --scope user -agent-bundle install codex --from artifact/codex -agent-bundle install cursor --from artifact/cursor +agent-bundle install claude --from artifact --scope user +agent-bundle install codex --from artifact +agent-bundle install cursor --from artifact ``` -Cursor-compatible `cursor`, `portable`, and multi-host `plugin` targets also -include a standalone `install.mjs`. Its staged copy is idempotent for identical +`--from` names the plugin root itself, the directory that holds the host's +manifest. A root whose selection includes `cursor` or `portable` also +includes a standalone `install.mjs`. Its staged copy is idempotent for identical content, records an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files and directories), replaces a same-version stale copy of its own plugin in place (owned files only; `state/` survives), and accepts @@ -635,7 +642,8 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install --from` reports the installed copy versus the artifact as `current`, `stale`, `version-mismatch`, `foreign`, or `not-installed` (see the package README's "Reinstall after a same-version rebuild"). Artifact validation rejects a -built-in target whose required install surface is missing. +root whose required install surface is missing (`INSTALL.md` for any built-in +host, `install.mjs` when `cursor` or `portable` is selected). ### Managed uninstall and lifecycle receipts (#101) @@ -651,11 +659,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 @@ -682,8 +690,9 @@ receipts and activation states". `--mode local` (default) or `--mode marketplace`: - **local** safe-copies the bundle into `~/.cursor/plugins/local/`. - Cursor loads the `.cursor-plugin/plugin.json` manifest, its `hooks/hooks.json`, - `mcp.json`, rules, and skills after a reload. Plugin hooks are registered by + Cursor loads the `.cursor-plugin/plugin.json` manifest, its + `.cursor-plugin/hooks.json` and `.cursor-plugin/mcp.json`, rules, and skills + after a reload. Plugin hooks are registered by the manifest alone: Cursor runs each command from the plugin root with `${CURSOR_PLUGIN_ROOT}` substituted, and no `~/.cursor/hooks.json` entry is written or required (observed 2026-09-03 on Cursor 3.18.25 for `preToolUse`, diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index f1cf85556..5200856c7 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -40,9 +40,10 @@ audiobook-curator --help Choose any writable directory already on `PATH` in place of `~/.local/bin`. This is a direct workspace link; it does not pack or install a tarball. -One `agent-bundle build` produces everything: complete Claude and Codex outputs -beneath `artifact/` (each host's plugin metadata, Skill, bundled CLI script, and -lifecycle-wrapped MCP server) plus the npm package beneath `dist/` +One `agent-bundle build` produces everything: one plugin root at `artifact/` +that both Claude Code and Codex install (each host's manifest directory, +`.claude-plugin/` and `.codex-plugin/`, over the shared Skill, bundled CLI +script, and lifecycle-wrapped MCP server) plus the npm package beneath `dist/` (`dist/bin/audiobook-curator.js` for `package.json` `bin`, and `dist/index.js` plus declarations for `exports`). The example uses only public `agent-bundle` and `@agent-bundle/runtime` exports with `workspace:*` dependencies. diff --git a/examples/hooks-and-scripts/README.md b/examples/hooks-and-scripts/README.md index 89d5863a5..6c1bce5de 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -13,8 +13,10 @@ blocking script traces, and live Logs. Both scripts export `main` and return their exit codes; the build generates the process envelope that owns argv, awaiting, and exit-code adoption. `verify-release` ships by convention — any unclaimed plain script under `src/scripts/` is discovered — while -`detect-risk` stays explicitly configured to restrict its targets, so the -example keeps both modes covered. +`detect-risk` stays explicitly configured because it selects a host: it is +emitted only when the build selects `portable`, into the shared `scripts/` of +the one plugin root every selected host installs — so the example keeps both +modes covered. ## Workbench walkthrough diff --git a/examples/hooks-and-scripts/agent-bundle.config.ts b/examples/hooks-and-scripts/agent-bundle.config.ts index 18498faf9..7402e3fb1 100644 --- a/examples/hooks-and-scripts/agent-bundle.config.ts +++ b/examples/hooks-and-scripts/agent-bundle.config.ts @@ -12,7 +12,9 @@ export default defineConfig({ }, // verify-release ships by convention: unclaimed plain scripts under // src/scripts/ are discovered. detect-risk stays explicitly configured - // because it restricts targets. + // because it selects a host: it is emitted only when the build selects + // portable — and, like every script, into the shared scripts/ directory + // of the one plugin root every selected host installs. scripts: { 'detect-risk': { entry: './src/scripts/detect-risk.ts', diff --git a/examples/host-test/README.md b/examples/host-test/README.md index 59c509dd5..4dd7fafd6 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -76,7 +76,7 @@ pnpm --filter @agent-bundle-example/host-test probe:uninstall claude - `probe:install` builds when needed, creates the isolated home plus a scratch git workspace, copies the host's sign-in file byte-for-byte into the isolated home (`--no-auth` skips it; the copy is removed by `probe:uninstall`), and - runs `agent-bundle install --from artifact/` with `HOME`, + runs `agent-bundle install --from artifact` with `HOME`, `CLAUDE_CONFIG_DIR`, or `CODEX_HOME` pointed at the isolated home. For Cursor it prints the isolated IDE launch line (`--user-data-dir`, `--extensions-dir`). - `probe:capture` runs the scenario prompt through `claude -p`, `codex exec`, or diff --git a/examples/host-test/scripts/probe.mjs b/examples/host-test/scripts/probe.mjs index 1cb8bbab9..686984d69 100644 --- a/examples/host-test/scripts/probe.mjs +++ b/examples/host-test/scripts/probe.mjs @@ -62,7 +62,7 @@ if (!['install', 'capture', 'uninstall', 'status'].includes(command) || !HOSTS.i const root = resolve(flags.root ?? process.env.HOST_TEST_ROOT ?? '/tmp/host-test'); const paths = { - artifact: join(exampleRoot, 'artifact', host), + artifact: join(exampleRoot, 'artifact'), captures: join(root, host), home: join(root, `${host}-home`), logDir: join(root, host, 'log'), diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 2a30fa844..d62f304fc 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -107,7 +107,7 @@ Installing [the optional RSC Runtime topology](../../docs/architecture/rsc-runtime-workbench.md) for the full ownership boundary. -The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and self-contained native plugin artifacts under `dist/plugins`. `dist/app` holds exactly one HTML file per App entry (`edit-timeline-v1.html`, `standalone.html`) with every script, style, asset, and licence comment inlined — the same invariants the framework's MCP App compiler enforces (`splitChunks: false`, unbounded `dataUriLimit`, no async chunks, `legalComments: 'inline'`); the build fails if the resolved configuration drifts from them or any sibling file would be emitted. The packaging step can also be rerun directly against the current Rsbuild output: +The build emits `dist/runtime` (including `dist/runtime/agent-runtime.manifest.json`), self-contained `dist/app` MCP App documents, and one self-contained composite plugin root at `dist/plugins` (the portable, Claude Code, and Codex projections over the shared payload). `dist/app` holds exactly one HTML file per App entry (`edit-timeline-v1.html`, `standalone.html`) with every script, style, asset, and licence comment inlined — the same invariants the framework's MCP App compiler enforces (`splitChunks: false`, unbounded `dataUriLimit`, no async chunks, `legalComments: 'inline'`); the build fails if the resolved configuration drifts from them or any sibling file would be emitted. The packaging step can also be rerun directly against the current Rsbuild output: ```bash pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec agent-bundle build --json --output dist/plugins @@ -117,7 +117,7 @@ To exercise one hook manually, give it an explicit state file and native Claude- ```bash AGENT_RUNTIME_STATE_FILE=/tmp/rsc-agent-state.sqlite \ - node examples/rsc-agent-runtime/dist/plugins/claude/hooks/event-route-tool-after.mjs < { const evaluateHost = async (host, capturedAt) => { const nativeEnvironment = sanitizedHostEnvironment(process.env); const version = await cliVersion(host, nativeEnvironment); - const pluginRoot = join(exampleRoot, 'dist', 'plugins', host); + // One composite root carries every host's projection. + const pluginRoot = join(exampleRoot, 'dist', 'plugins'); await stat(pluginRoot); const fixture = await mkdtemp(join(tmpdir(), `rsc-agent-runtime-${host}-fixture-`)); const marker = `rsc-eval-${randomBytes(16).toString('hex')}`; diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index d79eba23c..1ae4de953 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -103,11 +103,11 @@ const artifactDigest = async (root: string): Promise { +test('materializes self-contained Claude and Codex native plugin artifacts in one composite root', async () => { await runPackageHosts(); - const claudeRoot = join(pluginsRoot, 'claude'); - const codexRoot = join(pluginsRoot, 'codex'); - const claudeManifest = await readJson<{ name: string; version: string }>(join(claudeRoot, '.claude-plugin/plugin.json')); + // One composite root hosts every selected projection: Claude reads its + // conventional documents, Codex keeps its own beside its manifest. + const claudeManifest = await readJson<{ name: string; version: string }>(join(pluginsRoot, '.claude-plugin/plugin.json')); const codexManifest = await readJson<{ interface: unknown; mcpServers: string; @@ -115,22 +115,22 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy name: string; skills: string; version: string; - }>(join(codexRoot, '.codex-plugin/plugin.json')); - const claudeMcp = await readJson<{ mcpServers: Record }>(join(claudeRoot, '.mcp.json')); - const codexMcp = await readJson<{ mcpServers: Record }>(join(codexRoot, '.mcp.json')); + }>(join(pluginsRoot, '.codex-plugin/plugin.json')); + const claudeMcp = await readJson<{ mcpServers: Record }>(join(pluginsRoot, '.mcp.json')); + const codexMcp = await readJson<{ mcpServers: Record }>(join(pluginsRoot, '.codex-plugin/mcp.json')); const claudeHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(claudeRoot, 'hooks/hooks.json'), + join(pluginsRoot, 'hooks/hooks.json'), ); const codexHooks = await readJson<{ hooks: { PostToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> } }>( - join(codexRoot, 'hooks/hooks.json'), + join(pluginsRoot, '.codex-plugin/hooks.json'), ); // The generated identity is the config's `plugin` block. expect(claudeManifest).toMatchObject({ name: 'rsc-agent-runtime-demo', version: '1.0.0' }); expect(codexManifest).toMatchObject({ - hooks: './hooks/hooks.json', + hooks: './.codex-plugin/hooks.json', interface: expect.any(Object), - mcpServers: './.mcp.json', + mcpServers: './.codex-plugin/mcp.json', name: 'rsc-agent-runtime-demo', skills: './skills/', version: '1.0.0', @@ -144,35 +144,34 @@ test('materializes self-contained Claude and Codex native plugin artifacts', asy expect(JSON.stringify(codexMcp)).not.toMatch(/\$\{|workspace/i); expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:Write|Edit)$' }); expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${CLAUDE_PLUGIN_ROOT}'); - expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.mjs'); + // A hook both hosts receive compiles one wrapper per host. + expect(claudeHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.claude.mjs'); expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:apply_patch|Edit|Write)$' }); expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('${PLUGIN_ROOT}'); - expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.mjs'); + expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain('hooks/event-route-tool-after.codex.mjs'); expect(JSON.stringify({ claudeMcp, claudeHooks, codexMcp, codexHooks })).not.toMatch(/api[ _-]?key/i); const runtimeRoot = join(exampleRoot, 'dist/runtime'); const runtimeDigest = await artifactDigest(runtimeRoot); - expect(await artifactDigest(join(claudeRoot, 'runtime'))).toEqual(runtimeDigest); - expect(await artifactDigest(join(codexRoot, 'runtime'))).toEqual(runtimeDigest); + // The prebuilt runtime payload is packaged once, shared by every projection. + expect(await artifactDigest(join(pluginsRoot, 'runtime'))).toEqual(runtimeDigest); const assets = await runtimeAssets(); expect(assets.some((asset) => /^chunks\/.+\.js$/u.test(asset))).toBe(true); - for (const root of [claudeRoot, codexRoot]) { - for (const asset of assets) { - await access(join(root, 'runtime', asset)); - } - const asyncChunk = assets.find((asset) => /^chunks\/.+\.js$/.test(asset)); - expect(asyncChunk).toBeDefined(); - expect((await stat(join(root, 'runtime', asyncChunk!))).isFile()).toBe(true); + for (const asset of assets) { + await access(join(pluginsRoot, 'runtime', asset)); } + const asyncChunk = assets.find((asset) => /^chunks\/.+\.js$/.test(asset)); + expect(asyncChunk).toBeDefined(); + expect((await stat(join(pluginsRoot, 'runtime', asyncChunk!))).isFile()).toBe(true); for (const relative of ['dist/app/edit-timeline-v1.html', 'dist/app/standalone.html']) { const appHtml = await readFile(join(exampleRoot, relative), 'utf8'); expect(appHtml).not.toMatch(/]+src=|]+rel=["']stylesheet["']/iu); } // A skill-less plugin emits no `skills/` directory, while the manifest's - // `./skills/` pointer stays � as in every framework-built Codex artifact. - for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.mcp.json', 'hooks/hooks.json']) { - await access(join(codexRoot, relative)); + // `./skills/` pointer stays — as in every framework-built Codex artifact. + for (const relative of ['.agents/plugins/marketplace.json', '.codex-plugin/plugin.json', '.codex-plugin/mcp.json', '.codex-plugin/hooks.json']) { + await access(join(pluginsRoot, relative)); } }); @@ -187,7 +186,7 @@ test('emits each production App as one self-contained HTML document carrying its 'standalone.html', ]); - 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); for (const artifact of payload) { @@ -196,7 +195,7 @@ test('emits each production App as one self-contained HTML document carrying its expect(source).toContain(']+src=|]+rel=["']stylesheet["']/iu); // `legalComments: 'inline'` keeps the React licence text inside the - // document instead of an extracted `/*! LICENSE: � */` link. + // document instead of an extracted `/*! LICENSE: … */` link. expect(source).toContain('@license React'); expect(source).toContain('LICENSE file'); expect(source).not.toMatch(/\/\*!\s*LICENSE:/u); @@ -207,9 +206,9 @@ test('emits each production App as one self-contained HTML document carrying its 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' }); @@ -245,10 +244,10 @@ test('replays schema-conformance fixtures through each packaged native event rou for (const host of ['claude', 'codex'] as const) { const pluginRoot = join(temporaryRoot, `${host} plugin root ; ordinary`); const stateFile = join(temporaryRoot, `${host}-state.sqlite`); - const manifestPath = join(pluginRoot, 'hooks/hooks.json'); + const manifestPath = join(pluginRoot, host === 'claude' ? 'hooks/hooks.json' : '.codex-plugin/hooks.json'); const rootVariable = host === 'claude' ? 'CLAUDE_PLUGIN_ROOT' : 'PLUGIN_ROOT'; const filename = `${host}-note.txt`; - await cp(join(pluginsRoot, host), pluginRoot, { recursive: true }); + await cp(pluginsRoot, pluginRoot, { recursive: true }); const manifest = await readJson<{ hooks: { PostToolUse: Array<{ hooks: Array<{ command: string }> }> } }>(manifestPath); const command = manifest.hooks.PostToolUse[0]?.hooks[0]?.command; expect(command).toBeTypeOf('string'); @@ -274,11 +273,11 @@ test('replays schema-conformance fixtures through each packaged native event rou }, }); expect((await readFile(argvFile)).toString('utf8').split('\0').filter(Boolean)).toEqual([ - join(pluginRoot, 'hooks/event-route-tool-after.mjs'), + join(pluginRoot, `hooks/event-route-tool-after.${host}.mjs`), ]); const recorded = await createFileRuntimeKernel({ stateFile }).readSnapshot(); expect(recorded.edits.map((edit) => edit.host)).toEqual([host]); - expect(command).toBe(`node "\${${rootVariable}}/hooks/event-route-tool-after.mjs"`); + expect(command).toBe(`node "\${${rootVariable}}/hooks/event-route-tool-after.${host}.mjs"`); expect(command).not.toMatch(/(?:api[ _-]?key|echo|printenv|AGENT_RUNTIME_)/iu); } } finally { diff --git a/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts b/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts index ad4087947..7e05be97f 100644 --- a/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/mcp-transports.integration.test.ts @@ -451,11 +451,9 @@ test('production and development runtime graphs exclude state test controls', as }; assertExcluded(await readRuntimeSources(join(process.cwd(), 'dist/runtime'))); - for (const host of ['claude', 'codex']) { - const packagedRuntime = join(process.cwd(), 'dist/plugins', host, 'runtime'); - assertExcluded(await readRuntimeSources(packagedRuntime)); - await expect(import(pathToFileURL(join(packagedRuntime, 'state-file-test-support.js')).href)).rejects.toThrow(); - } + const packagedRuntime = join(process.cwd(), 'dist/plugins', 'runtime'); + assertExcluded(await readRuntimeSources(packagedRuntime)); + await expect(import(pathToFileURL(join(packagedRuntime, 'state-file-test-support.js')).href)).rejects.toThrow(); const compilerRoot = await mkdtemp(join(tmpdir(), 'rsc-agent-runtime-state-graph-')); const rsbuild = await createRsbuild({ diff --git a/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts b/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts index 6449c7b83..ef1507efc 100644 --- a/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts +++ b/examples/rsc-agent-runtime/tests/micro-eval.spot.test.ts @@ -32,7 +32,7 @@ test('micro-eval spot-check: built hook and MCP server share one RSC-rendered ru }); const runHookOnce = async (): Promise => { - const hook = spawn(process.execPath, [join(process.cwd(), 'dist/plugins/claude/hooks/event-route-tool-after.mjs')], { + const hook = spawn(process.execPath, [join(process.cwd(), 'dist/plugins/hooks/event-route-tool-after.claude.mjs')], { env: { ...process.env, AGENT_RUNTIME_STATE_FILE: stateFile }, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/examples/rsc-agent-runtime/tests/support/ensure-built.ts b/examples/rsc-agent-runtime/tests/support/ensure-built.ts index ebd0ccd79..69b8f564e 100644 --- a/examples/rsc-agent-runtime/tests/support/ensure-built.ts +++ b/examples/rsc-agent-runtime/tests/support/ensure-built.ts @@ -10,8 +10,8 @@ const exampleRoot = process.cwd(); /** One probe per build output the dist-consuming tests spawn or read. */ const requiredArtifacts = [ 'dist/app/standalone.html', - 'dist/plugins/claude/.claude-plugin/plugin.json', - 'dist/plugins/codex/.codex-plugin/plugin.json', + 'dist/plugins/.claude-plugin/plugin.json', + 'dist/plugins/.codex-plugin/plugin.json', 'dist/runtime/runtime-assets.json', ] as const; diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 9b47d5e52..f9e72fe6a 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -24,8 +24,8 @@ Generated executables target Node.js 22.12 or newer by default. `runtime: { node that floor (it can never be lowered), and the selected floor is recorded as `runtime.node` in the artifact manifest. -Files under a root `assets/` directory copy byte-for-byte into every target artifact's `assets/` -directory. Top-level `assets` replaces that convention with explicit entries: literal file paths, +Files under a root `assets/` directory copy byte-for-byte into the plugin root's `assets/` +directory, once for every selected host. Top-level `assets` replaces that convention with explicit entries: literal file paths, whole directories, or globs, all resolved from the project root. Entries outside `assets/` keep their project-relative path under the artifact's `assets/` directory. @@ -121,7 +121,7 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; durable `state/` is kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | | `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | -| `agent-bundle inspect` | Inspect normalized targets and adapter plans from source, with per-target component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | +| `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | | `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | | `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. | | `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. | @@ -133,14 +133,14 @@ manifests at files inside those payloads without compiling them. Payload files c ### Validate Claude bundles with Claude Code -When Claude Code is on `PATH`, artifact validation runs its validator for emitted `claude` and -unified `plugin` targets. Claude Code treats a directory that holds both `.claude-plugin/plugin.json` -and `.claude-plugin/marketplace.json` as a marketplace and then never opens the plugin's hook, -skill, agent, or command files, so Agent Bundle names each manifest: +When Claude Code is on `PATH` and the `claude` projection is selected, artifact validation runs +its validator over the plugin root. Claude Code treats a directory that holds both +`.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` as a marketplace and then never +opens the plugin's hook, skill, agent, or command files, so Agent Bundle names each manifest: ```sh -claude plugin validate /.claude-plugin/plugin.json --strict -claude plugin validate /.claude-plugin/marketplace.json --strict +claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict ``` On Claude Code 2.1.259 or later both runs use `--json`; older releases are parsed from the text @@ -152,42 +152,43 @@ fabricated success. Use `--no-host-validation` when a deterministic schema-only CI should use strict validation: ```sh -agent-bundle validate --artifact dist --strict +agent-bundle validate --artifact artifact --strict ``` -During development, load a built target without installing it and verify registration: +During development, load the built root without installing it and verify registration: ```sh -claude --plugin-dir dist/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` ## Distribute and install bundles -Every built target directory contains a generated `INSTALL.md` with commands -that use the bundle's real plugin and marketplace names. Claude and Codex -targets always include local marketplace manifests, so their public CLIs can -install the emitted directory directly: +The built plugin root contains a generated `INSTALL.md` with a section per +selected host, using the bundle's real plugin and marketplace names. The +`claude` and `codex` projections always include local marketplace manifests, +so their public CLIs can install the emitted directory directly: ```sh -agent-bundle install claude --from artifact/claude --scope user -agent-bundle install codex --from artifact/codex +agent-bundle install claude --from artifact --scope user +agent-bundle install codex --from artifact ``` The installer delegates to `claude plugin marketplace add` / `claude plugin install` and `codex plugin marketplace add` / `codex plugin add`; it fails with a typed diagnostic when the selected host -binary is unavailable. Cursor has no non-interactive install verb, so Cursor, -portable, and composite targets include `install.mjs`, which safely copies the -bundle into `~/.cursor/plugins/local/` without overwriting collisions: +binary is unavailable. Cursor has no non-interactive install verb, so a root +that carries the `cursor` or `portable` projection includes `install.mjs`, +which safely copies the bundle into `~/.cursor/plugins/local/` without +overwriting collisions: ```sh -agent-bundle install cursor --from artifact/cursor -# or, from the emitted target directory: +agent-bundle install cursor --from artifact +# or, from the emitted root: node ./install.mjs ``` Cursor loads the copied `.cursor-plugin/plugin.json` and its manifest-declared -`hooks/hooks.json` from that directory; plugin hooks run from the plugin root +`.cursor-plugin/hooks.json` from that directory; plugin hooks run from the plugin root with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. `--mode marketplace` instead stages a committed local marketplace repository at `~/.cursor/agent-bundle/marketplaces/` and prints the @@ -196,8 +197,9 @@ Cursor manage the plugin as a marketplace install; `agent-bundle doctor --host cursor` reports hook registration (`AB7322`), duplicate user-level delivery (`AB7323`), and marketplace import state (`AB7324`). -The `portable` target's `install.mjs` copies the Agent Plugins package to the -same `~/.cursor/plugins/local/` location and, because Cursor 3.18.25 +For a root whose only Cursor-loadable format is the `portable` projection, +`install.mjs` copies the Agent Plugins package to the same +`~/.cursor/plugins/local/` location and, because Cursor 3.18.25 expands none of the standard's placeholders (`${PLUGIN_ROOT}` / `${PLUGIN_DATA}` in `args`, `env`, `cwd`; no `PLUGIN_ROOT` / `PLUGIN_DATA` variables; omitted `cwd` → home directory; `./` commands → workspace folder), @@ -208,8 +210,9 @@ the shipped document in the install receipt (`cursorExpansion`), and itself stays spec-conformant for other Agent Plugins clients. Cursor installation is user-scoped. Claude also accepts `--scope project` and -`--scope local`; Codex is user-scoped. A source-free artifact root is accepted -by `--from` when it contains the selected host target directory. +`--scope local`; Codex is user-scoped. `--from` names the plugin root itself — +the directory that holds the selected host's manifest — and a source-free copy +of that root is accepted. ### Reinstall after a same-version rebuild @@ -1019,11 +1022,11 @@ Skill Markdown are inert in the workbench renderer. Top-level `scripts` is a record of stable output names to an entry path or `{ entry, targets? }`. JavaScript/TypeScript entries bundle to `scripts/.mjs`; `.sh`, `.bash`, and `.py` entries copy byte-for-byte while preserving source modes. The generated `agent-bundle.manifest.json` records file digests for stable artifact validation. -A project with routed `src/cli/**` commands also ships that CLI inside every host artifact as -`/bin/.mjs` (plus `bin/-flight.mjs` when a command renders), a +A project with routed `src/cli/**` commands also ships that CLI inside the plugin root as +`bin/.mjs` (plus `bin/-flight.mjs` when a command renders), a self-contained module run as `node /bin/.mjs ` — so a script route can spawn its `../bin/.mjs` sibling and a Claude skill can point at -`${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in target +`${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in host publishes the `cli` capability that admits it; `inspect` accounts for it as a `cli` component, and the manifest records both files with bundle provenance. The npm package bin under `dist/bin/` is unchanged. See `docs/entry-conventions.md` for the layout and diagnostics (`AB4765`, `AB4766`). diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json index 73edd8d5b..05cbffe0a 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.260.json @@ -1218,7 +1218,7 @@ "First-registered-wins collision rule: when more than one enabled server declares the same extension in extensionToLanguage, from one plugin or from different plugins, the first registered handles the extension and the others never start.", "The server binary is never vendored: \"You must install the language server binary separately. LSP plugins configure how Claude Code connects to a language server, but they don't include the server itself.\"", "Placeholder substitution for LSP servers is limited to command, args, env, and workspaceFolder.", - "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.", + "Codex and Cursor publish no plugin LSP surface at their pinned revisions, so a composite root's .lsp.json reaches Claude Code only.", "Plugin developer tools reference: `claude plugin validate ` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.", "2026-09-03: plugins-reference (plugin validate) documents `--json` as requiring Claude Code v2.1.259 or later, with a report of `success`, `strict`, `target`, `manifest`, and per-file `contents` entries carrying `errors`, `warnings`, and `notes`; the pinned 2.1.260 revision (and 2.1.259) prints that report and the previous 2.1.250 pin answers `error: unknown option '--json'` (exit 1), so Agent Bundle requests `--json` when the probed version is 2.1.259 or later and parses the text reporter below it (host-contracts/claude-plugin-validation.ts; scratch-installed 2.1.250 re-probed 2026-09-03 for the re-pin).", "2026-09-03: on 2.1.260 `plugin validate --strict --json` lists under `contents` only the files that produced findings (an accepted plugin with hooks/hooks.json and agents/*.md reports `contents: []`), each finding is `{path, message, code}` with `code` null, and the report's `success` agrees with the exit code on all 52 hooks-schema fixture cases (tests/fixtures/claude-hooks-schema/reports/2.1.260).", @@ -1267,7 +1267,7 @@ "2026-09-03: the plugins reference (\"Component path fields\", \"File locations reference\") documents `hooks/hooks.json` as the default hook location and the manifest `hooks` field (string|array|object) as \"Hook config paths or inline config\" with the example `./my-extra-hooks.json`. Claude Code 2.1.250 (the previous pin), 2.1.251, 2.1.257, 2.1.259, and 2.1.260 (the pinned revision) all refuse a plugin whose manifest names the auto-loaded file again (probed 2026-09-03 with `claude --plugin-dir plugin list --json`; the rule is not a 2.1.255 regression as ScriptedAlchemy/agent-bundle#462 assumed): `claude plugin list --json` carries `errors: [\"Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.\"]`, the human listing prints `Status: ✘ failed to load`, and no hook, MCP server, or skill from that plugin reaches the session. `claude plugin validate --strict` and `claude plugin details` accept the same manifest, so the list verb is the only surface that exposes the refusal (#463).", "2026-09-03: `claude plugin list --json` (2.1.259) rows carry `id`, `version`, `scope`, `enabled`, `installPath`, `installedAt`, `lastUpdated`, optional `mcpServers`, and — only for a plugin Claude Code failed to load — `errors` as a nonempty array of strings; healthy rows omit the key. `--plugin-dir` rows use the `@inline` id and carry the same `errors` array (#464).", "2026-09-03: on the pinned 2.1.260 (and 2.1.259) a `--plugin-dir` row is `{id: \"@inline\", version, scope: \"session\", enabled: true, installPath}` with no installedAt or lastUpdated, plus `errors[]` only when the load was refused; the duplicate-hooks refusal text is unchanged from 2.1.259 (schemas/claude/PROVENANCE.json repinNotes).", - "2026-09-03: Claude Code 2.1.259 loads exactly one hook set (`plugin details` reports `Hooks (1) SessionStart`) for a plugin that ships `hooks/hooks.json` beside `hooks/hooks-cursor.json` with no manifest `hooks` key, so the unified bundle needs no manifest pointer to keep Claude Code off the Cursor-format document.", + "2026-09-03: Claude Code 2.1.259 loads exactly one hook set (`plugin details` reports `Hooks (1) SessionStart`) for a plugin that ships `hooks/hooks.json` beside `hooks/hooks-cursor.json` with no manifest `hooks` key, so a composite root needs no manifest pointer to keep Claude Code off the Cursor-format document.", "2026-09-01: Local host proof against the observed Claude Code 2.1.257 binary (older than the pinned 2.1.260 table): `claude plugin validate --strict` accepts an emitted plugin manifest declaring one bare dependency and one `{name, version}` dependency object and prints \"Validation passed\" (host-adapters.native.test.ts).", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents required top-level marketplace name, owner, and plugins plus optional $schema, description, version, metadata.pluginRoot, allowCrossMarketplaceDependenciesOn, and renames; owner requires name and optionally accepts email and url.", "2026-09-02 (retrieved 2026-09-02): https://code.claude.com/docs/en/plugin-marketplaces documents plugin-entry name and source plus standard metadata, category, tags, strict, relevance, defaultEnabled, component configuration, headers, and headersHelper. Agent Bundle keeps generated `./` as the default and lets the authored plugin overlay record a distributed relative, github, git URL, git-subdir, npm, archive, or command source.", diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 56ef42284..75d262d1d 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -75,7 +75,7 @@ "retrieved 2026-09-02: https://developers.openai.com/plugins/build/plugins lists a legacy-compatible marketplace at $REPO_ROOT/.claude-plugin/marketplace.json alongside the repo and personal marketplace locations." ], "path": ".claude-plugin/marketplace.json", - "reason": "The Codex adapter emits the Codex-native .agents/plugins/marketplace.json and never a .claude-plugin/marketplace.json; the legacy path is a host-side reading convenience for Claude-authored repositories, and the unified plugin target's Claude marketplace is emitted by the Claude adapter, not as a Codex compatibility artifact.", + "reason": "The Codex adapter emits the Codex-native .agents/plugins/marketplace.json and never a .claude-plugin/marketplace.json; the legacy path is a host-side reading convenience for Claude-authored repositories, and a composite root's Claude marketplace is emitted by the Claude adapter, not as a Codex compatibility artifact.", "state": "unavailable" }, "marketplaceSources": { diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index d2869122c..6e251c99d 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -526,14 +526,14 @@ }, "emitted": { "commands": "./commands/", - "hooks": "./hooks/hooks.json", - "mcpServers": "./mcp.json", + "hooks": "./.cursor-plugin/hooks.json", + "mcpServers": "./.cursor-plugin/mcp.json", "rules": "./rules/", "skills": "./skills/" }, "evidence": [ "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents folder-based discovery defaults (skills/ subdirectories with SKILL.md; rules/ and agents/ .md/.mdc/.markdown; commands/ .md/.mdc/.markdown/.txt; hooks/hooks.json; mcp.json; root SKILL.md single-skill fallback) and that a manifest field replaces folder discovery for that component so the default folder is not also scanned.", - "retrieved 2026-09-02: the generated .cursor-plugin/plugin.json points every emitted component at its default location (./skills/, ./rules/, ./commands/, ./hooks/hooks.json, ./mcp.json), so replacement and discovery resolve to the same files; emitted rules are .mdc and commands are .md, subsets of the documented suffixes." + "retrieved 2026-09-02: the generated .cursor-plugin/plugin.json points every emitted component at its location (./skills/, ./rules/, ./commands/, and since #555 ./.cursor-plugin/hooks.json and ./.cursor-plugin/mcp.json beside the manifest, so a composite root shared with Claude Code or the portable projection never collides on hooks/hooks.json or mcp.json), so replacement and discovery resolve to the same files; emitted rules are .mdc and commands are .md, subsets of the documented suffixes." ], "replacement": "manifest-path-replaces-default-scan", "state": "supported" @@ -738,7 +738,7 @@ "retrieved 2026-09-02: https://cursor.com/docs/hooks documents the common input envelope (conversation_id, generation_id, model, model_id?, model_params?, hook_event_name, cursor_version, workspace_roots, user_email, transcript_path) for every agent hook; generated wrappers require session_id or conversation_id plus the per-event fields and reject anything else only when documented types are violated.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preToolUse output permission allow|deny (ask accepted by the schema but not enforced, so the canonical ask outcome fails closed on Cursor), user_message, agent_message, and updated_input; postToolUse output updated_mcp_tool_output (MCP tools only) and additional_context; sessionStart output env and additional_context (continue/user_message accepted but unenforced); stop output followup_message. The Cursor projections emit exactly those channels.", "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for subagentStart and subagentStop could not dispatch because the binary required unavailable authentication; the pinned official contract plus the wrapper fixture replay in tests are the evidence source for the subagent lifecycle envelopes.", - "2026-09-03 (#407): hooks registration is plugin-scoped via .cursor-plugin/plugin.json `hooks` -> hooks/hooks.json; no ~/.cursor/hooks.json entry is emitted or required. Observed on Cursor 3.18.25 (isolated HOME, Agents window, shell tool call): the emitted pack's preToolUse (matcher ^Shell$), postToolUse, and stop hooks fired with CURSOR_PLUGIN_ROOT substituted and cwd = plugin root, identical to the known-loading ~/.cursor/plugins/local/tracedecay and to official marketplace plugins (cursor-public/continual-learning uses `bun run ${CURSOR_PLUGIN_ROOT}/hooks/...`). https://cursor.com/docs/hooks: 'All matching hooks from every source run', so a user-level duplicate would double-fire; Doctor reports it (AB7323). See docs/audits/2026-09-03-cursor-plugin-hooks-registration.md.", + "2026-09-03 (#407): hooks registration is plugin-scoped via .cursor-plugin/plugin.json `hooks` -> the hooks document (hooks/hooks.json at the time; .cursor-plugin/hooks.json since #555); no ~/.cursor/hooks.json entry is emitted or required. Observed on Cursor 3.18.25 (isolated HOME, Agents window, shell tool call): the emitted pack's preToolUse (matcher ^Shell$), postToolUse, and stop hooks fired with CURSOR_PLUGIN_ROOT substituted and cwd = plugin root, identical to the known-loading ~/.cursor/plugins/local/tracedecay and to official marketplace plugins (cursor-public/continual-learning uses `bun run ${CURSOR_PLUGIN_ROOT}/hooks/...`). https://cursor.com/docs/hooks: 'All matching hooks from every source run', so a user-level duplicate would double-fire; Doctor reports it (AB7323). See docs/audits/2026-09-03-cursor-plugin-hooks-registration.md.", "2026-09-03 (#407): the #407 report (Cursor 3.16.21, ~/.cursor-server) is not reproducible on 3.18.25; https://cursor.com/docs/cli/changelog (August 11, 2026 release) records 'Plugin hooks run from installed plugins ... now execute and refresh when plugins reload', a vendor-side fix in that window. Installation: `install cursor --mode local` copies to ~/.cursor/plugins/local/ (https://cursor.com/docs/plugins#test-plugins-locally); `--mode marketplace` stages a committed .cursor-plugin/marketplace.json repository (https://cursor.com/docs/reference/plugins#cursor-multi-plugin-repositories) for Customize -> 'Add Plugins from Local Repository', because no plugin install deeplink exists and `cursor-agent plugin marketplace add` accepts hosted git URLs only." ] } diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index f709550bd..930c4a363 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -1,4 +1,3 @@ -import { stableJson } from '../core/digest.ts'; import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import { featureCapabilityName } from '../core/components.ts'; @@ -289,170 +288,3 @@ export const capabilityIsSupported = (capability: CapabilityState | undefined): } } }; - -/** Immutable Boolean view retained for callers that have not migrated yet. */ -export const capabilityBooleanView = ( - capabilities: Readonly>, -): Readonly> => Object.freeze(Object.fromEntries( - Object.entries(capabilities).map(([name, capability]) => [name, capabilityIsSupported(capability)]), -)); - -const evidenceFor = (capability: CapabilityState): CapabilityEvidence | undefined => { - switch (capability.state) { - case 'supported': - return capability.evidence; - case 'degraded': - return capability.evidence; - case 'unavailable': - case 'prohibited': - return undefined; - default: { - const exhaustive: never = capability; - throw unknownCapabilityStateError(exhaustive); - } - } -}; - -const precedenceFor = (capability: CapabilityState): 0 | 1 | 2 | 3 => { - switch (capability.state) { - case 'supported': - return 0; - case 'degraded': - return 1; - case 'unavailable': - return 2; - case 'prohibited': - return 3; - default: { - const exhaustive: never = capability; - throw unknownCapabilityStateError(exhaustive); - } - } -}; - -const reasonFor = (capability: CapabilityState, precedence: 1 | 2 | 3): string | undefined => { - switch (capability.state) { - case 'supported': - return undefined; - case 'degraded': - return precedence === 1 ? capability.reason : undefined; - case 'unavailable': - return precedence === 2 ? capability.reason : undefined; - case 'prohibited': - return precedence === 3 ? capability.reason : undefined; - default: { - const exhaustive: never = capability; - throw unknownCapabilityStateError(exhaustive); - } - } -}; - -const mergedReason = ( - left: CapabilityState, - right: CapabilityState, - precedence: 1 | 2 | 3, -): string => [...new Set([reasonFor(left, precedence), reasonFor(right, precedence)] - .filter((reason): reason is string => reason !== undefined))] - .sort((first, second) => first.localeCompare(second)) - .join('; '); - -/** Merges evidence without discarding either pinned table identity. */ -export const mergeCapabilityEvidence = ( - left: CapabilityEvidence, - right: CapabilityEvidence, -): CapabilityEvidence => { - const evidence = [left, right].sort((first, second) => { - const targetOrder = first.target.localeCompare(second.target); - return targetOrder === 0 ? stableJson(first).localeCompare(stableJson(second)) : targetOrder; - }); - return Object.freeze({ - observedVersion: evidence.map((entry) => `${entry.target}@${entry.observedVersion}`).join('+'), - target: evidence.map((entry) => entry.target).join('+'), - }); -}; - -/** - * Intersects two host judgments for a composite adapter. Prohibition dominates, - * then unavailability, then degradation; two supported states merge evidence. - */ -export const intersectCapabilityStates = ( - left: CapabilityState, - right: CapabilityState, -): CapabilityState => { - const leftPrecedence = precedenceFor(left); - const rightPrecedence = precedenceFor(right); - const precedence = leftPrecedence > rightPrecedence ? leftPrecedence : rightPrecedence; - switch (precedence) { - case 0: - if (left.state !== 'supported' || right.state !== 'supported') { - throw new Error('Supported capability intersection lost its evidence invariant.'); - } - return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence)); - case 1: { - const leftEvidence = evidenceFor(left); - const rightEvidence = evidenceFor(right); - const evidence = leftEvidence === undefined || rightEvidence === undefined - ? undefined - : mergeCapabilityEvidence(leftEvidence, rightEvidence); - return Object.freeze({ - ...(evidence === undefined ? {} : { evidence }), - reason: mergedReason(left, right, precedence), - state: 'degraded', - }); - } - case 2: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'unavailable' }); - case 3: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'prohibited' }); - default: { - const exhaustive: never = precedence; - throw new CapabilityStateError(`Capability precedence ${String(exhaustive)} has no intersection rule.`); - } - } -}; - -/** - * Unions two host judgments for composite emission dispatch: a composite - * emits a surface if any host side does. Support dominates degradation, - * unavailability, and prohibition; equally ranked supporting sides merge - * evidence, while equally ranked non-supported sides merge reasons. - */ -export const unionCapabilityStates = ( - left: CapabilityState, - right: CapabilityState, -): CapabilityState => { - const leftPrecedence = precedenceFor(left); - const rightPrecedence = precedenceFor(right); - const precedence = leftPrecedence < rightPrecedence ? leftPrecedence : rightPrecedence; - switch (precedence) { - case 0: - if (left.state === 'supported' && right.state === 'supported') { - return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence)); - } - if (left.state === 'supported') return supportedCapability(left.evidence); - if (right.state === 'supported') return supportedCapability(right.evidence); - throw new Error('Supported capability union lost its evidence invariant.'); - case 1: { - const leftEvidence = left.state === 'degraded' ? left.evidence : undefined; - const rightEvidence = right.state === 'degraded' ? right.evidence : undefined; - const evidence = leftEvidence === undefined - ? rightEvidence - : rightEvidence === undefined - ? leftEvidence - : mergeCapabilityEvidence(leftEvidence, rightEvidence); - return Object.freeze({ - ...(evidence === undefined ? {} : { evidence }), - reason: mergedReason(left, right, precedence), - state: 'degraded', - }); - } - case 2: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'unavailable' }); - case 3: - return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'prohibited' }); - default: { - const exhaustive: never = precedence; - throw new CapabilityStateError(`Capability precedence ${String(exhaustive)} has no union rule.`); - } - } -}; diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 20da19567..fd5b83863 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -7,6 +7,7 @@ import { pathTokens, type AgentBundleConfig, type AgentBundleHostConfig, + type NormalizedHook, type NormalizedMcpServer, type NormalizedHostPayloadDirectory, type NormalizedPlugin, @@ -71,7 +72,7 @@ import { type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; -import { withInstallSurface } from '../install/surface.ts'; +import { hookWrapperPath } from './composite-layout.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -388,7 +389,7 @@ declare module '../core/types.ts' { const claudeName = 'claude'; -/** Claude Code's conventional artifact document paths, shared with the unified bundle adapter. */ +/** Claude Code's conventional artifact document paths. */ export const claudeArtifactPaths = Object.freeze({ hooksManifest: 'hooks/hooks.json', lsp: '.lsp.json', @@ -409,8 +410,6 @@ const validateMonitors = validator.compile(monitorsSchema); const validateSettings = validator.compile(settingsSchema); const validateTheme = validator.compile(themeSchema); -/** The pinned Claude hooks validator, shared with the unified bundle adapter. */ -export const claudeHooksValidator = validateHooks; const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ hostContractRevision: capabilityTable.observedCliVersion, @@ -1994,9 +1993,9 @@ const noLspPlan: ClaudeLspPlan = deepFreeze({ * only the first server registered for a file extension, so pointing the * manifest at the conventional file risks a self-collision for no gain. * - * The Claude host config is the source of truth for both the `claude` - * target and the Claude half of the unified `plugin` bundle, because no - * other pinned host contract has an LSP surface to select. + * The Claude host config is the source of truth for the `claude` + * projection, because no other pinned host contract has an LSP surface to + * select. */ export const planClaudeLsp = (model: NormalizedPlugin): ClaudeLspPlan => { const extension = model.extensions[claudeName]; @@ -3089,16 +3088,21 @@ export const planClaudeMonitors = ( return { diagnostics, document, sourceInputs: inputs }; }; -export interface ClaudeArtifactPlanOptions { - /** Target name used for selection and provenance; native hooks stay keyed to Claude. */ - readonly targetName?: string; -} +/** + * The hook contract of one plan: the registered contract with the wrapper + * paths the composite root assigns for this selection (#555). Claude Code + * loads `hooks/hooks.json` conventionally, so the document never moves. + */ +const planHookContract = (model: NormalizedPlugin): TargetHookContract => { + const selected = model.targets.map((target) => target.name); + return Object.freeze({ + ...hookContract, + wrapperPath: (hook: NormalizedHook) => hookWrapperPath(claudeName, hook.name, hook.targets, selected), + }); +}; -export const planClaudeArtifacts = ( - model: NormalizedPlugin, - options: ClaudeArtifactPlanOptions = {}, -): TargetArtifactPlan => { - const targetName = options.targetName ?? claudeName; +export const planClaudeArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { + const targetName = claudeName; const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName); const diagnostics: Diagnostic[] = []; const servers: Record> = Object.create(null) as Record>; @@ -3145,7 +3149,7 @@ export const planClaudeArtifacts = ( diagnostics.push(...monitors.diagnostics); const dependencies = planClaudeDependencies(model, new Set([model.metadata.name])); diagnostics.push(...dependencies.diagnostics); - const generatedHooks = planHooks(model, targetName, hookContract); + const generatedHooks = planHooks(model, targetName, planHookContract(model)); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); @@ -3235,7 +3239,7 @@ export const planClaudeArtifacts = ( pluginRelativePath: claudeArtifactPaths.plugin, targetName, }); - return withInstallSurface(Object.freeze({ + return Object.freeze({ ...basePlan, entries: sortedEntries([ ...basePlan.entries, @@ -3244,7 +3248,7 @@ export const planClaudeArtifacts = ( ...workflows.entries, ...commandWriteEntries(model, isSelected, claudeCommandMarkdown), ]), - }), model, targetName === 'plugin' ? 'plugin' : 'claude'); + }); }; const artifactLayout: TargetArtifactLayout = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 8196be140..55fa662d7 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -1,7 +1,5 @@ import { posix } from 'node:path'; -import type { ValidateFunction } from 'ajv/dist/2020.js'; - import { createTargetDiagnostics } from './diagnostics.ts'; import type { CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -11,6 +9,7 @@ import { pathTokens, type AgentBundleConfig, type AgentBundleHostConfig, + type NormalizedHook, type NormalizedMcpServer, type NormalizedPlugin, } from '../core/types.ts'; @@ -30,6 +29,7 @@ import { import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { createNativeEventStarter, + emptyHookDocument, mergeHookDocuments, encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, @@ -60,7 +60,7 @@ import { type TargetArtifactPlan, } from './types.ts'; import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts'; -import { withInstallSurface } from '../install/surface.ts'; +import { folderDiscoveryShadowed, hookWrapperPath } from './composite-layout.ts'; import { deepFreeze } from '../core/freeze.ts'; export interface CodexInterfaceConfig { @@ -134,12 +134,18 @@ declare module '../core/types.ts' { const codexName = 'codex'; -/** Codex's conventional artifact document paths, shared with the unified bundle adapter. */ +/** Codex's conventional artifact document paths. */ +/** + * Codex's artifact documents. The hook and MCP documents live beside the + * manifest rather than at Claude Code's conventional `hooks/hooks.json` and + * `.mcp.json`: the manifest's `hooks` and `mcpServers` pointers name them, so + * a composite root that also holds a Claude projection never collides (#555). + */ export const codexArtifactPaths = Object.freeze({ apps: '.app.json', - hooksManifest: 'hooks/hooks.json', + hooksManifest: '.codex-plugin/hooks.json', marketplace: '.agents/plugins/marketplace.json', - mcp: '.mcp.json', + mcp: '.codex-plugin/mcp.json', plugin: '.codex-plugin/plugin.json', }); const validator = createAdapterValidator(); @@ -148,15 +154,8 @@ const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateMarketplace = validator.compile(marketplaceSchema); -/** - * The pinned schema admits every documented plugin-root-relative component - * path. Standalone and unified plans still emit their canonical paths. - */ -const pluginValidatorFor = (_mcpRelativePath: string): ValidateFunction => validatePlugin; - -/** Wrapped manifest validator for a plan whose MCP document path was relocated. */ -export const codexPluginDocumentValidator = (mcpRelativePath: string): TargetArtifactDocumentValidator => - validateJsonSchemaDocument(pluginValidatorFor(mcpRelativePath)); +/** The pinned manifest validator, shared with the host-install test harness. */ +export const codexPluginDocumentValidator: TargetArtifactDocumentValidator = validateJsonSchemaDocument(validatePlugin); const validateHooks = validator.compile(hooksSchema); const eventRouteNames = supportedEventRouteNamesFrom(capabilityTable.hooks.eventRoutes); const hookContract = Object.freeze({ @@ -167,7 +166,7 @@ const hookContract = Object.freeze({ encodeNativeHookPlaygroundOutput(result, event, nativeEvent, 'codex'), eventNames: capabilityTable.hooks.events, eventRouteNames, - manifestPath: 'hooks/hooks.json', + manifestPath: codexArtifactPaths.hooksManifest, matchers: capabilityTable.hooks.matchers, nativeEventStarter: (event) => { const nativeEvent = eventRouteNames[event]; @@ -216,11 +215,11 @@ const codexHookRules = Object.freeze({ const artifactValidation = deepFreeze({ documents: [ - Object.freeze({ path: '.app.json', required: false, schema: 'app' }), - Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }), - Object.freeze({ path: '.agents/plugins/marketplace.json', required: false, schema: 'marketplace' }), - Object.freeze({ path: '.mcp.json', required: false, schema: 'mcp' }), - Object.freeze({ path: '.codex-plugin/plugin.json', required: true, schema: 'plugin' }), + Object.freeze({ path: codexArtifactPaths.apps, required: false, schema: 'app' }), + Object.freeze({ path: codexArtifactPaths.hooksManifest, required: false, schema: 'hooks' }), + Object.freeze({ path: codexArtifactPaths.marketplace, required: false, schema: 'marketplace' }), + Object.freeze({ path: codexArtifactPaths.mcp, required: false, schema: 'mcp' }), + Object.freeze({ path: codexArtifactPaths.plugin, required: true, schema: 'plugin' }), ], schemas: [ Object.freeze({ name: 'app', validate: validateJsonSchemaDocument(validateApps) }), @@ -232,7 +231,7 @@ const artifactValidation = deepFreeze({ }); const mcpRuntime = createTargetMcpRuntime({ - manifestPath: '.mcp.json', + manifestPath: codexArtifactPaths.mcp, remoteTypes: ['streamable-http'], resolveStdioArgument: resolveTargetRelativeStdioArgument, resolveValue: createMcpPathTokenResolver({ @@ -1102,21 +1101,23 @@ const planMcpServer = ( }; }; -export interface CodexArtifactPlanOptions { - /** Artifact-relative path for the Codex MCP document; the unified bundle relocates it. */ - readonly mcpRelativePath?: string; - /** See StandardPluginArtifactsInput.sharedCopyEntries; the unified bundle emits shared copies once. */ - readonly sharedCopyEntries?: boolean; - /** Target name used for selection and provenance; native hooks stay keyed to Codex. */ - readonly targetName?: string; -} +/** + * The hook contract of one plan: the registered contract with the wrapper + * paths the composite root assigns for this selection (#555). The document + * itself stays at `codexArtifactPaths.hooksManifest`, which the manifest's + * `hooks` pointer names. + */ +const planHookContract = (selected: readonly string[]): TargetHookContract => + Object.freeze({ + ...hookContract, + wrapperPath: (hook: NormalizedHook) => hookWrapperPath(codexName, hook.name, hook.targets, selected), + }); -export const planCodexArtifacts = ( - model: NormalizedPlugin, - options: CodexArtifactPlanOptions = {}, -): TargetArtifactPlan => { - const targetName = options.targetName ?? codexName; - const mcpRelativePath = options.mcpRelativePath ?? codexArtifactPaths.mcp; +export const planCodexArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { + const targetName = codexName; + const selected = model.targets.map((target) => target.name); + const mcpRelativePath = codexArtifactPaths.mcp; + const planContract = planHookContract(selected); const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName); const diagnostics: Diagnostic[] = []; const servers: Record> = Object.create(null) as Record>; @@ -1127,11 +1128,15 @@ export const planCodexArtifacts = ( if (serverPlan.value !== undefined) servers[server.name] = serverPlan.value; } - const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers }; + // An empty document still carries the manifest pointer when Claude's + // conventional `.mcp.json` shares the root, so Codex never loads it (#555). + const mcp = Object.keys(servers).length === 0 + ? (folderDiscoveryShadowed('.mcp.json', selected) ? { mcpServers: {} } : undefined) + : { mcpServers: servers }; const mcpValid = mcp !== undefined && validateMcp(mcp); if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); diagnostics.push(...codexHostedToolDiagnostics(model, isSelected)); - const generatedHooks = planHooks(model, targetName, hookContract); + const generatedHooks = planHooks(model, targetName, planContract); diagnostics.push(...generatedHooks.diagnostics); if (generatedHooks.document !== undefined) { diagnostics.push(...schemaDiagnostics('hooks', validateHooks(generatedHooks.document), validateHooks.errors)); @@ -1147,7 +1152,9 @@ export const planCodexArtifacts = ( ? { diagnostics: [] } : validatedNativeHookDocument(model, codexName, 'Codex', validateHooks, errorDiagnostic); diagnostics.push(...nativeHooks.diagnostics); - const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document); + // Likewise an empty hooks document keeps Codex off Claude's `hooks/hooks.json`. + const hookDocument = mergeHookDocuments(generatedHooks.document, nativeHooks.document) + ?? (folderDiscoveryShadowed('hooks/hooks.json', selected) ? emptyHookDocument(planContract) : undefined); const hookSemantics = hookDocument === undefined ? [] : codexHookDocumentDiagnostics(hookDocument); diagnostics.push(...hookSemantics); const hookDocumentValid = hookDocument !== undefined && hookSemantics.length === 0 && validateHooks(hookDocument); @@ -1189,13 +1196,12 @@ export const planCodexArtifacts = ( interface: interfacePlan.value, ...(appsValid ? { apps: `./${codexArtifactPaths.apps}` } : {}), ...(mcp === undefined ? {} : { mcpServers: `./${mcpRelativePath}` }), - ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), + ...(hookDocument === undefined ? {} : { hooks: `./${planContract.manifestPath}` }), name: model.metadata.name, skills: './skills/', version: model.metadata.version, }; - const pluginValidator = pluginValidatorFor(mcpRelativePath); - diagnostics.push(...schemaDiagnostics('plugin', pluginValidator(plugin), pluginValidator.errors)); + diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); const interfaceCategory = interfacePlan.value['category']; const marketplacePlan = planCodexMarketplace( @@ -1235,7 +1241,7 @@ export const planCodexArtifacts = ( hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, - hookManifestPath: hookContract.manifestPath, + hookManifestPath: planContract.manifestPath, isSelected, marketplace, marketplaceRelativePath: codexArtifactPaths.marketplace, @@ -1246,16 +1252,11 @@ export const planCodexArtifacts = ( mcpValid, model, plugin, - ...(options.sharedCopyEntries === undefined ? {} : { sharedCopyEntries: options.sharedCopyEntries }), pluginRelativePath: codexArtifactPaths.plugin, targetName, }); - // interface.logo references an artifact path, so the referenced image must - // ship; shared-copy suppression leaves emission to the composite's owner. - const plan = options.sharedCopyEntries === false - ? basePlan - : Object.freeze({ ...basePlan, entries: withPluginLogoEntry(basePlan.entries, model) }); - return withInstallSurface(plan, model, targetName === 'plugin' ? 'plugin' : 'codex'); + // interface.logo references an artifact path, so the referenced image must ship. + return Object.freeze({ ...basePlan, entries: withPluginLogoEntry(basePlan.entries, model) }); }; export const codexAdapter: TargetAdapter = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/composite-layout.ts b/packages/agent-bundle/src/adapters/composite-layout.ts new file mode 100644 index 000000000..8ed5cd08b --- /dev/null +++ b/packages/agent-bundle/src/adapters/composite-layout.ts @@ -0,0 +1,90 @@ +/** + * Where the selected host projections place the files they cannot share + * inside the one composite root (#555). + * + * Every selected host lays its projection into the same artifact root, so a + * file two hosts would both emit is either byte-identical (merged once) or a + * fatal `AB4103` collision. Files that are host-specific by nature get a + * deterministic home instead of colliding: + * + * - **Hook and MCP documents** live at fixed per-host paths whatever the + * selection, so a single-host root and a composite root share one layout. + * Claude Code and the portable Agent Plugins format load theirs from the + * conventional plugin-root locations (`hooks/hooks.json`, `.mcp.json`, + * `mcp.json`) and cannot be redirected; Codex and Cursor manifests carry + * explicit `hooks` / `mcpServers` / `mcp` pointers, so their documents live + * beside their manifests (`.codex-plugin/hooks.json`, `.cursor-plugin/mcp.json`, + * …). Each adapter owns its constants. Both hosts also fall back to folder + * discovery of the conventional paths when the pointer is absent, so a + * projection with no document of its own still points at an empty one + * whenever a selected host claims the conventional path + * (`folderDiscoveryShadowed`). + * - **Hook wrappers** bake the host they were planned for (its codec, its + * `target`, its host contract revision), so a hook that reaches several + * selected hosts compiles one wrapper per host, `hooks/..mjs`; + * a hook only one selected host receives keeps `hooks/.mjs`. + * + * Those agreements hold between the built-in hosts only. An adapter + * registered on an advanced `TargetRegistry` has made none of them, so it + * gets a root of its own: `validateModel` refuses a selection that mixes it + * with any other target (`AB4106`), and the install surface documents only + * the built-in hosts it finds in a selection. + */ + +/** The hosts Agent Bundle ships adapters for: the only targets that may share one composite root. */ +export type BuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable'; + +/** The built-in hosts, in the fixed order the install surface documents them. */ +export const builtInHostNames: readonly BuiltInHost[] = Object.freeze(['claude', 'codex', 'cursor', 'portable']); + +export const isBuiltInHost = (target: string): target is BuiltInHost => + (builtInHostNames as readonly string[]).includes(target); + +/** The composite identity of a selection: its host names, sorted, joined by `+`. */ +export const projectionIdentity = (selected: Iterable): string => + sortedProjections(selected).join('+'); + +/** The selected host names, sorted and unique — the order every composite output uses. */ +export const sortedProjections = (selected: Iterable): readonly string[] => + Object.freeze([...new Set(selected)].sort((left, right) => left.localeCompare(right))); + +/** + * The artifact-relative path of the wrapper compiled for `host` from a hook + * that reaches `hookTargets`: shared hooks compile one wrapper per selected + * host, single-host hooks keep the unsuffixed name. + */ +export const hookWrapperPath = ( + host: string, + hookName: string, + hookTargets: readonly string[], + selected: Iterable, +): string => { + const selection = new Set(selected); + const reached = hookTargets.filter((target) => selection.has(target)); + return reached.length > 1 ? `hooks/${hookName}.${host}.mjs` : `hooks/${hookName}.mjs`; +}; + +/** + * The plugin-root documents Codex and Cursor load by folder discovery when + * their manifest carries no pointer, and the selected hosts whose projection + * writes one there. `hooks/hooks.json` and `.mcp.json` are Claude Code's; + * `mcp.json` is the portable format's. Cursor documents the fallback for both + * of its defaults and Codex for `hooks/hooks.json`; Codex's behaviour without + * an `mcpServers` pointer is not pinned, and an explicit empty pointer costs + * nothing, so it is shielded the same way. + */ +const folderDiscoveryClaimants: Readonly> = Object.freeze({ + '.mcp.json': Object.freeze(['claude']), + 'hooks/hooks.json': Object.freeze(['claude']), + 'mcp.json': Object.freeze(['portable']), +}); + +/** + * True when a selected host writes the conventional document at + * `defaultPath`, so a host that would otherwise fall back to folder + * discovery there must point its manifest at a document of its own. + */ +export const folderDiscoveryShadowed = (defaultPath: string, selected: Iterable): boolean => { + const selection = new Set(selected); + return (folderDiscoveryClaimants[defaultPath] ?? []).some((host) => selection.has(host)); +}; diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 7b3c5ffb0..d1406b591 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -30,6 +30,7 @@ import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: import { createNativeEventStarter, cursorHookWrapperSource, + emptyHookDocument, encodeCursorPlaygroundInput, encodeCursorPlaygroundOutput, planHooks, @@ -58,7 +59,7 @@ import { type TargetArtifactPlan, } from './types.ts'; import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts'; -import { withInstallSurface } from '../install/surface.ts'; +import { folderDiscoveryShadowed, hookWrapperPath } from './composite-layout.ts'; const cursorName = 'cursor'; @@ -97,15 +98,15 @@ declare module '../core/types.ts' { } /** - * Cursor's local-plugin document paths, shared with the unified bundle - * adapter. A known-loading physical install uses `.cursor-plugin/plugin.json` - * with root `mcp.json` and `hooks/hooks.json`; the manifest keeps explicit - * pointers so every declared component resolves from one plugin root. + * Cursor's artifact documents. The hook and MCP documents live beside the + * manifest rather than at the conventional `hooks/hooks.json` and `mcp.json`: + * the manifest's `hooks` and `mcp` pointers name them, so a composite root + * that also holds a Claude or portable projection never collides (#555). */ export const cursorArtifactPaths = Object.freeze({ - hooks: 'hooks/hooks.json', + hooks: '.cursor-plugin/hooks.json', marketplace: '.cursor-plugin/marketplace.json', - mcp: 'mcp.json', + mcp: '.cursor-plugin/mcp.json', plugin: '.cursor-plugin/plugin.json', }); @@ -123,7 +124,7 @@ const validateMarketplace = validator.compile(marketplaceSchema); const validateSchemaUri = validator.compile({ type: 'string', format: 'uri' }); const validateSchemaEmail = validator.compile({ type: 'string', format: 'email' }); -/** The pinned Cursor document validators, shared with the unified bundle adapter. */ +/** The pinned Cursor document validators, shared with artifact validation. */ export const cursorPluginValidator = validatePlugin; export const cursorMcpValidator = validateMcp; export const cursorHooksValidator = validateHooks; @@ -164,9 +165,6 @@ export const cursorPluginNameError = (name: string): string => `Plugin name ${JSON.stringify(name)} is not a valid Cursor plugin name ` + `(lowercase kebab-case, at most ${cursorNameMaxLength} characters).`; -/** The schema-collision guard the bundle emits when no hook lowers to Cursor. */ -export const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 }); - const cursorHookDocumentEntry = (input: TargetHookDocumentEntryInput): Record => ({ ...input }); const cursorHookDocumentEnvelope = (hooks: Record): Record => ({ hooks, version: 1 }); @@ -180,7 +178,7 @@ export interface CursorHookContractOptions { } /** - * Cursor hook lowering, shared with the unified bundle adapter: flat + * Cursor hook lowering: flat * `{ command, matcher?, timeout? }` entries under a `version: 1` envelope, * `${CURSOR_PLUGIN_ROOT}` command interpolation, and the dedicated Cursor * wrapper codec (Cursor's stdin/stdout envelope is not the shared @@ -564,8 +562,22 @@ export const planCursorMarketplace = (model: NormalizedPlugin): CursorMarketplac }); }; +/** + * The hook contract of one plan: the registered contract with the wrapper + * paths the composite root assigns for this selection (#555). The document + * itself stays at `cursorArtifactPaths.hooks`, which the manifest's `hooks` + * pointer names. + */ +const planHookContract = (selected: readonly string[]): TargetHookContract => createCursorHookContract({ + manifestPath: cursorArtifactPaths.hooks, + wrapperPath: (hook) => hookWrapperPath(cursorName, hook.name, hook.targets, selected), +}); + export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName); + const selected = model.targets.map((target) => target.name); + const planContract = planHookContract(selected); + const mcpRelativePath = cursorArtifactPaths.mcp; const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets)); const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); const diagnostics: Diagnostic[] = []; @@ -579,13 +591,19 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan diagnostics.push(...serverPlan.diagnostics); if (serverPlan.value !== undefined) servers[server.name] = serverPlan.value; } - const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers }; + // A manifest pointer replaces Cursor's folder discovery, so an empty + // document is still emitted when the portable `mcp.json` or Claude's + // `hooks/hooks.json` shares the root; Cursor never loads another host's (#555). + const mcp = Object.keys(servers).length === 0 + ? (folderDiscoveryShadowed('mcp.json', selected) ? { mcpServers: {} } : undefined) + : { mcpServers: servers }; const mcpValid = mcp !== undefined && validateMcp(mcp); if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors)); - const generatedHooks = planHooks(model, cursorName, hookContract); + const generatedHooks = planHooks(model, cursorName, planContract); diagnostics.push(...generatedHooks.diagnostics); - const hookDocument = generatedHooks.document; + const hookDocument = generatedHooks.document + ?? (folderDiscoveryShadowed('hooks/hooks.json', selected) ? emptyHookDocument(planContract) : undefined); const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors)); @@ -596,8 +614,8 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan diagnostics.push(...manifestMetadata.diagnostics); const plugin = cursorManifest(model, { ...(selectedCommands.length === 0 ? {} : { commands: './commands/' }), - ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}), - ...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}), + ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${planContract.manifestPath}` } : {}), + ...(mcp !== undefined && mcpValid ? { mcp: `./${mcpRelativePath}` } : {}), ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), ...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}), ...(variables === undefined ? {} : { variables }), @@ -615,20 +633,20 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan hookDocument, hookDocumentValid, hookEntries: generatedHooks.hookEntries, - hookManifestPath: cursorArtifactPaths.hooks, + hookManifestPath: planContract.manifestPath, isSelected, marketplace: marketplacePlan.document, marketplaceRelativePath: cursorArtifactPaths.marketplace, marketplaceValid: marketplacePlan.valid, mcp, - mcpRelativePath: cursorArtifactPaths.mcp, + mcpRelativePath, mcpValid, model, plugin, pluginRelativePath: cursorArtifactPaths.plugin, targetName: cursorName, }); - return withInstallSurface(Object.freeze({ + return Object.freeze({ ...basePlan, entries: sortedEntries(withPluginLogoEntry([ ...basePlan.entries, @@ -636,7 +654,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan command.markdown === command.body ? command.markdown : command.body), ...ruleWriteEntries(model, isSelected), ], model)), - }), model, 'cursor'); + }); }; const { distributionPolicy, formats } = capabilityTable.plugin; diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index d9bcb2790..72d842326 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -641,10 +641,18 @@ const standaloneEventRoute = (route: NonNullable): export const hookWrapperAppliesOperatorEnv = (entry: TargetHookWrapper): boolean => entry.hook.eventRoute === undefined || standaloneEventRoute(entry.hook.eventRoute); +/** + * The wrapper reaches the warm MCP runtime through an endpoint identified by + * the artifact alone — its epoch and its root directory, the same two values + * the generated MCP entry bakes (#555) — never by the projection selection: + * `targets` choose host projections, not runtime identity (#592). `target` is + * the host this wrapper was compiled for; it travels with every request for + * lineage and host attribution and is checked against the entry's allowed + * hosts. + */ const eventRouteHookWrapperSource = ( entry: TargetHookWrapper, hostContractRevision: string, - concreteTarget?: string, durableLineage = false, ): string => { const route = entry.hook.eventRoute!; @@ -653,16 +661,6 @@ const eventRouteHookWrapperSource = ( // then) retires the durable lineage journal itself, so roots never outlive // their session; only projects whose state is workspace-durable have one. const retiresLineage = standalone && durableLineage && route.event === 'session/end'; - const targetSource = concreteTarget !== undefined - ? [`const target = ${JSON.stringify(concreteTarget)};`] - : entry.target === 'plugin' - ? [ - 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', - 'const target = declaredHost === "claude" || declaredHost === "codex"', - ' ? declaredHost', - ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', - ] - : ['const target = artifactTarget;']; return [ // Only a wrapper that can render in-process needs the operator `.env` // layer (#469): a shared-runtime wrapper forwards the event to the warm @@ -691,12 +689,11 @@ const eventRouteHookWrapperSource = ( `const canonicalEvent = ${JSON.stringify(route.event)};`, `const capabilityRevision = ${JSON.stringify(hostContractRevision)};`, `const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`, - `const artifactTarget = ${JSON.stringify(entry.target)};`, - ...targetSource, + `const target = ${JSON.stringify(entry.target)};`, `const runtimeMode = ${JSON.stringify(route.runtime)};`, `const fallbackMode = ${JSON.stringify(route.fallback)};`, `const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`, - "const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;", + "const endpointId = `${artifactEpoch}:${dirname(dirname(resolve(process.argv[1])))}`;", '', 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', ...(standalone @@ -1124,11 +1121,18 @@ const matcherFor = ( return patterns.length === 1 ? patterns[0] : `(?:${patterns.join('|')})`; }; +/** + * The host's hook document with no entries: what a projection points its + * manifest at when another selected host owns the conventional document the + * host would otherwise discover by folder (#555). + */ +export const emptyHookDocument = (contract: TargetHookContract): Record => + contract.documentEnvelope === undefined ? { hooks: {} } : contract.documentEnvelope({}); + export const planHooks = ( model: NormalizedPlugin, target: string, contract: TargetHookContract, - concreteEventTarget?: string, ): HookPlan => { const diagnostics: Diagnostic[] = []; const selected = model.hooks @@ -1201,7 +1205,6 @@ export const planHooks = ( : eventRouteHookWrapperSource( wrapper, contract.hostContractRevision ?? target, - concreteEventTarget, model.state?.lifetime === 'workspace-durable', ), }); @@ -1219,21 +1222,21 @@ export const planHooks = ( /** * Emits the published hook wrapper source for one target. * - * Invariant: the `'Claude'` and `'Codex'` codec bodies must stay - * byte-identical apart from the codec token baked into identifier names + * Invariant: the `'Claude'` and `'Codex'` codec bodies stay byte-identical + * apart from the codec token baked into identifier names * (`decode${codecName}Native`/`encode${codecName}Native`) and the baked - * `const target = ...` line. Decode fields, output validation, output - * encoding, and exit behavior are shared and must not diverge between the - * two. This is what makes the `'Universal'` codec sound: it serves one - * wrapper body to every host, so any Claude/Codex divergence beyond those - * two spots would make the universal wrapper wrong for whichever host it - * was not modeled on. The 'keeps the Claude and Codex native wrapper codecs - * byte-identical...' test in tests/hooks.test.ts guards this invariant — - * update it alongside any deliberate change to the shared body. + * `const target = ...` line: decode fields, output validation, output + * encoding, and exit behavior are one shared body, and every host-specific + * branch keys off the baked `target` constant. Each host in a composite root + * gets its own wrapper with its own baked target, so no wrapper ever has to + * discriminate the calling host at runtime. The 'keeps the Claude and Codex + * native wrapper codecs byte-identical...' test in tests/hooks.test.ts + * guards this invariant — update it alongside any deliberate change to the + * shared body. */ export const nativeHookWrapperSource = ( entry: TargetHookWrapper, - codecName: 'Claude' | 'Codex' | 'Universal', + codecName: 'Claude' | 'Codex', ): string => { const nativeEvent = entry.nativeEvent; const decoderFields = nativeHookInputFields.map((field) => @@ -1242,27 +1245,12 @@ export const nativeHookWrapperSource = ( .filter((field) => field.canonical !== 'hookEventName') .map((field) => ` ${field.native}: canonicalInput.${field.canonical},`); - // The universal codec serves one wrapper to every host: Codex documents - // exporting PLUGIN_ROOT into hook processes and Claude does not, so its - // presence discriminates the calling host at runtime; the simulation - // harness can pin a host explicitly through AGENT_BUNDLE_HOOK_HOST. This - // host-detection block is the only source difference the Universal codec - // is allowed from the shared Claude/Codex body below it (see the parity - // invariant documented on nativeHookWrapperSource above). - const targetSource = codecName === 'Universal' - ? [ - 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', - 'const target = declaredHost === "claude" || declaredHost === "codex"', - ' ? declaredHost', - ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', - ] - : [`const target = ${JSON.stringify(entry.target)};`]; return [ // The installed pack's operator `.env` layer (#469): the first import, so // it evaluates before the handler module (see cursorHookWrapperSource). operatorEnvLayerImport, `import * as handlerModule from ${JSON.stringify(entry.hook.source)};`, - ...targetSource, + `const target = ${JSON.stringify(entry.target)};`, `const canonicalEvent = ${JSON.stringify(entry.event)};`, `const nativeEvent = ${JSON.stringify(nativeEvent)};`, '', diff --git a/packages/agent-bundle/src/adapters/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/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index d6ea114d5..d8957d316 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -42,6 +42,7 @@ import { payloadCopyEntries, routedCliBinLayout, schemaDescriptorsFrom, + sortedEntries, sourceInputs, validateJsonSchemaDocument, validateModernMcpDocument, @@ -50,10 +51,8 @@ import { type TargetArtifactEntry, type TargetArtifactPlan, } from './types.ts'; -import { withInstallSurface } from '../install/surface.ts'; import { deepFreeze } from '../core/freeze.ts'; - /** Agent Plugins 1.0.0 §5.4 `author` object: optional `name`, `email`, and `url` strings. */ export interface PortableAuthorConfig { readonly email?: string; @@ -603,11 +602,11 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } } - return withInstallSurface(deepFreeze({ + return deepFreeze({ diagnostics: diagnostics, - entries: entries, + entries: sortedEntries(entries), hookEntries: [], - }), model, 'portable'); + }); }; export const portableAdapter: TargetAdapter = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index aba695de6..c43e25290 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -17,10 +17,10 @@ import { } from './capability-state.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; +import type { BuiltInHost } from './composite-layout.ts'; import { cursorAdapter } from './cursor.ts'; import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts'; import { portableAdapter } from './portable.ts'; -import { pluginAdapter } from './plugin.ts'; import { routedCliBinLayout, type TargetAdapter, @@ -369,20 +369,6 @@ const snapshotNativeHookSource = (adapter: TargetAdapter): NativeHookSource | un return source; }; -/** - * The registry is the boundary where unchecked JavaScript adapters are - * validated, so a malformed `lowersConfigExtensions` fails registration - * instead of throwing later inside normalization. - */ -const snapshotLowersConfigExtensions = (adapter: TargetAdapter): readonly string[] => { - const declared = adapter.lowersConfigExtensions; - if (declared === undefined) return Object.freeze([]); - if (!Array.isArray(declared) || declared.some((key) => typeof key !== 'string' || key.trim().length === 0)) { - throw new Error(`Target adapter "${adapter.name}" lowersConfigExtensions must be an array of nonempty extension keys.`); - } - return Object.freeze([...new Set(declared)]); -}; - const snapshotBinSource = (adapter: TargetAdapter): BinSource | undefined => { const source = adapter.binSource; if (source !== undefined && typeof source !== 'function') { @@ -516,6 +502,18 @@ const assertCapabilityContract = (adapter: TargetAdapter): void => { } }; +/** + * The shipped adapters by identity. An advanced registry may register its own + * adapter under a built-in host's name; only these four are the built-in + * hosts, whatever an adapter is called. + */ +const builtInAdapters: ReadonlyMap = new Map([ + [claudeAdapter, 'claude'], + [codexAdapter, 'codex'], + [cursorAdapter, 'cursor'], + [portableAdapter, 'portable'], +]); + export class TargetRegistry implements NormalizationTargetRegistry { readonly #adapters = new Map(); readonly #artifactLayouts = new Map(); @@ -524,7 +522,6 @@ export class TargetRegistry implements NormalizationTargetRegistry { readonly #defaults: string[] = []; readonly #extensions = new Map(); readonly #hookContracts = new Map(); - readonly #lowersConfigExtensions = new Map(); readonly #metadata = new Map(); readonly #mcpRuntimes = new Map(); readonly #nativeHookSources = new Map(); @@ -550,11 +547,9 @@ export class TargetRegistry implements NormalizationTargetRegistry { const hookContract = snapshotHookContract(adapter); const mcpRuntime = snapshotMcpRuntime(adapter); const artifactLayout = snapshotArtifactLayout(adapter, hookContract, mcpRuntime); - const lowersConfigExtensions = snapshotLowersConfigExtensions(adapter); const noticeDelivery = snapshotNoticeDelivery(adapter); this.#adapters.set(adapter.name, adapter); - this.#lowersConfigExtensions.set(adapter.name, lowersConfigExtensions); this.#artifactValidations.set(adapter.name, artifactValidation); this.#artifactLayouts.set(adapter.name, artifactLayout); this.#metadata.set(adapter.name, metadata); @@ -629,6 +624,25 @@ export class TargetRegistry implements NormalizationTargetRegistry { return this.#adapters.has(name); } + /** + * The built-in host the adapter registered under `name` is — judged by the + * adapter's identity, never its name, so an advanced registry's own adapter + * called `portable` is not mistaken for the shipped one (#592). `undefined` + * for a custom adapter or an unknown name. + */ + builtInHost(name: string): BuiltInHost | undefined { + const adapter = this.#adapters.get(name); + return adapter === undefined ? undefined : builtInAdapters.get(adapter); + } + + /** The built-in hosts among `names`, by adapter identity, in the order given. */ + builtInHosts(names: readonly string[]): readonly BuiltInHost[] { + return Object.freeze(names.flatMap((name) => { + const host = this.builtInHost(name); + return host === undefined ? [] : [host]; + })); + } + hookContract(name: string): TargetHookContract | undefined { if (!this.#adapters.has(name)) { throw new Error(`Unknown target adapter "${name}".`); @@ -752,13 +766,6 @@ export class TargetRegistry implements NormalizationTargetRegistry { return adapter === undefined ? undefined : (adapter.componentCapabilities ?? adapter.capabilities)[capability]; } - lowersConfigExtension(name: string, key: string): boolean { - if (!this.#adapters.has(name)) return false; - // Ownership comes from the snapshots taken at registration, never from - // the live adapter object an unchecked JavaScript caller could mutate. - return this.#extensions.get(key)?.target === name || (this.#lowersConfigExtensions.get(name) ?? []).includes(key); - } - supports(name: string, capability: string): boolean { return capabilityIsSupported(this.capabilityState(name, capability)); } @@ -782,5 +789,4 @@ export const createDefaultRegistry = (): TargetRegistry => .register(portableAdapter, { default: true }) .register(codexAdapter) .register(claudeAdapter) - .register(cursorAdapter) - .register(pluginAdapter); + .register(cursorAdapter); diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 913f7ca0b..c3c25e714 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -10,7 +10,7 @@ "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.260 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.260.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, settings.json, workflows/, and output-styles/ layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.260.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard. Workflows and output styles deliberately reuse the bin slice's normalized directory/file payload shape and byte-faithful copy entries, but retain separate registry hooks, normalized fields, destination paths, and diagnostics so the executable policy cannot leak into non-executable components. The shared enumerator preserves source file modes through copy entries and rejects lexical or realpath escapes, including a configured directory symlink that resolves outside the project. The adapter emits only the canonical plugin-root workflows/ and output-styles/ directories, so it does not emit the optional `workflows` or `outputStyles` manifest path fields and leaves plugin.schema.json plus its SHA-256 pin unchanged. https://code.claude.com/docs/en/output-styles (retrieved 2026-09-01) explicitly defines output styles as Markdown, so Agent Bundle tightens the directory to `.md` files with claude.outputStyles.file.invalid. It does not validate frontmatter: `name` is optional because the filename supplies it, other documented fields are optional, and Claude Code 2.1.257 strict plugin validation accepts a Markdown output style with no frontmatter. The plugins reference gives workflow scripts no deeper file schema, so workflow file contents and suffixes remain opaque. monitors.schema.json and theme.schema.json were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01), which marks both components experimental and warns their manifest schema may change between releases. The monitor schema is a nonempty array of closed entries requiring nonempty unique name, command, and description; optional when admits only always or on-skill-invoke:, and the plan tightens the latter by requiring the named skill to be emitted by this plugin. The schema and plan both reject ${user_config.*} in monitor commands because Claude Code rejects it at shell execution time and supplies no CLAUDE_PLUGIN_OPTION_ variables to monitors. The theme schema is closed, requires a nonempty base, and admits optional nonempty name plus a sparse string-valued overrides map. Agent Bundle further requires a nonempty themes declaration, safe file-stem keys, defaults omitted name to that key, and rejects empty override strings; it deliberately accepts any nonempty color string because the reference shows hex examples but does not mandate hex syntax. Generated themes and monitors use the default locations, so no unstable experimental.* manifest fields are emitted. Local proof against Claude Code 2.1.257 shows strict validation accepts valid emitted themes and monitors but does not inspect either default-location document's contents: malformed themes missing base and carrying non-string overrides pass, as do monitors missing command. The pinned schemas and compiler diagnostics are therefore the content guard. The same strict host rejects the deprecated top-level monitors manifest key, so default-location emission also avoids the warning-to-error migration path. The artifact contract records themes/*.json as a schema family and validates every concrete generated theme path. marketplace.schema.json was re-pinned from https://code.claude.com/docs/en/plugin-marketplaces, https://code.claude.com/docs/en/plugins-reference, https://code.claude.com/docs/en/plugin-dependencies, and the linked relevance contract (retrieved 2026-09-02). It loosens the old generated-only snapshot by making top-level description and plugin-entry description/version optional, allowing documented owner email/url, top-level $schema/version/metadata/renames/allowCrossMarketplaceDependenciesOn, and every documented plugin-entry metadata, component, relevance, authentication, dependency, channel, userConfig, settings, and experimental field; plugin-entry version is now any nonempty documented version string rather than generated-only semver. It deliberately retains only the relative ./ source form: github, git URL, git-subdir, npm, archive, and command sources are deferred to the source-matrix follow-up. The authored overlay is narrower than the validation schema and does not expose plugin name/source or component routing, because generated identity/layout remains authoritative and the agents component remains gate-deferred. Agent Bundle tightens the prose contract by closing every structured object (free-form metadata uses recursive patternProperties with additionalProperties false), requiring nonempty arrays/maps where an empty declaration has no effect, requiring unique kebab-case cross-marketplace allowlist names and kebab-case rename keys/targets, rejecting the documented exact reserved marketplace names, requiring relative sources and component paths to stay inside the marketplace, limiting owner/author URLs to absolute HTTP(S), requiring metadata.pluginRoot to be an internal ./ path, requiring nonempty header names/values, and requiring explicit strict:false with headersHelper. Relevance enforces the documented topic, item-count, character-count, bare-hostname, and regular-expression bounds; Claude Code 2.1.257 strict validation does not reject an empty allowCrossMarketplaceDependenciesOn entry. The same native validator warns that headers and headersHelper have no effect on the generated relative source and promotes that warning to failure under --strict, so the authored overlay rejects both fields with claude.marketplace.plugin.headers.inapplicable and claude.marketplace.plugin.headersHelper.inapplicable rather than emitting a manifest the host's strict validation refuses; the pinned schema keeps the documented fields, including the strict:false requirement beside headersHelper, for the URL-capable source-matrix follow-up.", "sourceMatrixNotes": "Source-matrix addendum (retrieved 2026-09-02): marketplace.schema.json now admits the documented relative, github, git URL, git-subdir, npm, archive, and command plugin sources as closed alternatives. Agent Bundle tightens relative paths and git-subdir paths against lexical escapes; GitHub shorthand to one owner/repo pair; git URLs to HTTPS or git@ forms; npm names to npm's lowercase package grammar and npm versions to exact, ^, or ~ semantic versions; registry URLs to HTTP(S); archive URLs to HTTPS with lexical rejection for localhost, IPv4/IPv6 loopback, 169.254/16 and fe80::/10 link-local addresses, and known AWS, Azure, and Google metadata hostnames; git sha to 40 hex; archive sha256 to 64 case-insensitive hex; command text to 1-500 printable ASCII characters without four-space runs; timeout to integer seconds from 1 through 600; and mode to copy or link. Entry headers remain a nonempty record of nonempty string values, and headersHelper requires a nonempty command plus strict:false. These checks do not resolve DNS, follow redirects, contact registries or repositories, download archives, verify archive bytes, execute command sources, inspect produced directories, or attempt to predict Windows link-mode support; those remain native install-time behavior.", "agentsComponentNotes": "Agents-component addendum (retrieved 2026-09-02): https://code.claude.com/docs/en/plugins-reference documents plugin-root agents/ Markdown files with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation frontmatter, with worktree as the only valid plugin-agent isolation value. https://code.claude.com/docs/en/sub-agents documents each field's runtime behavior and states that hooks, mcpServers, and permissionMode are ignored for plugin subagents. Local Claude Code 2.1.257 `claude plugin validate --strict` accepts one plugin agent exercising all supported fields and also accepts a negative fixture declaring hooks, mcpServers, and permissionMode: bypassPermissions without warning, so strict validation does not enforce the loader-side ignore contract. Agent Bundle adds no agent schema or emission surface in this revision: merged PR #220 and #107 revision 3 deliberately keep agents behind the #100 stage-2 G5 gate pending a separate evidence-backed re-admission increment. Drift addendum (#478, uploaded 2026-09-03 sub-agents reference sub-agents-3.md, \"Supported frontmatter fields\"): the contract gained color (red, blue, green, yellow, purple, orange, pink, cyan), initialPrompt (main-session agents via --agent or the agent setting), and experimental.cacheTtl (5m or 1h, Claude Code 2.1.248 or later, read only from subagent files); permissionMode, mcpServers, and hooks are each documented as \"Ignored for plugin subagents\"; name may not contain a colon (reserved for plugin-scoped identifiers, unloaded since 2.1.218) and hooks receive it as agent_type. \"Project-level hooks for subagent events\" and the hooks reference's SubagentStart matcher row give the plugin-scoped matcher form ^:$, anchored because the colon puts it on the unanchored regular-expression path. claude-2.1.260.json plugin.agents and hooks.agentTypeMatcher record these rows; the component stays deferred.", - "hooksManifestNotes": "Manifest `hooks` addendum (2026-09-03, Claude Code plugins reference \"Plugin manifest schema\" > \"Component path fields\" and \"Plugin directory structure\" > \"File locations reference\"; #462/#463, adapter fix in #470): the default hooks location is `hooks/hooks.json`, which Claude Code loads on its own, and the manifest `hooks` field (string|array|object) is documented as \"Hook config paths or inline config\" with the example `./my-extra-hooks.json`, i.e. additional hook files. Claude Code 2.1.250, 2.1.251, 2.1.257, and 2.1.259 (`claude --plugin-dir plugin list --json`, observed 2026-09-03) all refuse a plugin whose manifest names the auto-loaded file again with `errors: [\"Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.\"]`; `claude plugin validate --strict` and `claude plugin details` accept that manifest, so only the list verb exposes the refusal. plugin.schema.json therefore admits the documented `./`-prefixed path, path array, and inline object forms for `hooks` but rejects the literal `./hooks/hooks.json`, and the Claude and unified `plugin` adapters emit the hook document at the default location without a manifest `hooks` key (#470). The same 2.1.259 binary lists exactly one hook set for a plugin shipping `hooks/hooks.json` beside `hooks/hooks-cursor.json` and no manifest `hooks` key, so the unified bundle needs no pointer to keep Claude Code off the Cursor document.", + "hooksManifestNotes": "Manifest `hooks` addendum (2026-09-03, Claude Code plugins reference \"Plugin manifest schema\" > \"Component path fields\" and \"Plugin directory structure\" > \"File locations reference\"; #462/#463, adapter fix in #470): the default hooks location is `hooks/hooks.json`, which Claude Code loads on its own, and the manifest `hooks` field (string|array|object) is documented as \"Hook config paths or inline config\" with the example `./my-extra-hooks.json`, i.e. additional hook files. Claude Code 2.1.250, 2.1.251, 2.1.257, and 2.1.259 (`claude --plugin-dir plugin list --json`, observed 2026-09-03) all refuse a plugin whose manifest names the auto-loaded file again with `errors: [\"Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.\"]`; `claude plugin validate --strict` and `claude plugin details` accept that manifest, so only the list verb exposes the refusal. plugin.schema.json therefore admits the documented `./`-prefixed path, path array, and inline object forms for `hooks` but rejects the literal `./hooks/hooks.json`, and the Claude adapter emits the hook document at the default location without a manifest `hooks` key (#470). The same 2.1.259 binary lists exactly one hook set for a plugin shipping `hooks/hooks.json` beside `hooks/hooks-cursor.json` and no manifest `hooks` key, so a composite root needs no pointer to keep Claude Code off the Cursor document.", "hooksSchemaNotes": "hooks.schema.json was re-pinned (uploaded 2026-09-03 hooks reference hooks-2.md, #477) from the single shell-form command handler to the documented handler union: \"Hook handler fields\" names five types (command, http, mcp_tool, prompt, agent); \"Common fields\" gives every type if, timeout, statusMessage, and once; \"Command hook fields\" adds command, args (exec form), async, asyncRewake, and shell (bash or powershell); \"HTTP hook fields\" adds url, headers, and allowedEnvVars; \"MCP tool hook fields\" adds server, tool, and input; \"Prompt and agent hook fields\" adds prompt and model, with continueOnBlock on prompt hooks only (\"Agent hook configuration\": agent hooks have no continueOnBlock field). Each handler carries the documented required fields (command; url; server and tool; prompt) and is closed. The `hooks` map is closed to the 33 events under \"Hook events\" that the pinned 2.1.260 host knows. PreModelSwitch and PostModelSwitch \"require Claude Code v2.1.251 or later\": the previous 2.1.250 pin rejected both keys under --strict (`Invalid key in record`) and the schema kept them out until the re-pin to 2.1.260 (2026-09-03, #477 follow-up) admitted them with the command/http/mcp_tool partition \"Prompt-based hooks\" documents for them (\"PreModelSwitch runs command, http, and mcp_tool hooks only\"). \"Prompt-based hooks\" partitions the admitted events: 13 admit all five types, 18 admit command, http, and mcp_tool, and SessionStart and Setup admit command and mcp_tool only. Cross-check against the pinned Claude Code 2.1.260 (`claude plugin validate --strict --json`) and against the previous 2.1.250 pin (`--strict`; that release has no --json, so its text reporter is recorded into the same report shape), both under tests/fixtures/claude-hooks-schema and recorded by scripts/record-claude-hooks-schema-fixtures.mjs: each CLI accepts a document using every type and field, 2.1.260 accepts a document naming all 33 admitted events (2.1.250 rejects that document on the two model-switch keys), and each rejects under --strict an unknown event, an unknown handler type, a missing type, each missing required field, and each wrong-typed field (timeout 0 or a string, shell other than bash/powershell, non-string args, non-object headers or input, non-array allowedEnvVars, non-boolean async/once, non-string if/statusMessage/matcher, a non-string description, and a document without `hooks`), all of which the schema rejects too. The two CLIs disagree only on the 2.1.251 model-switch events above (cases model-switch-events and all-pinned-events): rejected by 2.1.250, accepted by the pinned 2.1.260 and by the schema. 2.1.260 --strict also accepts a prompt handler on PostModelSwitch, which \"Prompt-based hooks\" excludes, so the schema's per-event partition is the only guard there as it is for the other command/http/mcp_tool events. Agent Bundle tightenings neither CLI shares: unknown keys on the document, a matcher group, or a handler are rejected (plugins-reference \"plugin validate\" documents unrecognized manifest fields as warnings that --strict promotes to errors, but both 2.1.250 and 2.1.260 --strict ignore unknown hooks.json keys silently, so the closed pin is the only guard against a misspelled field); a field of another handler type (args or async on http, continueOnBlock on agent, model or url on command, shell or asyncRewake on mcp_tool) is rejected rather than silently ignored; a prompt, agent, or http handler on an event the reference excludes it from is rejected although the CLI accepts it; and the pre-existing minLength 1 on command (now also server, tool, prompt, url, model), integer timeout (the hook contract lowers timeoutMs to whole seconds; the CLI accepts 1.5), and minItems 1 on a group's hooks array stay, where both CLIs accept empty strings, fractional timeouts, and empty groups. The undocumented top-level `modules` key both validators mention is not admitted. The compiler still emits only shell-form command handlers with command and timeout; exec form, async, if, and statusMessage are pass-through fields for authored nativeHooks documents until a separate emission increment.", "repinNotes": "Re-pin 2.1.250 -> 2.1.260 (2026-09-03). The exact CLI surface was diffed with an isolated CLAUDE_CONFIG_DIR/HOME (scratch `npm install --prefix @anthropic-ai/claude-code@2.1.250` beside the 2.1.259 and 2.1.260 native binaries): `claude plugin --help`, `claude plugin marketplace --help`, and the --help of every plugin subcommand (install, uninstall, enable, disable, update, list, details, validate, prune, tag, init) and marketplace subcommand (add, list, remove, update) are byte-identical between 2.1.250 and 2.1.260 except `claude plugin validate --help`, which gains `--json Output the validation report as JSON (same exit codes)`; 2.1.259 and 2.1.260 differ only in the version string. `plugin validate --strict --json` (2.1.259+) returns {success, strict, target, manifest{file,type,errors,warnings,notes}, contents[]} where contents lists only files with findings and each finding is {path, message, code}; 2.1.250 answers `error: unknown option '--json'` (exit 1), so host-contracts/claude-plugin-validation.ts requests --json when the probed version is 2.1.259 or later and parses the text reporter below that. `claude --plugin-dir plugin list --json` rows for a session-loaded plugin carry id (@inline), version, scope \"session\", enabled, installPath, and errors[] only when the load was refused; installed rows additionally carry installedAt, lastUpdated, and optional mcpServers (2.1.259 evidence in claude-2.1.260.json). The duplicate-`hooks` manifest refusal (#470/#479) is unchanged: 2.1.259 and 2.1.260 both list `Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file /hooks/hooks.json ...` while `plugin validate --strict --json` reports success. Marketplace source constraints: 2.1.250 reports a short github sha as `plugins.0.source: Invalid input`; 2.1.260 reports `plugins.0.source.sha: Invalid input` plus `plugins.0.source.sha: Must be a full 40-character lowercase git commit SHA` (the wording host-adapters.native.test.ts pins). Hook events: 2.1.260 --strict accepts PreModelSwitch and PostModelSwitch (v2.1.251+) and every other event under \"Hook events\"; hooks.schema.json admits both. Agents frontmatter: 2.1.260 --strict --json accepts an agents/*.md declaring color, initialPrompt, and experimental.cacheTtl (sub-agents-3.md \"Supported frontmatter fields\"), and equally accepts an unknown `colour` key, `cacheTtl: 2h`, and the hooks/mcpServers/permissionMode fields the reference marks ignored for plugin subagents; its only agent finding is the `description` warning (`No description in frontmatter ...`), which --strict promotes to failure, so the validator does not enforce the frontmatter vocabulary and the agents component stays behind the #100 stage-2 G5 gate with the capability rows updated. Nothing observed on 2.1.260 contradicts the uploaded hooks-2.md, plugins-reference-1.md, sub-agents-3.md, or plugin-marketplaces-0.md; the validator is looser than the documents in the places recorded above and in hooksSchemaNotes.", "schemas": { diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 732e94b26..6b38b2763 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -199,11 +199,6 @@ export interface StandardPluginArtifactsInput { readonly mcp?: Record; /** Artifact-relative path for the MCP document; defaults to the plugin-root `.mcp.json` convention. */ readonly mcpRelativePath?: string; - /** - * Emit the target-agnostic skill and asset copy entries; a composing target - * that lays two host plans into one root emits them from one side only. - */ - readonly sharedCopyEntries?: boolean; readonly mcpValid: boolean; readonly model: NormalizedPlugin; readonly plugin: Record; @@ -298,7 +293,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): ), }); } - for (const skill of input.sharedCopyEntries === false ? [] : model.skills) { + for (const skill of model.skills) { if (!isSelected(skill.targets)) continue; const hostDocument = skill.hostDocuments?.[targetName]; const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; @@ -342,7 +337,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): } } - for (const asset of input.sharedCopyEntries === false ? [] : model.assets ?? []) { + for (const asset of model.assets ?? []) { if (!isSelected(asset.targets)) continue; entries.push({ bytes: asset.bytes, @@ -362,7 +357,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): }); } - entries.push(...(input.sharedCopyEntries === false ? [] : payloadCopyEntries(model, isSelected))); + entries.push(...payloadCopyEntries(model, isSelected)); return Object.freeze({ diagnostics: Object.freeze(diagnostics), @@ -433,7 +428,7 @@ export interface TargetArtifactLayout { readonly mcpApps?: TargetArtifactOutputLayout; readonly mcpEntries?: TargetArtifactOutputLayout; readonly outputStyles?: TargetArtifactOutputLayout; - /** Adapter-owned plain documents at the artifact root (for example a generated AGENTS.md). */ + /** Adapter-owned plain documents at the artifact root (for example a host manifest under its dotfolder). */ readonly rootDocuments?: readonly string[]; readonly rules?: TargetArtifactOutputLayout; readonly scripts?: TargetArtifactOutputLayout; @@ -531,14 +526,6 @@ export interface TargetAdapter { /** Per-component-kind emission dispatch used by inspect skip accounting; defaults to `capabilities`. */ readonly componentCapabilities?: Readonly>; readonly configExtension?: TargetConfigExtension; - /** - * Config extension keys this adapter lowers besides its own (a composite - * that plans other hosts' sides). Host-scoped declarations such as - * `.lspServers` are eligible for emission only on adapters that lower - * that key, so an opaque declaration never counts as emitted by a host whose - * planner never reads it. - */ - readonly lowersConfigExtensions?: readonly string[]; readonly hookContract?: TargetHookContract; readonly metadata: TargetAdapterMetadata; readonly mcpRuntime?: TargetMcpRuntimeContract; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 0db293a9a..cf4234235 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -6,10 +6,12 @@ import { promisify } from 'node:util'; import { Effect, type Scope } from 'effect'; import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts'; +import type { BuiltInHost } from './adapters/composite-layout.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; -import { routedCliBins, targetHostsCliBin } from './build/cli-bins.ts'; +import { routedCliBins } from './build/cli-bins.ts'; +import { planComposite } from './build/compose.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; import { rewritesWorkspaceProtocols } from './build/pack-dependencies.ts'; import { @@ -553,8 +555,8 @@ export interface BuildOptions extends ProjectOptions { /** * After the artifact is written, run the installed Claude developer * validator (`claude plugin validate --strict` against the emitted - * `plugin.json` and `marketplace.json`) for every built `claude` and - * `plugin` target, exactly as `validate --artifact` does. The CLI `build` + * `plugin.json` and `marketplace.json`) for every built `claude` target, + * exactly as `validate --artifact` does. The CLI `build` * command requests this by default; programmatic artifact operations * (temporary artifacts, dev, evals) never do. Without `claude` on `PATH` * the run costs one failed spawn and reports a single `AB6019` info. @@ -583,7 +585,7 @@ export interface BuildProjectResult { * (`AB6019`–`AB6022`) when `hostValidation` ran. */ readonly diagnostics: readonly Diagnostic[]; - /** One report per built `claude`/`plugin` target; present only when `hostValidation` was requested. */ + /** One report per built `claude` target; present only when `hostValidation` was requested. */ readonly hostValidation?: readonly ClaudePluginValidationReport[]; readonly model: NormalizedPlugin; readonly packageBuild?: PackageBuildResult; @@ -728,20 +730,8 @@ const temporaryArtifact = async ( )); }; -type HostValidatedTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; - -const hostValidatedTargets: ReadonlySet = new Set([ - 'claude', - 'codex', - 'cursor', - 'plugin', - 'portable', -]); - -const isHostValidatedTarget = (name: string): name is HostValidatedTarget => hostValidatedTargets.has(name); - const hostValidationReport = ( - target: HostValidatedTarget, + target: BuiltInHost, pluginDirectory: string, strict: boolean | undefined, ): Promise[number]> => { @@ -753,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; @@ -774,10 +763,13 @@ export const validate = async (options: ValidateOptions): Promise target.name) - .filter(isHostValidatedTarget) - .map((target) => hostValidationReport(target, join(artifact, target), options.strict))); + // The shipped validators judge the shipped adapters' projections, by + // adapter identity: a custom adapter named like a built-in host is not + // held to that host's contract (#592). + const reports = await Promise.all( + registryFor(options).builtInHosts(validated.snapshot.manifest.targets.map((target) => target.name)) + .map((target) => hostValidationReport(target, artifact, options.strict)), + ); return Object.freeze({ diagnostics: freezeDiagnostics([ ...validated.diagnostics, @@ -1113,18 +1105,14 @@ export const inspect = async (options: InspectOptions): Promise = let bundler: BundlerInspection | undefined; if (options.focus === 'bundler') { try { + // The bundler surfaces are those of the one composite root, so the + // inspection composes the same selection the build stages (#555). The + // preparation above already judged that composition (AB4103, AB4105), + // so an invalid root never reaches this point. bundler = await composeBundlerInspection({ + composite: planComposite(model, prepared.registry).plan, model, projectRoot: prepared.root, - targets: plans.map((plan) => { - const noticeDelivery = prepared.registry.noticeDelivery(plan.target); - return { - cliBin: targetHostsCliBin(prepared.registry, plan.target), - hookEntries: plan.hookEntries, - name: plan.target, - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), - }; - }), ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); } catch { @@ -1232,7 +1220,7 @@ export const build = async (options: BuildOptions): Promise if (packageBuild !== undefined) assertPackageOutputSources(packageBuild, projectContext); } const hostValidation = options.hostValidation === true - ? await buildHostValidation(result.manifest.targets.map((target) => target.name), output, options) + ? await buildHostValidation(prepared.registry.builtInHosts(result.manifest.targets.map((target) => target.name)), output, options) : undefined; return Object.freeze({ build: result, @@ -1248,30 +1236,30 @@ export const build = async (options: BuildOptions): Promise }); }; -const claudeValidatedTargets: ReadonlySet = new Set(['claude', 'plugin']); - /** * `build --host-validation`: the Claude developer validator (`plugin validate` * over both manifests, then the `--plugin-dir … plugin list --json` load check) - * over every built `claude`/`plugin` target (#476). Targets run one after - * another: once the CLI proves absent (`AB6019`), the remaining targets are - * marked `unavailable` without another spawn, so a build without `claude` on - * `PATH` costs one failed spawn and reports the skip once. + * over the built `claude` projection (#476) — the shipped Claude adapter by + * identity, never a custom adapter under that name (#592). Targets run one + * after another: once the CLI proves absent (`AB6019`), the remaining targets + * are marked `unavailable` without another spawn, so a build without `claude` + * on `PATH` costs one failed spawn and reports the skip once. */ const buildHostValidation = async ( - targets: readonly string[], + hosts: readonly BuiltInHost[], output: string, options: Pick, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly reports: readonly ClaudePluginValidationReport[] }> => { const reports: ClaudePluginValidationReport[] = []; let unavailable = false; - for (const target of targets.filter((name) => claudeValidatedTargets.has(name))) { + for (const target of hosts.filter((host) => host === 'claude')) { if (unavailable) { reports.push(Object.freeze({ diagnostics: freezeDiagnostics([]), host: 'claude', status: 'unavailable', target })); continue; } + // The Claude projection's plugin directory is the composite root (#555). const report = await validateClaudePlugin({ - pluginDirectory: join(output, target), + pluginDirectory: output, ...(options.hostValidationRunner === undefined ? {} : { run: options.hostValidationRunner }), ...(options.strict === undefined ? {} : { strict: options.strict }), target, diff --git a/packages/agent-bundle/src/build/artifact-layout.ts b/packages/agent-bundle/src/build/artifact-layout.ts index cd7202ef9..0e44c48f2 100644 --- a/packages/agent-bundle/src/build/artifact-layout.ts +++ b/packages/agent-bundle/src/build/artifact-layout.ts @@ -18,12 +18,3 @@ export const isDirectOutputLayoutPath = ( nested.length === 0 && layout.allowedSuffixes.some((suffix) => file.length > suffix.length && file.endsWith(suffix)); }; - -export const targetArtifactPath = (target: string, path: string): string => `${target}/${path}`; - -export const pathInTargetOutputLayout = ( - targetPath: string, - target: string, - layout: TargetArtifactOutputLayout | undefined, -): boolean => targetPath.startsWith(`${target}/`) && - isDirectOutputLayoutPath(targetPath.slice(target.length + 1), layout); diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 60edf717f..95e8b5877 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -4,8 +4,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'; import packageManifest from '../../package.json' with { type: 'json' }; import type { TargetRegistry } from '../adapters/registry.ts'; -import type { TargetArtifactEntry, TargetHookEntry } from '../adapters/types.ts'; -import { deduplicateDiagnostics, DiagnosticBag, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; +import { deduplicateDiagnostics, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; import { pathTokens, type AgentBundleToolsConfig, type NormalizedPlugin } from '../core/types.ts'; import { assertInside, isInsideOrEqual } from '../core/paths.ts'; @@ -21,13 +20,8 @@ import { type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; -import { - cliBinCollisionDiagnostics, - planCliBinsSurface, - planCompiledCliBins, - targetHostsCliBin, - type CompiledCliBin, -} from './cli-bins.ts'; +import { planCliBinsSurface, planCompiledCliBins, type CompiledCliBin } from './cli-bins.ts'; +import { composeProjections, type CompositePlan } from './compose.ts'; import { projectMeta } from './meta.ts'; import { compileMcpApps, @@ -38,7 +32,7 @@ import { } from './mcp-apps.ts'; import { bundleSyntaxCheckFor } from './module-imports.ts'; import { compileRslibSurfaces, settledRslibSurface } from './rslib.ts'; -import { planTargetStages } from './target-stages.ts'; +import { planCompileStages } from './compile-stages.ts'; import { assertUniqueArtifactDestinations, artifactHookIndexName, @@ -94,15 +88,8 @@ export interface BuildOptions { readonly tools?: AgentBundleToolsConfig; } -interface PlannedTarget { - /** True when the target's adapter publishes the `cli` capability, admitting the routed CLI bin. */ - readonly cliBin: boolean; - readonly entries: readonly TargetArtifactEntry[]; - readonly hookEntries: readonly TargetHookEntry[]; - readonly name: string; -} - -interface StagedTarget extends PlannedTarget { +/** The compiled outputs of the one composite root, planned before anything compiles. */ +interface StagedRoot { readonly compiledCliBins: readonly CompiledCliBin[]; readonly compiledEntries: readonly CompiledEntry[]; readonly compiledHooks: readonly CompiledHookEntry[]; @@ -171,73 +158,47 @@ const prebuiltPayloadDiagnostics = ( return diagnostics; }; -const planTargets = (options: BuildOptions): readonly PlannedTarget[] => { - const diagnostics: Diagnostic[] = []; - const planned: PlannedTarget[] = []; - - for (const target of options.model.targets) { - const adapter = options.registry.get(target.name); - const plan = adapter.plan(options.model); - diagnostics.push(...plan.diagnostics); - const hookEntries = plan.hookEntries ?? Object.freeze([]); - for (const hookEntry of hookEntries) { - if (hookEntry.target !== target.name) { - diagnostics.push({ - code: 'AB5000', - message: `Target adapter ${JSON.stringify(target.name)} planned hook ${JSON.stringify(hookEntry.hook.id)} for target ${JSON.stringify(hookEntry.target)}, expected ${JSON.stringify(target.name)}.`, - severity: 'error', - target: target.name, - }); - } - } - const cliBin = targetHostsCliBin(options.registry, target.name); - if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(options.model, target.name, plan.entries)); - planned.push({ - cliBin, - entries: plan.entries, - hookEntries, - name: target.name, - }); - } - new DiagnosticBag(deduplicateDiagnostics(diagnostics)).throwIfErrors(); - return planned; -}; +/** + * The scripts a composite root compiles: every script whose target set + * intersects the selection, once. Script sources are host-neutral, so one + * `scripts/.mjs` serves every selected host that declares it. + */ +const selectedScripts = (model: NormalizedPlugin, selected: readonly string[]): NormalizedPlugin['scripts'] => + model.scripts.filter((script) => script.targets.some((target) => selected.includes(target))); -const planStagedTargets = (options: { - readonly artifactRoot: string; +const planStagedRoot = (options: { + readonly composite: CompositePlan; readonly model: NormalizedPlugin; readonly projectRoot: string; - readonly targets: readonly PlannedTarget[]; -}): readonly StagedTarget[] => options.targets.map((target) => { - const root = assertInside(options.artifactRoot, resolve(options.artifactRoot, target.name)); - const scripts = options.model.scripts.filter((script) => script.targets.includes(target.name)); - const compiledEntries = planCompiledEntries(scripts, { cwd: options.projectRoot, outDir: root }); - const compiledHooks = planCompiledHooks(target.hookEntries, { outDir: root }); - const compiledMcpApps = planCompiledMcpApps(options.model.mcpApps ?? [], { + readonly root: string; +}): StagedRoot => { + const { composite, model, root } = options; + const compiledEntries = planCompiledEntries(selectedScripts(model, composite.selected), { cwd: options.projectRoot, outDir: root }); + const compiledHooks = planCompiledHooks(composite.hookEntries, { outDir: root }); + const compiledMcpApps = planCompiledMcpApps(model.mcpApps ?? [], { outDir: root, - target: target.name, + selected: composite.selected, + target: composite.identity, }); - const compiledMcpEntries = planCompiledMcpEntries(options.model.mcpServers, { + const compiledMcpEntries = planCompiledMcpEntries(model.mcpServers, { outDir: root, - target: target.name, + target: composite.identity, + targets: composite.selected, }); - const compiledCliBins = target.cliBin - ? planCompiledCliBins(options.model, { outDir: root, target: target.name }) + const compiledCliBins = composite.cliBin + ? planCompiledCliBins(model, { outDir: root, target: composite.identity }) : Object.freeze([]); - return { ...target, compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; -}); + return { compiledCliBins, compiledEntries, compiledHooks, compiledMcpApps, compiledMcpEntries, root }; +}; -const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[] => - targets.flatMap((target) => [ - ...target.entries.map((entry) => - resolveArtifactDestination(target.root, entry.relativePath), - ), - ...target.compiledCliBins.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ...target.compiledMcpApps.map((entry) => entry.output), - ...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), - ]); +const plannedDestinations = (composite: CompositePlan, staged: StagedRoot): readonly string[] => [ + ...composite.entries.map((entry) => resolveArtifactDestination(staged.root, entry.relativePath)), + ...staged.compiledCliBins.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledHooks.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), + ...staged.compiledMcpApps.map((entry) => entry.output), + ...staged.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), +]; const hookIndexSourceInputs = ( model: NormalizedPlugin, @@ -257,16 +218,16 @@ const outputCandidatesFor = (options: { readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly entries: CompositePlan['entries']; readonly model: NormalizedPlugin; - readonly targets: readonly StagedTarget[]; }): readonly ArtifactOutputCandidate[] => [ - ...options.targets.flatMap((target) => target.entries.map((entry) => ({ + ...options.entries.map((entry) => ({ kind: entry.kind !== 'copy' ? 'generated' as const : entry.prebuilt === true ? 'prebuilt' as const : 'copy' as const, - path: resolveArtifactDestination(target.root, entry.relativePath), + path: resolveArtifactDestination(options.artifactRoot, entry.relativePath), sourceInputs: entry.sourceInputs, - }))), + })), ...options.compiledCliBins.flatMap((entry) => [{ kind: 'bundle' as const, path: entry.output, @@ -329,11 +290,12 @@ const assertOutputProvenanceSources = (options: { } }; +/** The selected real projections the composite root holds, with their adapter provenance. */ const manifestTargets = ( registry: TargetRegistry, - targets: readonly StagedTarget[], -): ArtifactManifest['targets'] => Object.freeze(targets - .map(({ name }) => { + selected: readonly string[], +): ArtifactManifest['targets'] => Object.freeze(selected + .map((name) => { const metadata = registry.metadata(name); return Object.freeze({ adapterRevision: metadata.adapterRevision, @@ -351,9 +313,9 @@ const manifestFor = (options: { readonly model: NormalizedPlugin; readonly projectContext: ProjectContext; readonly registry: TargetRegistry; - readonly targets: readonly StagedTarget[]; + readonly selected: readonly string[]; }): ArtifactManifest => { - const targets = manifestTargets(options.registry, options.targets); + const targets = manifestTargets(options.registry, options.selected); return { agentSkills: agentSkillsSchemaRevision, files: options.files, @@ -373,14 +335,17 @@ export const build = async (options: BuildOptions): Promise => { const outputRoot = resolve(options.outputRoot); const payloadDiagnostics = prebuiltPayloadDiagnostics(options.model, outputRoot); if (payloadDiagnostics.length > 0) throw new DiagnosticError(payloadDiagnostics); - const planned = planTargets(options); - const preflightTargets = planStagedTargets({ - artifactRoot: outputRoot, + // One composite root (#555): the selected host projections are planned + // together and staged as one tree at the artifact root, never one + // subdirectory per target. + const composite = composeProjections(options.model, options.registry); + const preflight = planStagedRoot({ + composite, model: options.model, projectRoot: options.projectRoot, - targets: planned, + root: outputRoot, }); - assertUniqueArtifactDestinations(plannedDestinations(preflightTargets)); + assertUniqueArtifactDestinations(plannedDestinations(composite, preflight)); const stageParent = dirname(outputRoot); await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); @@ -388,13 +353,13 @@ export const build = async (options: BuildOptions): Promise => { assertInside(outputRoot, resolve(outputRoot, relative(stageRoot, entry.output))); try { - const stagedTargets = planStagedTargets({ - artifactRoot: stageRoot, + const staged = planStagedRoot({ + composite, model: options.model, projectRoot: options.projectRoot, - targets: planned, + root: stageRoot, }); - assertUniqueArtifactDestinations(plannedDestinations(stagedTargets)); + assertUniqueArtifactDestinations(plannedDestinations(composite, staged)); const compiledCliBins: CompiledCliBin[] = []; const compiledEntries: CompiledEntry[] = []; @@ -411,86 +376,88 @@ 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. - { - const views = await compileMcpApps(options.model.mcpApps ?? [], { - cwd: options.projectRoot, - meta, - ...(options.mode === undefined ? {} : { mode: options.mode }), - outDir: target.root, - target: target.name, - ...tools, - }); - targetMcpApps = views.apps; - compiledMcpApps.push(...views.apps); - compileDiagnostics.push(...views.diagnostics); - } - break; - case 'node-surfaces': { - await emitPlanEntries({ entries: target.entries, root: target.root }); - const noticeDelivery = options.registry.noticeDelivery(target.name); - // Every agent-host surface of the target lowers through one Rslib - // instance; each surface keeps its own evidence and result. - const [cliBins, scripts, hooks, mcpEntries] = await compileRslibSurfaces( - { cwd: options.projectRoot, meta, outputRoot: target.root, ...tools }, - [ - target.cliBin - ? planCliBinsSurface(options.model, { outDir: target.root, target: target.name }) - : settledRslibSurface(Object.freeze([])), - await planScriptsSurface( - options.model.scripts.filter((script) => script.targets.includes(target.name)), - { - cwd: options.projectRoot, - layouts: options.model.layouts ?? [], - outDir: target.root, - ...noticePolicy, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - }, - ), - planHooksSurface(target.hookEntries, { - artifactEpoch: options.projectContext.revision, - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), - ...noticePolicy, - outDir: target.root, - plugin, - providers: options.model.providers ?? [], - ...(options.model.state === undefined ? {} : { state: options.model.state }), - }), - await planMcpEntriesSurface(options.model.mcpServers, { - apps: targetMcpApps, - artifactEpoch: options.projectContext.revision, - eventHooks: target.hookEntries - .filter((entry) => entry.hook.eventRoute !== undefined) - .map((entry) => entry.hook), + // The routes every selected host honours; the shared MCP entries and each + // host's hook wrappers are wired from the same advertisement. + const noticeDelivery = composite.noticeDelivery === undefined ? {} : { noticeDelivery: composite.noticeDelivery }; + let stagedMcpApps: readonly CompiledMcpApp[] = Object.freeze([]); + for (const stage of planCompileStages(staged)) { + switch (stage.kind) { + case 'mcp-apps': + // The optional browser stage, always first: the MCP entries embed + // its HTML, and its Rsbuild pass asserts the root holds nothing but + // that HTML. + { + const views = await compileMcpApps(options.model.mcpApps ?? [], { + cwd: options.projectRoot, + meta, + ...(options.mode === undefined ? {} : { mode: options.mode }), + outDir: stageRoot, + selected: composite.selected, + target: composite.identity, + ...tools, + }); + stagedMcpApps = views.apps; + compiledMcpApps.push(...views.apps); + compileDiagnostics.push(...views.diagnostics); + } + break; + case 'node-surfaces': { + await emitPlanEntries({ entries: composite.entries, root: stageRoot }); + // Every agent-host surface of the root lowers through one Rslib + // instance; each surface keeps its own evidence and result. + const [cliBins, scripts, hooks, mcpEntries] = await compileRslibSurfaces( + { cwd: options.projectRoot, meta, outputRoot: stageRoot, ...tools }, + [ + composite.cliBin + ? planCliBinsSurface(options.model, { outDir: stageRoot, target: composite.identity }) + : settledRslibSurface(Object.freeze([])), + await planScriptsSurface( + selectedScripts(options.model, composite.selected), + { + cwd: options.projectRoot, layouts: options.model.layouts ?? [], - ...(noticeDelivery === undefined ? {} : { noticeDelivery }), + outDir: stageRoot, ...noticePolicy, - outDir: target.root, - plugin, providers: options.model.providers ?? [], ...(options.model.state === undefined ? {} : { state: options.model.state }), - target: target.name, - }), - ], - ); - compiledCliBins.push(...cliBins); - compiledEntries.push(...scripts); - compiledHooks.push(...hooks); - compiledMcpEntries.push(...mcpEntries); - break; - } - default: { - const exhaustive: never = stage; - throw new Error(`Unknown target compile stage ${JSON.stringify(exhaustive)}.`); - } + }, + ), + planHooksSurface(composite.hookEntries, { + artifactEpoch: options.projectContext.revision, + ...noticeDelivery, + ...noticePolicy, + outDir: stageRoot, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + }), + await planMcpEntriesSurface(options.model.mcpServers, { + apps: stagedMcpApps, + artifactEpoch: options.projectContext.revision, + eventHooks: [...new Map(composite.hookEntries + .filter((entry) => entry.hook.eventRoute !== undefined) + .map((entry) => [entry.hook.id, entry.hook])).values()], + layouts: options.model.layouts ?? [], + ...noticeDelivery, + ...noticePolicy, + outDir: stageRoot, + plugin, + providers: options.model.providers ?? [], + ...(options.model.state === undefined ? {} : { state: options.model.state }), + target: composite.identity, + targets: composite.selected, + }), + ], + ); + compiledCliBins.push(...cliBins); + compiledEntries.push(...scripts); + compiledHooks.push(...hooks); + compiledMcpEntries.push(...mcpEntries); + break; + } + default: { + const exhaustive: never = stage; + throw new Error(`Unknown compile stage ${JSON.stringify(exhaustive)}.`); } } } @@ -523,8 +490,8 @@ export const build = async (options: BuildOptions): Promise => { compiledHooks, compiledMcpApps, compiledMcpEntries, + entries: composite.entries, model: options.model, - targets: stagedTargets, }), projectRoot: options.projectRoot, }); @@ -552,7 +519,7 @@ export const build = async (options: BuildOptions): Promise => { model: options.model, projectContext: options.projectContext, registry: options.registry, - targets: stagedTargets, + selected: composite.selected, }), }); const diagnostics = await validateArtifact({ artifactRoot: stageRoot, bundleSyntaxCheck, registry: options.registry }); diff --git a/packages/agent-bundle/src/build/target-stages.ts b/packages/agent-bundle/src/build/compile-stages.ts similarity index 61% rename from packages/agent-bundle/src/build/target-stages.ts rename to packages/agent-bundle/src/build/compile-stages.ts index 19006ca24..b37f8c196 100644 --- a/packages/agent-bundle/src/build/target-stages.ts +++ b/packages/agent-bundle/src/build/compile-stages.ts @@ -2,8 +2,8 @@ import type { CompiledCliBin } from './cli-bins.ts'; import type { CompiledEntry, CompiledHookEntry, CompiledMcpEntry } from './entries.ts'; import type { CompiledMcpApp } from './mcp-apps.ts'; -/** The planned outputs of one target, before anything compiles. */ -export interface PlannedTargetOutputs { +/** The planned compiled outputs of the composite root, before anything compiles. */ +export interface PlannedRootOutputs { readonly compiledCliBins: readonly Pick[]; readonly compiledEntries: readonly Pick[]; readonly compiledHooks: readonly Pick[]; @@ -12,12 +12,12 @@ export interface PlannedTargetOutputs { } /** - * One compile stage of a target, in dependency order. + * One compile stage of the composite root, in dependency order. * * - `mcp-apps`: the browser environment — MCP App views through the - * workspace `@rsbuild/core`. Present only for a target that declares - * apps, and always first: the MCP entries embed its emitted HTML, and its - * pass asserts the target root holds nothing but that HTML. + * workspace `@rsbuild/core`. Present only when the selection reaches an + * app, and always first: the MCP entries embed its emitted HTML, and its + * pass asserts the root holds nothing but that HTML. * - `node-surfaces`: every agent-host surface — the routed CLI bin, bundled * scripts, hook wrappers, MCP entries, and each surface's react-server * Flight worker — lowered together through one Rslib instance (one @@ -26,7 +26,7 @@ export interface PlannedTargetOutputs { * reaches the worker by file name at run time, never through a build-time * manifest, so nothing orders them within it. */ -export type TargetCompileStage = +export type CompileStage = | { readonly kind: 'mcp-apps'; readonly outputs: readonly string[] } | { readonly kind: 'node-surfaces'; readonly outputs: readonly string[] }; @@ -38,25 +38,25 @@ const withWorkers = ( ]); /** - * The compile stages of one target. The build runs them in this order; the - * browser stage is skipped entirely — no Rsbuild instance — for a target - * without MCP Apps, and the node stage creates no Rslib instance when it - * has no outputs. + * The compile stages of the composite root. The build runs them in this + * order; the browser stage is skipped entirely — no Rsbuild instance — for a + * root without MCP Apps, and the node stage creates no Rslib instance when + * it has no outputs. */ -export const planTargetStages = (target: PlannedTargetOutputs): readonly TargetCompileStage[] => Object.freeze([ - ...(target.compiledMcpApps.length === 0 +export const planCompileStages = (root: PlannedRootOutputs): readonly CompileStage[] => Object.freeze([ + ...(root.compiledMcpApps.length === 0 ? [] : [Object.freeze({ kind: 'mcp-apps' as const, - outputs: Object.freeze(target.compiledMcpApps.map((app) => app.output)), + outputs: Object.freeze(root.compiledMcpApps.map((app) => app.output)), })]), Object.freeze({ kind: 'node-surfaces' as const, outputs: Object.freeze([ - ...withWorkers(target.compiledCliBins), - ...withWorkers(target.compiledEntries.filter((entry) => entry.outputKind === 'bundle')), - ...withWorkers(target.compiledHooks), - ...withWorkers(target.compiledMcpEntries), + ...withWorkers(root.compiledCliBins), + ...withWorkers(root.compiledEntries.filter((entry) => entry.outputKind === 'bundle')), + ...withWorkers(root.compiledHooks), + ...withWorkers(root.compiledMcpEntries), ]), }), ]); diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts new file mode 100644 index 000000000..0734dc7b1 --- /dev/null +++ b/packages/agent-bundle/src/build/compose.ts @@ -0,0 +1,281 @@ +import { componentKindCapabilityName, type AgentComponentKind } from '../core/components.ts'; +import { deduplicateDiagnostics, DiagnosticBag, type Diagnostic } from '../core/diagnostics.ts'; +import type { NormalizedPlugin } from '../core/types.ts'; +import { projectionIdentity, sortedProjections } from '../adapters/composite-layout.ts'; +import { intersectNoticeDeliveryAdvertisements } from '../adapters/capability-state.ts'; +import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; +import type { TargetRegistry } from '../adapters/registry.ts'; +import { + sortedEntries, + type TargetArtifactEntry, + type TargetArtifactPlan, + type TargetHookEntry, +} from '../adapters/types.ts'; +import { installSurfaceEntries } from '../install/surface.ts'; +import { cliBinCollisionDiagnostics, targetHostsCliBin } from './cli-bins.ts'; + +/** + * One selected host's projection into the composite root (#555): the plan its + * adapter produced for the whole normalized model, before the projections are + * merged into the single tree the build stages. + */ +interface ComposedProjection { + /** True when the host's adapter publishes the `cli` capability, admitting the routed CLI bin. */ + readonly cliBin: boolean; + readonly name: string; + readonly plan: TargetArtifactPlan; +} + +/** + * The one artifact tree the build stages at `artifactRoot`: every selected + * projection merged by path, plus the install surface written once for the + * whole selection. + */ +export interface CompositePlan { + /** True when any selected host admits the routed CLI bin. */ + readonly cliBin: boolean; + readonly entries: readonly TargetArtifactEntry[]; + readonly hookEntries: readonly TargetHookEntry[]; + /** The selection as one identity: sorted host names joined by `+`. */ + readonly identity: string; + /** + * The notice delivery advertisement every selected host honours: the + * intersection of the hosts' own advertisements, so the shared MCP entries + * and every host's hook wrappers agree on the routes they may use. Absent + * when a selected host advertises nothing. + */ + readonly noticeDelivery: NoticeDeliveryAdvertisement | undefined; + /** The selected projections, sorted by host name. */ + readonly projections: readonly ComposedProjection[]; + /** The selected host names, sorted. */ + readonly selected: readonly string[]; +} + +const sameBytes = (left: TargetArtifactEntry, right: TargetArtifactEntry): boolean => { + if (left.kind === 'write' && right.kind === 'write') return left.content === right.content; + if (left.kind === 'copy' && right.kind === 'copy') { + return left.source === right.source && (left.prebuilt === true) === (right.prebuilt === true); + } + return false; +}; + +const collisionDiagnostic = (relativePath: string, owners: readonly string[]): Diagnostic => ({ + code: 'AB4103', + generatedPath: relativePath, + message: `Artifact path ${JSON.stringify(relativePath)} is planned with different contents by the ${owners.join(' and ')} projections; one composite root cannot hold both.`, + recovery: 'Build the conflicting hosts into separate artifacts (one `targets` entry per build), or make the component identical for every selected host.', + severity: 'error', +}); + +interface MergedEntries { + readonly diagnostics: readonly Diagnostic[]; + readonly entries: readonly TargetArtifactEntry[]; +} + +/** + * Merges the selected projections into one tree. Byte-identical entries at + * one path (a skill every host copies, a shared script) are kept once; a path + * two projections plan with different bytes is fatal (`AB4103`). Projections + * are visited in host-name order and entries in path order, so the same + * selection reports the same collision no matter how `targets` was written. + */ +const mergeEntries = ( + owned: readonly { readonly entries: readonly TargetArtifactEntry[]; readonly owner: string }[], +): MergedEntries => { + const byPath = new Map(); + for (const { entries, owner } of owned) { + for (const entry of sortedEntries([...entries])) { + const existing = byPath.get(entry.relativePath); + if (existing === undefined) { + byPath.set(entry.relativePath, { conflicting: [], entry, owners: [owner] }); + continue; + } + if (sameBytes(existing.entry, entry)) { + existing.owners.push(owner); + continue; + } + existing.conflicting.push(owner); + } + } + const diagnostics: Diagnostic[] = []; + const entries: TargetArtifactEntry[] = []; + for (const [relativePath, merged] of [...byPath.entries()].sort(([left], [right]) => left.localeCompare(right))) { + if (merged.conflicting.length > 0) { + diagnostics.push(collisionDiagnostic(relativePath, sortedProjections([...merged.owners, ...merged.conflicting]))); + continue; + } + entries.push(merged.entry); + } + return Object.freeze({ diagnostics: Object.freeze(diagnostics), entries: sortedEntries(entries) }); +}; + +/** + * The host-scopable component kinds a host discovers by scanning a + * conventional directory of the plugin root rather than by following a + * manifest pointer. Every built-in host that declares one of these + * directories in its artifact layout reads it that way (`commands/` for + * Claude Code and Cursor, `rules/` for Cursor), so inside one composite root + * a component scoped to fewer hosts than share the directory is discovered by + * a host it was not declared for. Skills are discovered the same way + * (`skills/` for every host) but are never host-scoped: normalization gives + * every skill every selected target, and a per-host frontmatter extension + * changes the lowered bytes instead, which the merge reports as `AB4103`. + */ +type ConventionalKind = Extract; + +const conventionalKinds: readonly ConventionalKind[] = Object.freeze(['command', 'rule']); + +const kindLabel: Readonly> = Object.freeze({ + command: 'Command', + rule: 'Rule', +}); + +const conventionalDirectory = (registry: TargetRegistry, host: string, kind: ConventionalKind): string | undefined => { + const layout = registry.artifactLayout(host); + switch (kind) { + case 'command': + return layout.commands?.directory; + case 'rule': + return layout.rules?.directory; + default: { + const exhaustive: never = kind; + throw new TypeError(`Unknown conventional component kind ${String(exhaustive)}.`); + } + } +}; + +interface ScopedComponent { + readonly kind: ConventionalKind; + readonly name: string; + readonly sourcePath: string; + readonly targets: readonly string[]; +} + +const scopedComponents = (model: NormalizedPlugin): readonly ScopedComponent[] => [ + ...(model.commands ?? []).map((command): ScopedComponent => ({ + kind: 'command', name: command.name, sourcePath: command.provenance.sourcePath, targets: command.targets, + })), + ...(model.rules ?? []).map((rule): ScopedComponent => ({ + kind: 'rule', name: rule.name, sourcePath: rule.provenance.sourcePath, targets: rule.targets, + })), +]; + +/** + * AB4105 (#555, decision D5): a host-scoped component that another selected + * host would discover conventionally cannot be isolated inside one composite + * root, so the build refuses rather than leaking it. A host "reads" a kind + * when its adapter both hosts the component kind and declares the directory + * the emitting host writes it to. Per-host duplicated views are a later step. + */ +const scopeLeakDiagnostics = ( + model: NormalizedPlugin, + registry: TargetRegistry, + selected: readonly string[], +): readonly Diagnostic[] => { + const readers = new Map( + conventionalKinds.map((kind) => { + const capability = componentKindCapabilityName(kind); + return [kind, selected.flatMap((host) => { + const directory = conventionalDirectory(registry, host, kind); + return directory !== undefined && capability !== undefined && registry.hostsComponent(host, capability) + ? [{ directory, host }] + : []; + })]; + }), + ); + return scopedComponents(model).flatMap((component): Diagnostic[] => { + const kindReaders = readers.get(component.kind) ?? []; + const emitters = kindReaders.filter((reader) => component.targets.includes(reader.host)); + if (emitters.length === 0) return []; + const directories = new Set(emitters.map((emitter) => emitter.directory)); + const leakedTo = kindReaders + .filter((reader) => !component.targets.includes(reader.host) && directories.has(reader.directory)) + .map((reader) => reader.host) + .sort((left, right) => left.localeCompare(right)); + if (leakedTo.length === 0) return []; + const directory = [...directories].sort((left, right) => left.localeCompare(right)).join(', '); + const scoped = [...component.targets].filter((target) => selected.includes(target)).sort((left, right) => left.localeCompare(right)); + return [{ + code: 'AB4105', + message: `${kindLabel[component.kind]} ${JSON.stringify(component.name)} is scoped to ${scoped.map((target) => JSON.stringify(target)).join(', ')} but ${leakedTo.map((host) => JSON.stringify(host)).join(', ')} also discover ${JSON.stringify(`${directory}/`)} conventionally in the composite root; a host-scoped component cannot be isolated there.`, + recovery: 'Extend the component `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts (one `targets` entry per build).', + severity: 'error', + sourcePath: component.sourcePath, + }]; + }); +}; + +const compositeNoticeDelivery = ( + registry: TargetRegistry, + selected: readonly string[], +): NoticeDeliveryAdvertisement | undefined => { + const advertisements = selected.map((host) => registry.noticeDelivery(host)); + if (advertisements.some((advertisement) => advertisement === undefined)) return undefined; + const [first, ...rest] = advertisements as readonly NoticeDeliveryAdvertisement[]; + return first === undefined ? undefined : rest.reduce(intersectNoticeDeliveryAdvertisements, first); +}; + +/** A composite plan beside every diagnostic its planning raised, fatal or not. */ +export interface CompositePlanning { + readonly diagnostics: readonly Diagnostic[]; + readonly plan: CompositePlan; +} + +/** + * Plans the selected host projections into one composite root (#555) without + * judging the outcome. Only the planners of the selected hosts run, each over + * the whole model; a declaration reaches the root when its target set + * intersects the selection while every emitted host document keeps its own + * per-host scoping. Projection, collision (`AB4103`), and scope-leak + * (`AB4105`) diagnostics are returned beside the plan so `validate` and + * `inspect` report exactly what `build` would refuse. + */ +export const planComposite = (model: NormalizedPlugin, registry: TargetRegistry): CompositePlanning => { + const selected = sortedProjections(model.targets.map((target) => target.name)); + const diagnostics: Diagnostic[] = []; + const projections = selected.map((name): ComposedProjection => { + const plan = registry.get(name).plan(model); + diagnostics.push(...plan.diagnostics); + for (const hookEntry of plan.hookEntries ?? []) { + if (hookEntry.target !== name) { + diagnostics.push({ + code: 'AB5000', + message: `Target adapter ${JSON.stringify(name)} planned hook ${JSON.stringify(hookEntry.hook.id)} for target ${JSON.stringify(hookEntry.target)}, expected ${JSON.stringify(name)}.`, + severity: 'error', + target: name, + }); + } + } + const cliBin = targetHostsCliBin(registry, name); + if (cliBin) diagnostics.push(...cliBinCollisionDiagnostics(model, name, plan.entries)); + return Object.freeze({ cliBin, name, plan }); + }); + const merged = mergeEntries([ + ...projections.map((projection) => ({ entries: projection.plan.entries, owner: projection.name })), + { entries: installSurfaceEntries(model, registry.builtInHosts(selected)), owner: 'install surface' }, + ]); + diagnostics.push(...merged.diagnostics, ...scopeLeakDiagnostics(model, registry, selected)); + return Object.freeze({ + diagnostics: Object.freeze(deduplicateDiagnostics(diagnostics)), + plan: Object.freeze({ + cliBin: projections.some((projection) => projection.cliBin), + entries: merged.entries, + hookEntries: Object.freeze(projections.flatMap((projection) => projection.plan.hookEntries ?? [])), + identity: projectionIdentity(selected), + noticeDelivery: compositeNoticeDelivery(registry, selected), + projections: Object.freeze(projections), + selected, + }), + }); +}; + +/** + * The composite plan a build stages: `planComposite` judged, throwing a + * `DiagnosticError` when any projection, collision (`AB4103`), or scope leak + * (`AB4105`) is fatal. + */ +export const composeProjections = (model: NormalizedPlugin, registry: TargetRegistry): CompositePlan => { + const planning = planComposite(model, registry); + new DiagnosticBag(planning.diagnostics).throwIfErrors(); + return planning.plan; +}; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 937e2f048..6cbe3277b 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -303,13 +303,59 @@ const localMcpOutputName = (server: NormalizedMcpServer): string => { return match[1]; }; +/** The selected hosts whose MCP documents list `server` — the hosts that can launch it — in selection order. */ +export const selectedServerHosts = (server: NormalizedMcpServer, selected: readonly string[]): readonly string[] => + selected.filter((target) => server.targets.includes(target)); + +/** + * Where a composite root hosts its shared event runtime (#555). Each selected + * host reaches the runtime through the first generated-route server its MCP + * document lists — the rule every per-host artifact applied before the roots + * merged — so a root whose hosts launch different servers hosts the runtime + * in each of them, and a host that lists no generated-route server is not + * served (`AB4817` refuses that for a route without standalone fallback). The + * endpoint is the artifact's alone (#592) and whichever hosting process owns + * it answers every host's wrappers, so every hosting server accepts the same + * set: the selected hosts that list a hosting server. The build bakes this + * into the entries and `inspect --bundler` describes the same hosting. + */ +export interface EventRuntimeHosting { + /** The selected hosts whose hook wrappers may deliver events, in selection order. */ + readonly allowedTargets: readonly string[]; + /** The ids of the generated-route servers that host the runtime. */ + readonly serverIds: ReadonlySet; +} + +export const eventRuntimeHosting = ( + servers: readonly NormalizedMcpServer[], + selected: readonly string[], +): EventRuntimeHosting => { + const generated = servers.filter((server) => server.source !== undefined && server.generatedRoutes !== undefined); + const hostedBy = new Map(); + for (const host of selected) { + const server = generated.find((candidate) => candidate.targets.includes(host)); + if (server !== undefined) hostedBy.set(host, server.id); + } + return Object.freeze({ + allowedTargets: Object.freeze([...hostedBy.keys()]), + serverIds: new Set(hostedBy.values()), + }); +}; + +/** + * The MCP entries of one artifact root: every server that reaches one of the + * selected hosts (`targets`), compiled once. `target` is the composite + * identity the compiled surface is attributed to in build reports and + * `inspect --bundler`; it names no host and never reaches the generated code. + */ export const planCompiledMcpEntries = ( servers: readonly NormalizedMcpServer[], - options: { readonly outDir: string; readonly target: string }, + options: { readonly outDir: string; readonly target: string; readonly targets: readonly string[] }, ): readonly CompiledMcpEntry[] => { const names = new Set(); + const selected = options.targets; return Object.freeze(servers - .filter((server) => server.source !== undefined && server.targets.includes(options.target)) + .filter((server) => server.source !== undefined && server.targets.some((target) => selected.includes(target))) .map((server) => { const outputName = localMcpOutputName(server); const name = outputName.slice(0, -extname(outputName).length); @@ -359,11 +405,13 @@ export const planMcpEntriesSurface = async ( readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; readonly target: string; + /** The selected hosts of the composite root; a server reaching any of them is compiled and may receive their events. */ + readonly targets: readonly string[]; }, ): Promise> => { const compiled = planCompiledMcpEntries(servers, options); - const eventHostId = compiled.find((entry) => - servers.find((server) => server.id === entry.id)?.generatedRoutes !== undefined)?.id; + const hosting = eventRuntimeHosting(servers, options.targets); + const hostsRuntime = (id: string): boolean => hosting.serverIds.has(id); const virtualSources = await Promise.all(compiled.map(async (entry) => { const records = await Promise.all((options.apps ?? []) .filter((app) => app.serverIds.includes(entry.id)) @@ -388,14 +436,15 @@ export const planMcpEntriesSurface = async ( ? undefined : generatedRouteMcpEntrySource({ artifactEpoch: options.artifactEpoch, - eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + eventRoutes: hostsRuntime(entry.id) ? options.eventHooks : [], ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), plugin: options.plugin, routes: server.generatedRoutes, serverName: server.name, ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), ...(options.state === undefined ? {} : { state: options.state }), - target: options.target, + allowedTargets: hostsRuntime(entry.id) ? hosting.allowedTargets : [], + hosts: selectedServerHosts(server, options.targets), workerFile: `${entry.name}-flight.mjs`, }); }); @@ -405,7 +454,7 @@ export const planMcpEntriesSurface = async ( ? undefined : generatedRouteFlightWorkerSource({ artifactEpoch: generatedRouteArtifactEpoch(options.plugin), - eventRoutes: entry.id === eventHostId ? options.eventHooks : [], + eventRoutes: hostsRuntime(entry.id) ? options.eventHooks : [], layouts: options.layouts ?? [], ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), providers: options.providers ?? [], @@ -449,7 +498,7 @@ export const planMcpEntriesSurface = async ( ? {} : { [mcpEntryRuntimeSpecifier]: runtimeShell, - ...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined + ...(!hostsRuntime(id) || eventIpcRuntime === undefined || eventProjectRuntime === undefined ? {} : { [eventIpcRuntimeSpecifier]: eventIpcRuntime, diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index b4ab4899c..9e59e3708 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -732,7 +732,22 @@ export interface GeneratedRouteMcpEntryOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - readonly target?: string; + /** + * The hosts whose hook wrappers may deliver events to this entry: the + * selected hosts of the composite root that the server targets (#555). + * The entry's event endpoint is identified by the artifact alone — epoch + * and root directory — and the invoking host arrives with each request and + * is checked against this set; `targets` select projections, they are not + * runtime identity (#592). Absent means no host may deliver events. + */ + readonly allowedTargets?: readonly string[]; + /** + * The selected hosts whose MCP documents list this server — the hosts that + * can launch it. When exactly one can, the runtime assumes that host for + * tool-call lineage when the MCP client does not name itself; otherwise the + * client's own name alone decides (#592). Absent means none. + */ + readonly hosts?: readonly string[]; readonly workerFile: string; } @@ -1091,10 +1106,8 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti assertRegistrableMcpRoutes(routes, options.state !== undefined); const artifactEpoch = generatedRouteArtifactEpoch(options.plugin); const hasEvents = (options.eventRoutes?.length ?? 0) > 0; - const eventTarget = options.target ?? 'unknown'; - const allowedEventTargets = eventTarget === 'plugin' - ? ['claude', 'codex', 'cursor'] - : [eventTarget]; + const allowedEventTargets = [...(options.allowedTargets ?? [])].sort((left, right) => left.localeCompare(right)); + const eventHosts = [...(options.hosts ?? [])].sort((left, right) => left.localeCompare(right)); const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already @@ -1155,18 +1168,20 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ...(hasEvents ? [ // The endpoint identity is artifact-location dependent, so it stays - // in the artifact rather than the shared runtime. + // in the artifact rather than the shared runtime: the artifact epoch + // and the root directory (the entry lives in `mcp/`), never the + // projection selection — the hook wrapper derives the same id (#592). `const EVENT_ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch ?? 'unknown')};`, - `const EVENT_TARGET = ${JSON.stringify(eventTarget)};`, `const EVENT_ALLOWED_TARGETS = Object.freeze(${JSON.stringify(allowedEventTargets)});`, + `const EVENT_HOSTS = Object.freeze(${JSON.stringify(eventHosts)});`, 'const events = Object.freeze({', ' allowedTargets: EVENT_ALLOWED_TARGETS,', ' artifactEpoch: EVENT_ARTIFACT_EPOCH,', ' createCanonicalEventProps,', ' createEventRuntimeServer,', - ' endpointId: `${EVENT_ARTIFACT_EPOCH}:${EVENT_TARGET}:${dirname(dirname(resolve(process.argv[1])))}`,', + ' endpointId: `${EVENT_ARTIFACT_EPOCH}:${dirname(dirname(resolve(process.argv[1])))}`,', + ' hosts: EVENT_HOSTS,', ' projectEventDocument,', - ' target: EVENT_TARGET,', '});', '', ] diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 4225f69f9..f794e2182 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,5 +1,4 @@ import { hookWrapperAppliesOperatorEnv } from '../adapters/hook-contract.ts'; -import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import type { TargetHookEntry } from '../adapters/types.ts'; import { isPlainRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; @@ -21,7 +20,8 @@ import { } from './entry-shell.ts'; import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; -import { planCompiledMcpEntries } from './entries.ts'; +import type { CompositePlan } from './compose.ts'; +import { eventRuntimeHosting, planCompiledMcpEntries, selectedServerHosts } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; import { planPackageEntries } from './package-build.ts'; @@ -40,9 +40,9 @@ import type { AgentBundleMeta } from '../meta.ts'; * what actually compiles. * * Two build-time-only values are replaced with stable tokens so the output - * is deterministic for one project: the artifact output root (chosen by - * `build --output` and staged per build) appears as `/`, - * and the synthesized declaration tsconfig (a temporary file the package + * is deterministic for one project: the composite artifact root (chosen by + * `build --output` and staged per build) appears as ``, and the + * synthesized declaration tsconfig (a temporary file the package * build generates under `node_modules`) appears as * ``. Nothing else is redacted; this is a local * debugging surface. The generated-module namespace @@ -58,10 +58,11 @@ export interface BundlerInspectionEntry { readonly generatedEntry?: string; readonly kind: 'bin' | 'hook' | 'lib' | 'mcp-apps' | 'mcp-entry' | 'script'; readonly name: string; - /** POSIX output path relative to the artifact root (targets) or project root (package build). */ + /** POSIX output path relative to the artifact root (artifact surfaces) or project root (package build). */ readonly outputPath: string; - /** The authored entry module (absent for the per-target MCP Apps config). */ + /** The authored entry module (absent for the MCP Apps config). */ readonly source?: string; + /** The composite identity of the selected projections; absent for package-build entries. */ readonly target?: string; } @@ -71,7 +72,7 @@ export interface BundlerInspection { export const generatedDtsTsconfigToken = ''; -const artifactOutputToken = (target: string): string => `/${target}`; +const artifactOutputToken = ''; const isPlainObject: (value: object) => boolean = isPlainRecord; @@ -134,13 +135,14 @@ const rslibInspectionEntry = (options: { const scriptEntries = async ( model: NormalizedPlugin, projectRoot: string, - target: string, + composite: CompositeSelection, tools: AgentBundleToolsConfig | undefined, ): Promise => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = composite.identity; const scripts = model.scripts.filter((script) => - script.mode === 'bundle' && script.targets.includes(target)); + script.mode === 'bundle' && script.targets.some((candidate) => composite.selected.includes(candidate))); return Promise.all(scripts.map(async (script) => { const exports = await scanEntryExports(script.source); return rslibInspectionEntry({ @@ -163,7 +165,7 @@ const scriptEntries = async ( kind: 'script', meta, name: script.name, - outputPath: `${target}/scripts/${script.name}.mjs`, + outputPath: `scripts/${script.name}.mjs`, outputRoot, projectRoot, source: script.source, @@ -173,7 +175,7 @@ const scriptEntries = async ( })); }; -/** The artifact-hosted routed CLI bins of one target (#387), composed by the build's own planner. */ +/** The artifact-hosted routed CLI bins of the composite root (#387), composed by the build's own planner. */ const cliBinEntries = ( model: NormalizedPlugin, projectRoot: string, @@ -181,14 +183,14 @@ const cliBinEntries = ( tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; const planned = planCompiledCliBins(model, { outDir: outputRoot, target }); return cliBinRslibEntries(planned, model).map((entry) => rslibInspectionEntry({ entry, kind: 'bin', meta, name: entry.name.replace(/^bin-/u, ''), - outputPath: `${target}/${entry.outputRelativePath}`, + outputPath: entry.outputRelativePath, outputRoot, projectRoot, source: entry.source, @@ -200,13 +202,15 @@ const cliBinEntries = ( const mcpEntryEntries = async ( model: NormalizedPlugin, projectRoot: string, - target: string, + composite: CompositeSelection, tools: AgentBundleToolsConfig | undefined, - noticeDelivery: NoticeDeliveryAdvertisement | undefined, ): Promise => { const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken(target); - const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target }); + const outputRoot = artifactOutputToken; + const target = composite.identity; + const noticeDelivery = composite.noticeDelivery; + const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target, targets: composite.selected }); + const hosting = eventRuntimeHosting(model.mcpServers, composite.selected); const entries: BundlerInspectionEntry[] = []; for (const entry of planned) { const server = model.mcpServers.find((candidate) => candidate.id === entry.id); @@ -214,9 +218,11 @@ const mcpEntryEntries = async ( const generatedRoutes = server?.generatedRoutes; const wrapped = generatedRoutes !== undefined || (await scanEntryExports(entry.source)).hasDefaultExport; const workerFile = `${entry.name}-flight.mjs`; - const routeSource = generatedRoutes === undefined + const routeSource = generatedRoutes === undefined || server === undefined ? undefined : generatedRouteMcpEntrySource({ + allowedTargets: hosting.serverIds.has(server.id) ? hosting.allowedTargets : [], + hosts: selectedServerHosts(server, composite.selected), ...(noticeDelivery === undefined ? {} : { noticeDelivery }), ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), plugin: { name: model.metadata.name, version: model.metadata.version }, @@ -263,7 +269,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 +299,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 +318,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 +332,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 +344,13 @@ const hookEntries = ( const mcpAppsEntry = ( model: NormalizedPlugin, projectRoot: string, - target: string, + composite: CompositeSelection, tools: AgentBundleToolsConfig | undefined, ): readonly BundlerInspectionEntry[] => { - const outputRoot = artifactOutputToken(target); + const outputRoot = artifactOutputToken; + const target = composite.identity; const apps = model.mcpApps ?? []; - const planned = planCompiledMcpApps(apps, { outDir: outputRoot, target }); + const planned = planCompiledMcpApps(apps, { outDir: outputRoot, selected: composite.selected, target }); if (planned.length === 0) return []; const sources = planned.map((app) => { const source = apps.find((candidate) => candidate.id === app.id); @@ -362,7 +369,7 @@ const mcpAppsEntry = ( })), kind: 'mcp-apps' as const, name: 'mcp-apps', - outputPath: `${target}/mcp-apps`, + outputPath: 'mcp-apps', target, })]; }; @@ -398,31 +405,26 @@ const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry) left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name); +/** The composite root's selection, as the build planned it (#555). */ +export type CompositeSelection = Pick; + export const composeBundlerInspection = async (options: { + readonly composite: CompositeSelection; readonly model: NormalizedPlugin; /** The project root: the bundler `context` and the root of the generated-module namespace. */ readonly projectRoot: string; - readonly targets: readonly { - /** True when the target hosts the routed CLI bin (its adapter publishes the `cli` capability). */ - readonly cliBin?: boolean; - readonly hookEntries: readonly TargetHookEntry[]; - readonly name: string; - readonly noticeDelivery?: NoticeDeliveryAdvertisement; - }[]; readonly tools?: AgentBundleToolsConfig; }): Promise => { - const entries: BundlerInspectionEntry[] = []; - const meta = projectMeta(options.model.metadata); - for (const target of options.targets) { - entries.push( - ...(target.cliBin === true ? cliBinEntries(options.model, options.projectRoot, target.name, options.tools) : []), - ...(await scriptEntries(options.model, options.projectRoot, target.name, options.tools)), - ...(await mcpEntryEntries(options.model, options.projectRoot, target.name, options.tools, target.noticeDelivery)), - ...hookEntries(target.hookEntries, meta, options.projectRoot, target.name, options.tools), - ...mcpAppsEntry(options.model, options.projectRoot, target.name, options.tools), - ); - } - entries.push(...(await packageBuildEntries(options.model, options.projectRoot, options.tools))); + const { composite, model, projectRoot, tools } = options; + const meta = projectMeta(model.metadata); + const entries: BundlerInspectionEntry[] = [ + ...(composite.cliBin ? cliBinEntries(model, projectRoot, composite.identity, tools) : []), + ...(await scriptEntries(model, projectRoot, composite, tools)), + ...(await mcpEntryEntries(model, projectRoot, composite, tools)), + ...hookEntries(composite.hookEntries, meta, projectRoot, composite.identity, tools), + ...mcpAppsEntry(model, projectRoot, composite, tools), + ...(await packageBuildEntries(model, projectRoot, tools)), + ]; return deepFreeze({ entries: entries.sort(entryOrder), }); diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index b42140c84..8062bc033 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -266,26 +266,23 @@ const appIdentity = (app: NormalizedMcpApp): string => stableJson({ ...(app.template === undefined ? {} : { template: app.template }), }); -export type McpAppTargetSelection = - | Readonly<{ readonly target: string; readonly targets?: never }> - | Readonly<{ readonly target?: never; readonly targets: Readonly> }>; - -const selectedAppTarget = ( - app: NormalizedMcpApp, - selection: McpAppTargetSelection, -): string | undefined => { - const target = selection.target ?? selection.targets[app.id]; - return target !== undefined && app.targets.includes(target) ? target : undefined; -}; +/** + * The projections one composite root compiles apps for (#555): an app is + * compiled once when its target set reaches any selected host, and the + * compiled surface is attributed to the selection's identity (`target`). + */ +export interface McpAppSelection { + readonly selected: readonly string[]; + readonly target: string; +} export const planCompiledMcpApps = ( apps: readonly NormalizedMcpApp[], - options: Readonly<{ readonly outDir: string } & McpAppTargetSelection>, + options: Readonly<{ readonly outDir: string } & McpAppSelection>, ): readonly PlannedMcpApp[] => { - const planned = new Map(); + const planned = new Map(); for (const app of apps) { - const target = selectedAppTarget(app, options); - if (app.prebuilt === true || target === undefined) continue; + if (app.prebuilt === true || !app.targets.some((target) => options.selected.includes(target))) continue; const identity = appIdentity(app); const existing = planned.get(app.name); if (existing !== undefined) { @@ -298,9 +295,9 @@ export const planCompiledMcpApps = ( if (!existing.serverIds.includes(app.serverId)) existing.serverIds.push(app.serverId); continue; } - planned.set(app.name, { app, identity, serverIds: [app.serverId], target }); + planned.set(app.name, { app, identity, serverIds: [app.serverId] }); } - return Object.freeze([...planned.values()].map(({ app, serverIds, target }) => Object.freeze({ + return Object.freeze([...planned.values()].map(({ app, serverIds }) => Object.freeze({ ...(app._meta === undefined ? {} : { _meta: app._meta }), id: app.id, mimeType: mcpAppMimeType, @@ -314,7 +311,7 @@ export const planCompiledMcpApps = ( app.source, ...(app.template === undefined ? [] : [app.template]), ]), - target, + target: options.target, }))); }; @@ -423,12 +420,9 @@ export const compileMcpApps = async ( readonly mode?: McpAppCompileMode; readonly outDir: string; readonly tools?: AgentBundleToolsConfig; - } & McpAppTargetSelection>, + } & McpAppSelection>, ): Promise => { - const compiled = planCompiledMcpApps(apps, { - outDir: options.outDir, - ...(options.target === undefined ? { targets: options.targets } : { target: options.target }), - }); + const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, selected: options.selected, target: options.target }); if (compiled.length === 0) { return Object.freeze({ apps: Object.freeze([]), diagnostics: Object.freeze([]) }); } diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 40b8bf39d..b358ac88a 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -8,7 +8,6 @@ import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isRecord } from '../core/strict-json.ts'; import { readFileBytes, readFileString, runWithPlatform } from '../effect/platform.ts'; -import { installSurfaceRequirements } from '../install/surface.ts'; import { artifactManifestName } from './emit.ts'; import { parseArtifactManifest } from './manifest.ts'; import { @@ -112,12 +111,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: @@ -272,9 +265,10 @@ export const packInventoryDiagnostics = async (options: { const expected = new Set([ ...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`), `${artifactPrefix}/${artifactManifestName}`, + // Every emitted file is manifested, the install surface included: the + // artifact validator (`AB6023`/`AB6024`) already judged its presence by + // adapter identity, so the pack expects exactly what the manifest lists. ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), - ...manifest.targets.flatMap((target) => - installSurfaceRequirements(target.name).map((path) => `${artifactPrefix}/${target.name}/${path}`)), 'README.md', ]); @@ -322,9 +316,9 @@ export const packInventoryDiagnostics = async (options: { ]; for (const target of manifest.targets) { for (const path of hostManifestPaths(target.name)) { - const absolute = join(artifactRoot, target.name, path); + const absolute = join(artifactRoot, path); if (await exists(absolute)) { - versions.push([`${target.name}/${path}`, (await jsonRecord(absolute)).version]); + versions.push([path, (await jsonRecord(absolute)).version]); } } } diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 15f0f1f03..6ee8ab68d 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -196,7 +196,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..61e951114 100644 --- a/packages/agent-bundle/src/build/validate-artifact-hooks.ts +++ b/packages/agent-bundle/src/build/validate-artifact-hooks.ts @@ -9,7 +9,7 @@ import { import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; -import { matchesManifestFile, pathInTargetOutputLayout, targetArtifactPath } from './artifact-layout.ts'; +import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; import { artifactHookIndexName, type ArtifactFile, @@ -70,13 +70,11 @@ export const validateHookCoherence = async (options: { if (!options.registry.has(hook.target)) continue; const contract = options.registry.hookContract(hook.target); const layout = options.registry.artifactLayout(hook.target).hookWrappers; - const expectedPrefix = `${hook.target}/`; const file = files.get(hook.path); const manifestFile = manifestFiles.get(hook.path); if ( contract === undefined || - !hook.path.startsWith(expectedPrefix) || - !pathInTargetOutputLayout(hook.path, hook.target, layout) || + !isDirectOutputLayoutPath(hook.path, layout) || file === undefined || manifestFile === undefined || !matchesManifestFile(file, manifestFile) @@ -95,7 +93,9 @@ export const validateHookCoherence = async (options: { const contract = options.registry.hookContract(target); if (contract === undefined) continue; const hooks = indexedByTarget.get(target) ?? []; - const manifestPath = targetArtifactPath(target, contract.manifestPath); + // Every selected host's document lives at its contract path inside the + // one composite root; the wrappers it names are the host's own (#555). + const manifestPath = contract.manifestPath; if (!files.has(manifestPath)) { if (hooks.length === 0) continue; diagnostics.push(diagnostic( @@ -130,7 +130,7 @@ export const validateHookCoherence = async (options: { } const relativePaths = new Map(); for (const hook of hooks) { - const relativePath = hook.path.slice(target.length + 1); + const relativePath = hook.path; relativePaths.set(relativePath, (relativePaths.get(relativePath) ?? 0) + 1); const command = generatedHookCommand(contract, relativePath); const occurrences = commands.commands.filter((candidate) => candidate.command === command).length; @@ -151,7 +151,7 @@ export const validateHookCoherence = async (options: { // command without arguments parses like a wrapper command but points // into its payload directory, outside the wrapper layout, and is // deliberately absent from the hook index (like native hooks). - if (!pathInTargetOutputLayout(targetArtifactPath(target, relativePath), target, wrapperLayout)) continue; + if (!isDirectOutputLayoutPath(relativePath, wrapperLayout)) continue; const entries = relativePaths.get(relativePath) ?? 0; if (entries === 1) continue; diagnostics.push(diagnostic( diff --git a/packages/agent-bundle/src/build/validate-artifact-logo.ts b/packages/agent-bundle/src/build/validate-artifact-logo.ts index 595fc6e87..a58dde991 100644 --- a/packages/agent-bundle/src/build/validate-artifact-logo.ts +++ b/packages/agent-bundle/src/build/validate-artifact-logo.ts @@ -3,7 +3,6 @@ import { posix } from 'node:path'; import { isContainedRelativePath, safeArtifactPath } from '../core/paths.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; -import { targetArtifactPath } from './artifact-layout.ts'; const isRemoteLogoReference = (value: string): boolean => { try { @@ -30,7 +29,8 @@ export const manifestLogoPathDiagnostics = (options: { options.target, )]); } - const artifactPath = targetArtifactPath(options.target, relativePath); + // The manifest's logo path is plugin-root relative, and the plugin root is the artifact root. + const artifactPath = relativePath; if (options.files.has(artifactPath)) return Object.freeze([]); return Object.freeze([diagnostic( 'AB6025', diff --git a/packages/agent-bundle/src/build/validate-artifact-mcp.ts b/packages/agent-bundle/src/build/validate-artifact-mcp.ts index e9c7c1266..0d02f20e4 100644 --- a/packages/agent-bundle/src/build/validate-artifact-mcp.ts +++ b/packages/agent-bundle/src/build/validate-artifact-mcp.ts @@ -8,7 +8,7 @@ import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts'; import { readTargetMcpServers } from '../services/mcp-runtime.ts'; import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { matchesManifestFile, pathInTargetOutputLayout, targetArtifactPath } from './artifact-layout.ts'; +import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts'; import type { ArtifactFile, ManifestFile } from './emit.ts'; import type { ArtifactManifest } from './manifest.ts'; @@ -82,7 +82,7 @@ const validateMcpArtifactReference = (options: { )]); } - const path = targetArtifactPath(options.target, reference.path); + const path = reference.path; const file = options.files.get(path); const manifestFile = options.manifestFiles.get(path); const diagnostics: Diagnostic[] = []; @@ -113,6 +113,13 @@ const validateMcpArtifactReference = (options: { return Object.freeze(diagnostics); }; +/** + * Every selected host's MCP document lives in the one composite root and + * names the shared compiled entries (`mcp/.mjs`) the host's servers + * reach (#555). Within one document each entry is referenced once; across the + * selection every compiled entry is referenced by at least one document, since + * an entry only exists because a selected host's server declared it. + */ export const validateMcpCoherence = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; @@ -124,17 +131,23 @@ export const validateMcpCoherence = async (options: { const files = new Map(options.files.map((file) => [file.path, file])); const manifestFiles = new Map(options.manifest.files.map((file) => [file.path, file])); const artifactRoot = resolve(options.artifactRoot); + // The plugin root every host installs is the artifact root itself. + const targetRoot = artifactRoot; + const compiledEntries = new Set(); + const referencedAnywhere = new Set(); for (const target of options.manifest.targets) { if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue; const runtime = options.registry.mcpRuntime(target.name); if (runtime === undefined) continue; - const manifestPath = targetArtifactPath(target.name, runtime.manifestPath); - const targetRoot = resolve(artifactRoot, target.name); + const manifestPath = runtime.manifestPath; const mcpLayout = options.registry.artifactLayout(target.name).mcpEntries; const referenceCounts = new Map(); - const mcpEntries = options.files.filter((file) => pathInTargetOutputLayout(file.path, target.name, mcpLayout)); - for (const file of mcpEntries) referenceCounts.set(file.path, []); + const mcpEntries = options.files.filter((file) => isDirectOutputLayoutPath(file.path, mcpLayout)); + for (const file of mcpEntries) { + referenceCounts.set(file.path, []); + compiledEntries.add(file.path); + } const manifestFile = files.get(manifestPath); if (manifestFile !== undefined) { @@ -233,9 +246,9 @@ export const validateMcpCoherence = async (options: { value: server.command, }); if (commandReference.status === 'artifact-local') { - const path = targetArtifactPath(target.name, commandReference.path); - recordMcpReference(referenceCounts, path, { field: 'command', server: entry.name }); - entryPaths.add(path); + recordMcpReference(referenceCounts, commandReference.path, { field: 'command', server: entry.name }); + referencedAnywhere.add(commandReference.path); + entryPaths.add(commandReference.path); } } @@ -257,9 +270,9 @@ export const validateMcpCoherence = async (options: { value: argument, }); if (argumentReference.status === 'artifact-local') { - const path = targetArtifactPath(target.name, argumentReference.path); - recordMcpReference(referenceCounts, path, { field: 'argument', server: entry.name }); - entryPaths.add(path); + recordMcpReference(referenceCounts, argumentReference.path, { field: 'argument', server: entry.name }); + referencedAnywhere.add(argumentReference.path); + entryPaths.add(argumentReference.path); } } options.mcpServers.push(Object.freeze({ @@ -275,24 +288,30 @@ export const validateMcpCoherence = async (options: { } for (const [path, occurrences] of referenceCounts) { - if (occurrences.length === 1) continue; - if (occurrences.length === 0 && path.endsWith('-flight.mjs')) { - const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs'; - const mainReferences = referenceCounts.get(mainPath); - if (mainReferences?.length === 1) { - const mainSource = await runWithPlatform(readFileString(resolve(artifactRoot, mainPath))); - if (mainSource.includes(`./${posix.basename(path)}`)) continue; - } - } + if (occurrences.length <= 1) continue; diagnostics.push(diagnostic( 'AB6017', - occurrences.length === 0 - ? `Compiler MCP entry ${JSON.stringify(path)} is not referenced by a server in target ${JSON.stringify(target.name)}.` - : `Compiler MCP entry ${JSON.stringify(path)} is referenced ${occurrences.length} times in target ${JSON.stringify(target.name)}.`, + `Compiler MCP entry ${JSON.stringify(path)} is referenced ${occurrences.length} times in target ${JSON.stringify(target.name)}.`, path, target.name, )); } } + + for (const path of [...compiledEntries].sort((left, right) => left.localeCompare(right))) { + if (referencedAnywhere.has(path)) continue; + if (path.endsWith('-flight.mjs')) { + const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs'; + if (referencedAnywhere.has(mainPath)) { + const mainSource = await runWithPlatform(readFileString(resolve(artifactRoot, mainPath))); + if (mainSource.includes(`./${posix.basename(path)}`)) continue; + } + } + diagnostics.push(diagnostic( + 'AB6017', + `Compiler MCP entry ${JSON.stringify(path)} is not referenced by a server of any selected target.`, + path, + )); + } return Object.freeze(diagnostics); }; diff --git a/packages/agent-bundle/src/build/validate-artifact-skills.ts b/packages/agent-bundle/src/build/validate-artifact-skills.ts index 167615fa2..aeee29f7e 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -4,7 +4,7 @@ import type { TargetRegistry } from '../adapters/registry.ts'; import { parseSkillMarkdown, referencedResources } from '../config/skill-references.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts'; +import { validateAgentSkillsFrontmatter, type AgentSkillsFrontmatterIssue } from '../schemas/agent-skills/contract.ts'; import { validateClaudeSkillFrontmatter, validateCursorSkillFrontmatter, @@ -16,45 +16,58 @@ import { import type { ArtifactFile } from './emit.ts'; import type { ArtifactManifest } from './manifest.ts'; -export const targetNamespaces = (manifest: ArtifactManifest): ReadonlySet => - new Set(manifest.targets.map((target) => target.name)); - -export const pathTarget = (path: string, targets: ReadonlySet): string | undefined => { - const [target] = path.split('/'); - return target !== undefined && targets.has(target) ? target : undefined; +/** The selected host projections the composite root records. */ +export const manifestTargets = (manifest: ArtifactManifest): readonly string[] => + manifest.targets.map((target) => target.name); + +/** + * The skill directories the selected hosts read, each with the hosts that + * read it. Every built-in host emits `skills/`, so one emitted Skill is read + * by every selected host and must satisfy each host's frontmatter contract. + */ +const skillDirectories = ( + targets: readonly string[], + registry: TargetRegistry, +): ReadonlyMap => { + const directories = new Map(); + for (const target of targets) { + if (!registry.has(target)) continue; + const directory = registry.artifactLayout(target).skills; + if (directory === undefined) continue; + directories.set(directory, [...(directories.get(directory) ?? []), target]); + } + return directories; }; interface EmittedSkill { readonly name: string; readonly path: string; readonly root: string; - readonly target: string; + /** The selected hosts that read this Skill directory. */ + readonly targets: readonly string[]; } const emittedSkillFor = ( file: ArtifactFile, - targets: ReadonlySet, - registry: TargetRegistry, + directories: ReadonlyMap, ): EmittedSkill | undefined => { const segments = file.path.split('/'); - const [target, layout, name, document] = segments; - if (target === undefined || !targets.has(target) || !registry.has(target)) return undefined; - const skillLayout = registry.artifactLayout(target).skills; - if ( - layout !== skillLayout || - name === undefined || - document !== 'SKILL.md' || - segments.length !== 4 - ) { - return undefined; + const [layout, name, document] = segments; + if (layout === undefined || name === undefined || document !== 'SKILL.md' || segments.length !== 3) return undefined; + const targets = directories.get(layout); + if (targets === undefined) return undefined; + return { name, path: file.path, root: `${layout}/${name}`, targets }; +}; + +const frontmatterValidatorFor = (target: string): (frontmatter: unknown) => readonly AgentSkillsFrontmatterIssue[] => { + switch (target) { + case 'claude': + return validateClaudeSkillFrontmatter; + case 'cursor': + return validateCursorSkillFrontmatter; + default: + return validateAgentSkillsFrontmatter; } - if (skillLayout === undefined) return undefined; - return { - name, - path: file.path, - root: `${target}/${skillLayout}/${name}`, - target, - }; }; const isSkillRootEscape = (reference: string): boolean => @@ -69,42 +82,39 @@ export const validateEmittedSkills = async (options: { readonly registry: TargetRegistry; }): Promise => { const diagnostics: Diagnostic[] = []; - const targets = targetNamespaces(options.manifest); + const directories = skillDirectories(manifestTargets(options.manifest), options.registry); const skills = options.files - .map((file) => emittedSkillFor(file, targets, options.registry)) + .map((file) => emittedSkillFor(file, directories)) .filter((skill): skill is EmittedSkill => skill !== undefined); const skillsByRoot = new Map(skills.map((skill) => [skill.root, skill])); for (const file of options.files) { - if (!file.path.endsWith('/SKILL.md') || emittedSkillFor(file, targets, options.registry) !== undefined) continue; - const target = pathTarget(file.path, targets); + if (!file.path.endsWith('/SKILL.md') || emittedSkillFor(file, directories) !== undefined) continue; diagnostics.push(diagnostic( 'AB6015', `Emitted Skill document ${JSON.stringify(file.path)} does not use the canonical skills//SKILL.md layout.`, file.path, - target, + undefined, skillRecovery, )); } const resourceFilesBySkill = new Map(); for (const file of options.files) { - const [target, layout, name] = file.path.split('/'); - if (target === undefined || name === undefined || !targets.has(target) || !options.registry.has(target)) continue; - if (layout !== options.registry.artifactLayout(target).skills) continue; - const root = `${target}/${layout}/${name}`; + const [layout, name] = file.path.split('/'); + if (layout === undefined || name === undefined || !directories.has(layout)) continue; + const root = `${layout}/${name}`; const existing = resourceFilesBySkill.get(root) ?? []; resourceFilesBySkill.set(root, [...existing, file]); } for (const [root, files] of resourceFilesBySkill) { if (skillsByRoot.has(root)) continue; - const [target] = root.split('/'); diagnostics.push(diagnostic( 'AB6015', `Emitted Skill resource directory ${JSON.stringify(root)} is missing its SKILL.md document.`, files[0]?.path, - target, + undefined, skillRecovery, )); } @@ -118,7 +128,7 @@ export const validateEmittedSkills = async (options: { 'AB6015', 'Emitted Skill Markdown cannot be read.', skill.path, - skill.target, + undefined, skillRecovery, )); continue; @@ -130,7 +140,7 @@ export const validateEmittedSkills = async (options: { 'AB6015', 'Emitted Skill Markdown must start with YAML frontmatter.', skill.path, - skill.target, + undefined, skillRecovery, )); continue; @@ -140,33 +150,32 @@ export const validateEmittedSkills = async (options: { 'AB6015', `Emitted Skill YAML frontmatter is invalid: ${parsed.message}`, skill.path, - skill.target, + undefined, skillRecovery, )); continue; } - const frontmatterIssues = skill.target === 'claude' - ? validateClaudeSkillFrontmatter(parsed.frontmatter) - : skill.target === 'cursor' - ? validateCursorSkillFrontmatter(parsed.frontmatter) - : validateAgentSkillsFrontmatter(parsed.frontmatter); - for (const issue of frontmatterIssues) { - const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath); - diagnostics.push(diagnostic( - 'AB6015', - `Emitted Skill frontmatter ${location} ${issue.message}.`, - skill.path, - skill.target, - skillRecovery, - )); + // One emitted document, read by every selected host: each host's + // frontmatter contract judges it and names itself in the diagnostic. + for (const target of skill.targets) { + for (const issue of frontmatterValidatorFor(target)(parsed.frontmatter)) { + const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath); + diagnostics.push(diagnostic( + 'AB6015', + `Emitted Skill frontmatter ${location} ${issue.message}.`, + skill.path, + target, + skillRecovery, + )); + } } if (typeof parsed.frontmatter.name === 'string' && parsed.frontmatter.name !== skill.name) { diagnostics.push(diagnostic( 'AB6015', `Emitted Skill name ${JSON.stringify(parsed.frontmatter.name)} must match directory ${JSON.stringify(skill.name)}.`, skill.path, - skill.target, + undefined, skillRecovery, )); } @@ -175,7 +184,7 @@ export const validateEmittedSkills = async (options: { 'AB6034', 'Emitted Skill Markdown must contain instructions after its YAML frontmatter.', skill.path, - skill.target, + undefined, artifactDiagnosticRecoveries.AB6034, )); } @@ -189,7 +198,7 @@ export const validateEmittedSkills = async (options: { 'AB6016', `Emitted Skill reference ${JSON.stringify(reference)} escapes its Skill root.`, skill.path, - skill.target, + undefined, artifactDiagnosticRecoveries.AB6016, )); } else if (!resources.has(reference)) { @@ -197,7 +206,7 @@ export const validateEmittedSkills = async (options: { 'AB6016', `Emitted Skill references missing regular resource ${JSON.stringify(reference)}.`, skill.path, - skill.target, + undefined, artifactDiagnosticRecoveries.AB6016, )); } diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index d7ca0525e..483ee92bb 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -1,5 +1,5 @@ import { lstat, readFile } from 'node:fs/promises'; -import { dirname, posix, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { portableAdapter } from '../adapters/portable.ts'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; @@ -7,7 +7,6 @@ import type { TargetArtifactDocumentIssue, TargetArtifactDocumentValidator, } from '../adapters/types.ts'; -import { mcpEntryAliasPattern } from '../config/normalize.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { dataArrayValues, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts'; @@ -43,7 +42,7 @@ import { validateJavaScriptModules } from './validate-artifact-modules.ts'; import { validateHookCoherence } from './validate-artifact-hooks.ts'; import { manifestLogoPathDiagnostics } from './validate-artifact-logo.ts'; import { validateMcpCoherence } from './validate-artifact-mcp.ts'; -import { pathTarget, targetNamespaces, validateEmittedSkills } from './validate-artifact-skills.ts'; +import { manifestTargets, validateEmittedSkills } from './validate-artifact-skills.ts'; import { installSurfaceRequirements } from '../install/surface.ts'; export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './artifact-diagnostics.ts'; @@ -85,23 +84,6 @@ const changedArtifactPaths = ( .sort((left, right) => left.localeCompare(right)); }; -const localMcpArgument = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined; - const relative = value.replace(/^\.\//, ''); - return mcpEntryAliasPattern.test(relative) ? relative : undefined; -}; - -const localMcpPaths = (document: unknown): readonly string[] => { - if (!isRecord(document)) return []; - const servers = document.mcpServers; - if (!isRecord(servers)) return []; - return Object.values(servers).flatMap((server) => { - if (!isRecord(server)) return []; - const args = server.args; - return Array.isArray(args) ? [localMcpArgument(args[0])].filter((path): path is string => path !== undefined) : []; - }); -}; - const isEpochStagingMarker = (value: string): boolean => { try { const marker: unknown = JSON.parse(value); @@ -338,6 +320,11 @@ const matchesArtifactDocumentPath = (contractPath: string, relativePath: string) return matched.length > 0 && !matched.includes('/'); }; +/** + * Every selected projection's contract, checked against the one composite + * root (#555): the install surface is written once for the whole selection, + * and each host's documents live at their contract paths inside the root. + */ const validateTargetContracts = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; @@ -346,6 +333,16 @@ const validateTargetContracts = async (options: { }): Promise => { const diagnostics: Diagnostic[] = []; const files = new Set(options.files.map((file) => file.path)); + const selected = manifestTargets(options.manifest); + + for (const relativePath of installSurfaceRequirements(options.registry.builtInHosts(selected))) { + if (files.has(relativePath)) continue; + diagnostics.push(diagnostic( + relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', + `Artifact is missing required install surface ${JSON.stringify(relativePath)}.`, + relativePath, + )); + } for (const target of options.manifest.targets) { if (!options.registry.has(target.name)) { @@ -367,34 +364,20 @@ const validateTargetContracts = async (options: { continue; } - for (const relativePath of installSurfaceRequirements(target.name)) { - const generatedPath = `${target.name}/${relativePath}`; - if (files.has(generatedPath)) continue; - diagnostics.push(diagnostic( - relativePath === 'INSTALL.md' ? 'AB6023' : 'AB6024', - `Target ${JSON.stringify(target.name)} is missing required install surface ${JSON.stringify(relativePath)}.`, - generatedPath, - target.name, - )); - } - const validation = options.registry.artifactValidation(target.name); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { - const targetPrefix = `${target.name}/`; const generatedPaths = document.path.includes('*') ? [...files] - .filter((path) => - path.startsWith(targetPrefix) && - matchesArtifactDocumentPath(document.path, path.slice(targetPrefix.length))) + .filter((path) => matchesArtifactDocumentPath(document.path, path)) .sort((left, right) => left.localeCompare(right)) - : [`${targetPrefix}${document.path}`].filter((path) => files.has(path)); + : [document.path].filter((path) => files.has(path)); if (generatedPaths.length === 0) { if (document.required) { diagnostics.push(diagnostic( 'AB6011', `Target ${JSON.stringify(target.name)} is missing required document ${JSON.stringify(document.path)}.`, - `${targetPrefix}${document.path}`, + document.path, target.name, )); } @@ -412,10 +395,9 @@ const validateTargetContracts = async (options: { const issues = validateSchemaDocument(validate, parsed); const issue = issues[0]; if (issue !== undefined) { - const relativePath = generatedPath.slice(targetPrefix.length); diagnostics.push(diagnostic( 'AB6012', - `Target ${JSON.stringify(target.name)} document ${JSON.stringify(relativePath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + `Target ${JSON.stringify(target.name)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, generatedPath, target.name, )); @@ -439,39 +421,35 @@ const validateTargetContracts = async (options: { }; /** - * Agent Plugins 1.0.0 bytes-at-rest lane (AB6035–AB6037) over every tree - * emitted by the built-in portable adapter, so a standard-invalid `mcp.json` - * or layout fails ordinary `build` and `validate --artifact` rather than only - * `--host-validation`. The lane keys on the registered adapter identity, not - * the name: an advanced registry may bind `portable` to its own adapter and - * contract, and that output is validated by its own `artifactValidation`. - * A tree that already holds a symlink or other unsupported entry (AB6013) is - * skipped: the byte lane follows `plugin.json`/`mcp.json`/`skills` with - * `stat`/`readFile`/`readdir`, so it must not touch paths whose containment - * the filesystem inspection has already refused. + * Agent Plugins 1.0.0 bytes-at-rest lane (AB6035–AB6037) over the composite + * root when the built-in portable adapter is among its projections, so a + * standard-invalid `mcp.json` or layout fails ordinary `build` and + * `validate --artifact` rather than only `--host-validation`. The lane keys + * on the registered adapter identity, not the name: an advanced registry may + * bind `portable` to its own adapter and contract, and that output is + * validated by its own `artifactValidation`. A tree that already holds a + * symlink or other unsupported entry (AB6013) is skipped: the byte lane + * follows `plugin.json`/`mcp.json`/`skills` with `stat`/`readFile`/`readdir`, + * so it must not touch paths whose containment the filesystem inspection has + * already refused. */ -const validatePortableTargets = async (options: { +const validatePortableProjection = async (options: { readonly artifactRoot: string; readonly filesystem: ArtifactFilesystemSnapshot; readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; }): Promise => { + const portable = options.manifest.targets.find((target) => + options.registry.has(target.name) && options.registry.get(target.name) === portableAdapter); + if (portable === undefined) return Object.freeze([]); + const unsupported = options.filesystem.entries.some((entry) => entry.kind !== 'directory' && entry.kind !== 'file'); + if (unsupported) return Object.freeze([]); const diagnostics: Diagnostic[] = []; - for (const target of options.manifest.targets) { - if (!options.registry.has(target.name) || options.registry.get(target.name) !== portableAdapter) continue; - const prefix = `${target.name}/`; - if (!options.filesystem.files.some((file) => file.path.startsWith(prefix))) continue; - const unsupported = options.filesystem.entries.some((entry) => - (entry.path === target.name || entry.path.startsWith(prefix)) && - entry.kind !== 'directory' && - entry.kind !== 'file'); - if (unsupported) continue; - for (const entry of await validatePortablePluginFiles({ - pluginDirectory: resolve(options.artifactRoot, target.name), - target: target.name, - })) { - diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(target.name)}: ${entry.message}` })); - } + for (const entry of await validatePortablePluginFiles({ + pluginDirectory: options.artifactRoot, + target: portable.name, + })) { + diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(portable.name)}: ${entry.message}` })); } return Object.freeze(diagnostics); }; @@ -494,15 +472,16 @@ const isRecursiveArtifactPath = (relativePath: string, directory: string | undef const isAdapterRootDocument = (relativePath: string, rootDocuments: readonly string[] | undefined): boolean => rootDocuments?.includes(relativePath) === true; -const isTargetArtifactPath = ( - path: string, +/** + * True when a selected host's contract admits `path` at the composite root: + * its emitted layouts, its root documents, its hook and MCP documents, or a + * document its artifact validation names (#555). + */ +const isProjectionArtifactPath = ( + relativePath: string, target: string, registry: TargetRegistry, ): boolean => { - const relativePath = path.slice(target.length + 1); - // Unknown targets are diagnosed by the target-contract validator; without their - // registry contract there is no trustworthy layout against which to classify files. - if (!registry.has(target)) return true; const layout = registry.artifactLayout(target); const hookContract = registry.hookContract(target); const mcpRuntime = registry.mcpRuntime(target); @@ -523,6 +502,14 @@ const isTargetArtifactPath = ( matchesArtifactDocumentPath(document.path, relativePath)); }; +/** + * Every file in the composite root must be owned by a selected projection's + * contract, be a prebuilt payload file, or be artifact metadata; every + * directory must hold a file. Unknown targets are diagnosed by the + * target-contract validator, and without their registry contract there is no + * trustworthy layout to classify files against, so their presence admits + * every file. + */ const validateArtifactOwnership = (options: { readonly filesystem: ArtifactFilesystemSnapshot; readonly files: readonly ArtifactFile[]; @@ -530,56 +517,34 @@ const validateArtifactOwnership = (options: { readonly registry: TargetRegistry; }): readonly Diagnostic[] => { const diagnostics: Diagnostic[] = []; - const targets = targetNamespaces(options.manifest); + const selected = manifestTargets(options.manifest); + const known = selected.filter((target) => options.registry.has(target)); + const admitsEverything = known.length !== selected.length; const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { - if (artifactRootMetadata.has(file.path)) continue; - const target = pathTarget(file.path, targets); - if (target !== undefined && isTargetArtifactPath(file.path, target, options.registry)) continue; - // Prebuilt payload files live in config-named directories under their - // target namespace, so no emitted layout describes them. - if (target !== undefined && manifestKinds.get(file.path) === 'prebuilt') continue; + if (artifactRootMetadata.has(file.path) || admitsEverything) continue; + if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; + // Prebuilt payload files live in config-named directories under the + // root, so no emitted layout describes them. + if (manifestKinds.get(file.path) === 'prebuilt') continue; diagnostics.push(diagnostic( 'AB6014', - `Artifact file ${JSON.stringify(file.path)} is outside declared target emitted layouts.`, + `Artifact file ${JSON.stringify(file.path)} is outside the emitted layouts of the selected targets.`, file.path, - target, + undefined, ownershipRecovery, )); } for (const entry of options.filesystem.entries) { if (entry.kind !== 'directory' || entry.path === '.') continue; - const target = pathTarget(entry.path, targets); - if (!entry.path.includes('/') && !targets.has(entry.path)) { - diagnostics.push(diagnostic( - 'AB6014', - `Artifact directory ${JSON.stringify(entry.path)} does not name a declared target namespace.`, - entry.path, - undefined, - ownershipRecovery, - )); - continue; - } if (!options.files.some((file) => file.path.startsWith(`${entry.path}/`))) { diagnostics.push(diagnostic( 'AB6014', `Artifact directory ${JSON.stringify(entry.path)} is empty.`, entry.path, - target, - ownershipRecovery, - )); - } - } - - for (const target of options.manifest.targets) { - if (!options.files.some((file) => file.path.startsWith(`${target.name}/`))) { - diagnostics.push(diagnostic( - 'AB6014', - `Declared target ${JSON.stringify(target.name)} has no emitted namespace.`, - target.name, - target.name, + undefined, ownershipRecovery, )); } @@ -623,7 +588,6 @@ const validateGeneratedFiles = async (options: { readonly prebuiltPaths?: ReadonlySet; }): Promise => { const diagnostics: Diagnostic[] = []; - const generatedFiles = new Set(options.files.map((file) => file.path)); // Prebuilt payload files are opaque consumer build outputs: they stay // hash-locked to the manifest, but their contents are never held to the // generated-output contracts (strict JSON, bundled ESM import graphs). @@ -634,20 +598,10 @@ const validateGeneratedFiles = async (options: { for (const file of options.files.filter((entry) => entry.path.endsWith('.json'))) { try { - const document = JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, file.path)))) as unknown; + // Strict parseability only: host MCP documents are read against the + // compiled entries by validateMcpCoherence. + JSON.parse(await runWithPlatform(readFileString(resolve(options.artifactRoot, file.path)))); validJson.add(file.path); - if (!file.path.includes('/')) { - for (const mcpPath of localMcpPaths(document)) { - const generatedPath = posix.join(dirname(file.path), mcpPath); - if (!generatedFiles.has(generatedPath)) { - diagnostics.push(diagnostic( - 'AB6007', - `MCP manifest references missing generated server ${JSON.stringify(mcpPath)}.`, - file.path, - )); - } - } - } } catch { if (!prebuiltPaths.has(file.path)) { diagnostics.push(diagnostic('AB6006', 'Generated JSON cannot be parsed.', file.path)); @@ -798,7 +752,7 @@ export const validateArtifactWithSnapshot = async ( manifest, registry, }), - validatePortableTargets({ + validatePortableProjection({ artifactRoot, filesystem: inspection.filesystem, manifest, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 5ad4c9125..4f935deba 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -849,7 +849,7 @@ export const runCli = async ( program.command('build').description('Build a validated Agent Bundle artifact'), ) .option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default artifact, since dist is the npm package build output)') - .option('--host-validation', 'Run the installed Claude developer validator over built claude and plugin targets', true) + .option('--host-validation', 'Run the installed Claude developer validator over the built claude projection', true) .option('--no-host-validation', 'Skip the installed Claude developer validator') .option('--strict', 'Promote host-tool warnings to errors'); buildCommand.action(async (options: BuildCommandOptions) => { diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 7e56ca929..11c5e4c84 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -6,8 +6,6 @@ import { basename, dirname, extname, posix, relative, resolve, sep, win32 } from import { digest } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { capabilityIsSupported } from '../adapters/capability-state.ts'; -import { componentKindCapabilityName } from '../core/components.ts'; import { isInside } from '../core/paths.ts'; import { defaultGeneratedRuntime, @@ -68,8 +66,7 @@ import type { CompiledCliSurface } from '../routes/types.ts'; import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; -import type { SkillIr } from '../skills/ir.ts'; -import { decideSkillTreeLayout, lowerSkillIr, lowerSkillIrForHosts } from '../skills/lower.ts'; +import { decideSkillTreeLayout, lowerSkillIrForHosts } from '../skills/lower.ts'; import { parseSkillIr } from '../skills/parse-ir.ts'; import type { SkillHost } from '../skills/tokens.ts'; import { normalizeNoticeRetention } from './notice-retention.ts'; @@ -78,27 +75,8 @@ import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './scri const isSkillHost = (name: string): name is SkillHost => name === 'claude' || name === 'codex' || name === 'cursor' || name === 'portable'; -const loweringHosts = (targetNames: readonly string[]): SkillHost[] => { - const hosts = new Set(); - for (const name of targetNames) { - if (name === 'plugin') { - hosts.add('claude'); - hosts.add('codex'); - } else if (isSkillHost(name)) { - hosts.add(name); - } - } - return [...hosts]; -}; - -const pluginSharedDocument = (skillIr: SkillIr) => { - const claude = lowerSkillIr(skillIr, 'claude'); - const codex = lowerSkillIr(skillIr, 'codex'); - if (claude.passThrough && codex.passThrough && claude.skillMarkdown === codex.skillMarkdown) { - return claude; - } - return lowerSkillIr(skillIr, 'portable'); -}; +const loweringHosts = (targetNames: readonly string[]): SkillHost[] => + [...new Set(targetNames.filter(isSkillHost))]; const unique = (values: readonly string[]): string[] => [...new Set(values)]; @@ -1047,22 +1025,6 @@ const normalizeExtensions = ( return deepFreeze(extensions); }; -/** - * Enumerates `lsp` components (#100) from every selected host extension's - * `lspServers` record. Server configuration stays opaque here — the declaring - * adapter validates and lowers it — so a malformed record yields no component - * and the adapter's own diagnostics explain it. The declaration is host-scoped: - * a component targets only the selected adapters that lower its extension key - * (the declaring host and composites that plan that host's side), so a host - * whose planner never reads the key is excluded by the declaration and its - * pinned `lsp` row still explains the omission in inspection. - */ -/** The emission judgment for one component kind: the adapter's component override when published, else its top-level row. */ -const hostsComponent = (registry: NormalizationTargetRegistry, target: string, capability: string): boolean => { - const state = registry.componentCapabilityState?.(target, capability) ?? registry.capabilityState?.(target, capability); - return state === undefined ? registry.supports(target, capability) : capabilityIsSupported(state); -}; - /** * Total, injective escape for one `lsp:` id segment: `%` first, then the `:` * separator. Unlike `encodeURIComponent` it accepts every JavaScript string @@ -1070,26 +1032,27 @@ const hostsComponent = (registry: NormalizationTargetRegistry, target: string, c */ const lspIdSegment = (segment: string): string => segment.replaceAll('%', '%25').replaceAll(':', '%3A'); +/** + * Enumerates `lsp` components (#100) from every selected host extension's + * `lspServers` record. Server configuration stays opaque here — the declaring + * adapter validates and lowers it — so a malformed record yields no component + * and the adapter's own diagnostics explain it. The declaration is host-scoped: + * a component targets only the selected adapter that owns its extension key, + * so every other host is excluded by the declaration and its pinned `lsp` row + * still explains the omission in inspection. + */ const normalizeLspServers = ( extensions: Readonly>, targetNames: readonly string[], - registry: NormalizationTargetRegistry, ): readonly NormalizedLspServer[] => { const servers: NormalizedLspServer[] = []; for (const extension of Object.values(extensions)) { if (!isRecord(extension.value)) continue; const declared = extension.value['lspServers']; if (!isRecord(declared)) continue; - // The declaring host always judges its own declaration; a composite that - // lowers the extension inherits it only when the declaring host can lower - // LSP servers at all — a composite planning a host with no LSP surface - // (Codex) emits nothing for that host's `lspServers`. - const declaringHostLowersLsp = hostsComponent(registry, extension.target, componentKindCapabilityName('lsp')!); - const targets = targetNames.filter((target) => target === extension.target || ( - declaringHostLowersLsp && - registry.lowersConfigExtension !== undefined && - registry.lowersConfigExtension(target, extension.key) - )); + // A host-scoped `.lspServers` declaration is emitted only by the + // declaring host; no other projection reads that extension. + const targets = targetNames.filter((target) => target === extension.target); for (const name of Object.keys(declared).sort((left, right) => left.localeCompare(right))) { servers.push({ declaredBy: extension.key, @@ -1107,19 +1070,25 @@ const normalizeLspServers = ( return servers.sort((left, right) => left.id.localeCompare(right.id)); }; +/** + * The selected projections, by precedence: `--target` flags, then the config + * `targets`, then the registry default (`portable`). The selection is a set — + * sorted by name so reordering `targets` cannot change one byte of the + * composite root (#555). + */ const selectedTargetNames = ( loaded: LoadedConfig, registry: NormalizationTargetRegistry, ): string[] => { if (loaded.context.selectedTargets.length > 0) { - return unique(loaded.context.selectedTargets); + return sortedUnique(loaded.context.selectedTargets); } if (loaded.config.targets !== undefined) { - return unique(loaded.config.targets); + return sortedUnique(loaded.config.targets); } - return unique(registry.defaultTargetNames()); + return sortedUnique(registry.defaultTargetNames()); }; const skillProvenance = ( @@ -1232,10 +1201,7 @@ export const normalizeProject = async ( : basename(skill.dir); const description = frontmatter.description; const skillIr = parseSkillIr(skill); - const hostDocuments = { - ...lowerSkillIrForHosts(skillIr, skillHosts), - ...(targetNames.includes('plugin') ? { plugin: pluginSharedDocument(skillIr) } : {}), - }; + const hostDocuments = lowerSkillIrForHosts(skillIr, skillHosts); return { body: skill.body, @@ -1294,7 +1260,7 @@ export const normalizeProject = async ( discovered.routeGraph?.cli, ); const extensions = normalizeExtensions(loaded, registry, configProvenance); - const lspServers = normalizeLspServers(extensions, targetNames, registry); + const lspServers = normalizeLspServers(extensions, targetNames); const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), ...(commands.length === 0 ? {} : { commands }), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 9a6fe1560..add65dd96 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'n import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; import { capabilityIsSupported, cliBinCapability } from '../adapters/capability-state.ts'; +import { builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts'; import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts'; import type { CapabilityState } from '../core/capabilities.ts'; @@ -1017,8 +1018,7 @@ interface TargetedDocumentCodes { /** * The host's judgment of one component feature row (`.`, #100), - * read through the emission-dispatch view so the composite `plugin` target is - * judged by the half that emits the kind. A host that publishes no row for a + * read through the emission-dispatch view. A host that publishes no row for a * feature it is asked about has not evidenced it and reads as `unavailable`. */ const featureCapabilityStateFor = ( @@ -2315,6 +2315,39 @@ const routedCliBinTargetDiagnostics = ( return diagnostics; }; +/** + * One composite root is shared by the built-in hosts only (#555): their + * projections agree on where the files they cannot share live, which + * conventional directories each discovers, and one install surface. An + * adapter registered on an advanced `TargetRegistry` has made none of those + * agreements, so a selection that puts it beside any other known target is + * refused on the model — `validate`, `inspect`, and `build` all report it — + * and named on the non-built-in target. Selected alone, any target gets a + * root of its own; unknown names are `AB4100`'s and project nothing to share. + */ +const compositeRootTargetDiagnostics = ( + model: NormalizedPlugin, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const known = model.targets.filter((target) => registry.has(target.name)); + const selected = [...new Set(known.map((target) => target.name))]; + if (selected.length < 2) return []; + // Built-in by adapter identity where the registry can tell (#592): a custom + // adapter registered under a built-in host's name has made no agreement. + const isBuiltIn = (name: string): boolean => + registry.builtInHost === undefined ? isBuiltInHost(name) : registry.builtInHost(name) !== undefined; + return known + .filter((target) => !isBuiltIn(target.name)) + .map((target) => ({ + code: 'AB4106', + message: `Target ${JSON.stringify(target.name)} cannot share one composite root with the other selected targets (${selected.filter((name) => name !== target.name).join(', ')}): only the built-in hosts (${builtInHostNames.join(', ')}) project into a shared root.`, + recovery: `Build ${JSON.stringify(target.name)} alone — targets: [${JSON.stringify(target.name)}] — into its own --output, and the other targets into another.`, + severity: 'error', + sourcePath: target.provenance.sourcePath, + target: target.name, + })); +}; + export const validateModel = ( model: NormalizedPlugin, registry: NormalizationTargetRegistry, @@ -2333,6 +2366,8 @@ export const validateModel = ( } } + diagnostics.push(...compositeRootTargetDiagnostics(model, registry)); + diagnostics.push(...routedCliBinTargetDiagnostics(model, registry)); const ids = new Map(); @@ -2513,11 +2548,15 @@ export const validateModel = ( } } + // Two inputs may not produce one path of a host's projection. Paths are + // root-relative — every projection shares the composite root (#555) — and + // the same input reaching several hosts is one file, not a collision. const outputs = new Map(); const recordOutput = (generatedPath: string, source: string, target: string): void => { - const firstSource = outputs.get(generatedPath); + const key = `${target}\u0000${generatedPath}`; + const firstSource = outputs.get(key); if (firstSource === undefined) { - outputs.set(generatedPath, source); + outputs.set(key, source); return; } diagnostics.push({ @@ -2535,20 +2574,20 @@ export const validateModel = ( const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; if (generatedSkill) { recordOutput( - posix.join(target.name, 'skills', skill.name, 'SKILL.md'), + posix.join('skills', skill.name, 'SKILL.md'), skill.source, target.name, ); for (const sidecar of hostDocument.sidecars) { recordOutput( - posix.join(target.name, 'skills', skill.name, sidecar.relativePath), + posix.join('skills', skill.name, sidecar.relativePath), sidecar.source ?? skill.source, target.name, ); } } else if (skill.markdown !== undefined) { recordOutput( - posix.join(target.name, 'skills', skill.name, 'SKILL.md'), + posix.join('skills', skill.name, 'SKILL.md'), skill.source, target.name, ); @@ -2561,7 +2600,7 @@ export const validateModel = ( continue; } recordOutput( - posix.join(target.name, 'skills', skill.name, resource.relativePath), + posix.join('skills', skill.name, resource.relativePath), resource.source, target.name, ); @@ -2569,38 +2608,38 @@ export const validateModel = ( } for (const asset of model.assets ?? []) { if (!asset.targets.includes(target.name)) continue; - recordOutput(posix.join(target.name, 'assets', asset.relativePath), asset.source, target.name); + recordOutput(posix.join('assets', asset.relativePath), asset.source, target.name); } for (const command of model.commands ?? []) { if (!command.targets.includes(target.name)) continue; - recordOutput(posix.join(target.name, 'commands', `${command.name}.md`), command.source, target.name); + recordOutput(posix.join('commands', `${command.name}.md`), command.source, target.name); } for (const rule of model.rules ?? []) { if (!rule.targets.includes(target.name)) continue; - recordOutput(posix.join(target.name, 'rules', `${rule.name}.mdc`), rule.source, target.name); + recordOutput(posix.join('rules', `${rule.name}.mdc`), rule.source, target.name); } for (const payload of model.payloads ?? []) { if (!payload.targets.includes(target.name)) continue; for (const file of payload.files) { - recordOutput(posix.join(target.name, payload.name, file.relativePath), file.source, target.name); + recordOutput(posix.join(payload.name, file.relativePath), file.source, target.name); } } for (const bin of model.hostBins ?? []) { if (bin.target !== target.name) continue; for (const file of bin.files) { - recordOutput(posix.join(target.name, 'bin', file.relativePath), file.source, target.name); + recordOutput(posix.join('bin', file.relativePath), file.source, target.name); } } for (const directory of model.hostOutputStyles ?? []) { if (directory.target !== target.name) continue; for (const file of directory.files) { - recordOutput(posix.join(target.name, 'output-styles', file.relativePath), file.source, target.name); + recordOutput(posix.join('output-styles', file.relativePath), file.source, target.name); } } for (const directory of model.hostWorkflows ?? []) { if (directory.target !== target.name) continue; for (const file of directory.files) { - recordOutput(posix.join(target.name, 'workflows', file.relativePath), file.source, target.name); + recordOutput(posix.join('workflows', file.relativePath), file.source, target.name); } } } diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 242089d14..e6588bac4 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -787,9 +787,14 @@ export interface NormalizationTargetRegistry { componentCapabilityState?(name: string, capability: string): CapabilityState | undefined; configExtensions(): readonly NormalizationConfigExtension[]; defaultTargetNames(): readonly string[]; + /** + * The built-in host the adapter registered under `name` is, judged by + * adapter identity rather than name (`TargetRegistry.builtInHost`); a + * registry that cannot tell judges by name. `undefined` for a custom + * adapter or an unknown name. + */ + builtInHost?(name: string): string | undefined; has(name: string): boolean; - /** True when the target's adapter reads the config extension `key` (its own key or a declared composite side). */ - lowersConfigExtension?(name: string, key: string): boolean; nativeHookSources?( config: Readonly, targetNames: readonly string[], diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index c52108a08..661940503 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -102,7 +102,7 @@ const treeNode = ( ): ArtifactInspectionDirectoryNode => { const children: ArtifactInspectionTreeNode[] = [ ...[...directory.directories.entries()] - .map(([directoryName, child]) => treeNode(directoryName, `${path}/${directoryName}`, child)), + .map(([directoryName, child]) => treeNode(directoryName, path === '.' ? directoryName : `${path}/${directoryName}`, child)), ...[...directory.files.entries()].map(([fileName, file]): ArtifactInspectionFileNode => Object.freeze({ file, kind: 'file', @@ -264,27 +264,27 @@ export class ArtifactInspectionService { manifest: ArtifactManifest, files: readonly ArtifactInspectionFile[], ): readonly ArtifactInspectionTarget[] { - return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => { - const root = emptyTreeBuildDirectory(); - const prefix = `${target.name}/`; - for (const file of files) { - if (!file.path.startsWith(prefix)) continue; - const segments = file.path.slice(prefix.length).split('/'); - const fileName = segments.pop(); - if (fileName === undefined) continue; - let directory = root; - for (const segment of segments) { - let child = directory.directories.get(segment); - if (child === undefined) { - child = emptyTreeBuildDirectory(); - directory.directories.set(segment, child); - } - directory = child; + // One composite root (#555): every selected projection reads the same tree. + const root = emptyTreeBuildDirectory(); + for (const file of files) { + const segments = file.path.split('/'); + const fileName = segments.pop(); + if (fileName === undefined) continue; + let directory = root; + for (const segment of segments) { + let child = directory.directories.get(segment); + if (child === undefined) { + child = emptyTreeBuildDirectory(); + directory.directories.set(segment, child); } - directory.files.set(fileName, file); + directory = child; } - return Object.freeze({ name: target.name, tree: treeNode(target.name, target.name, root) }); - })); + directory.files.set(fileName, file); + } + return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => Object.freeze({ + name: target.name, + tree: treeNode(target.name, '.', root), + }))); } #runtime( @@ -324,7 +324,7 @@ export class ArtifactInspectionService { const hooks: ArtifactInspectionHook[] = []; for (const hook of runtime.hooks) { const file = filesByPath.get(hook.path); - if (file === undefined || !hook.path.startsWith(`${hook.target}/`)) { + if (file === undefined) { throw this.#runtimeError('Validated hook evidence references an unmanifested wrapper.', hook.path, hook.target); } hooks.push(Object.freeze({ @@ -349,11 +349,11 @@ export class ArtifactInspectionService { ): readonly ArtifactInspectionMcpServer[] { const servers: ArtifactInspectionMcpServer[] = []; for (const server of runtime.mcpServers) { - if (!server.manifestPath.startsWith(`${server.target}/`) || !filesByPath.has(server.manifestPath)) { + if (!filesByPath.has(server.manifestPath)) { throw this.#runtimeError('Validated MCP evidence references an unmanifested target manifest.', server.manifestPath, server.target); } for (const path of server.entryPaths) { - if (!path.startsWith(`${server.target}/`) || !filesByPath.has(path)) { + if (!filesByPath.has(path)) { throw this.#runtimeError('Validated MCP evidence references an unmanifested target file.', path, server.target); } } diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts index 03280ec5b..3fdddf6d8 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts @@ -33,7 +33,9 @@ export const artifactScriptCatalog = ( for (const target of manifest.targets) { const layout = registry.artifactLayout(target.name).scripts; if (layout === undefined) continue; - const prefix = `${target.name}/${layout.directory}/`; + // Scripts live once at the composite root; every selected host that lays + // out that directory reads the same emitted file. + const prefix = `${layout.directory}/`; for (const manifestFile of manifest.files) { if (!manifestFile.path.startsWith(prefix)) continue; const file = manifestFile.path.slice(prefix.length); diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index 26edf43b0..e2233ab90 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -3,7 +3,6 @@ import { mkdir, mkdtemp, readdir, rename, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { build, type BuildOptions, type BuildResult } from '../../build/build.ts'; -import { listArtifactFiles } from '../../build/emit.ts'; import { recheckValidatedArtifactSnapshot, validateArtifact, @@ -12,7 +11,6 @@ import { } from '../../build/validate-artifact.ts'; import { freezeDiagnostics, hasErrors, DiagnosticError, type Diagnostic } from '../../core/diagnostics.ts'; import { errorMessage } from '../../core/errors.ts'; -import { digest } from '../../core/digest.ts'; import type { ProjectSourceInput, ProjectSourceSnapshotInput } from '../../core/project-context.ts'; import type { NormalizedPlugin } from '../../core/types.ts'; import { @@ -27,6 +25,7 @@ import { type NativePlaygroundCatalogPublicationReceipt, } from '../playground/native-playground-service.ts'; import type { PreparedProject } from '../project-service.ts'; +import { projectionDigests } from './projection-digest.ts'; import { freezeArtifactEpoch, type ArtifactEpoch, type DiagnosticSummary } from '../types.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -85,16 +84,6 @@ const failureDiagnostics = ( }]); }; -const targetDigests = async ( - artifactRoot: string, - model: NormalizedPlugin, -): Promise>> => Object.freeze(Object.fromEntries( - await Promise.all(model.targets.map(async (target) => [ - target.name, - digest(await listArtifactFiles(join(artifactRoot, target.name))), - ])), -)); - const createAttempt = async (projectRoot: string): Promise => { const attemptsRoot = join(resolve(projectRoot), '.agent-bundle', 'attempts'); await mkdir(attemptsRoot, { recursive: true }); @@ -255,7 +244,7 @@ export class ArtifactService { ...(projectContext.packageName === undefined ? {} : { packageName: projectContext.packageName }), ...(projectContext.packageVersion === undefined ? {} : { packageVersion: projectContext.packageVersion }), projectRevision: projectContext.revision, - targetDigests: await targetDigests(artifactRoot, model), + targetDigests: await projectionDigests(artifactRoot, model.targets.map((target) => target.name)), }); staging = await this.#epochStore.createStagingEpoch({ epoch, diff --git a/packages/agent-bundle/src/dev/artifacts/projection-digest.ts b/packages/agent-bundle/src/dev/artifacts/projection-digest.ts new file mode 100644 index 000000000..b14a34618 --- /dev/null +++ b/packages/agent-bundle/src/dev/artifacts/projection-digest.ts @@ -0,0 +1,20 @@ +import { listArtifactFiles, type ArtifactFile } from '../../build/emit.ts'; +import { digest } from '../../core/digest.ts'; + +/** + * Every selected host reads the same composite artifact root, so a host's + * digest covers the whole root; the host name keeps each projection's identity + * distinct. The epoch store records these and the playgrounds verify against + * them, so both sides must derive the digest here. + */ +const projectionDigest = (files: readonly ArtifactFile[], target: string): string => + digest({ files, target }); + +/** One digest per selected host, read from the artifact root once. */ +export const projectionDigests = async ( + artifactRoot: string, + targets: readonly string[], +): Promise>> => { + const files = await listArtifactFiles(artifactRoot); + return Object.freeze(Object.fromEntries(targets.map((target) => [target, projectionDigest(files, target)]))); +}; diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 29b811541..7f36e3960 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -124,7 +124,6 @@ interface StagingRecord { readonly root: string; readonly rootDevice: number; readonly rootInode: number; - readonly targets: readonly string[]; } interface AtomicWriteProgress { @@ -380,7 +379,7 @@ export class EpochStore { assertSafeEpochId(options.epoch.id); return assertEpoch(options.epoch); }); - const targets = yield* liftTry(() => this.#assertTargetSet(epoch, options.targets)); + yield* liftTry(() => this.#assertTargetSet(epoch, options.targets)); yield* liftTry(() => this.#manifestRelativePath(epoch)); yield* liftPromise(() => mkdir(this.#epochsPath, { recursive: true })); const root = yield* liftPromise(() => mkdtemp(join(this.#epochsPath, stagingPrefix))); @@ -394,7 +393,6 @@ export class EpochStore { root, rootDevice: metadata.dev, rootInode: metadata.ino, - targets, }); return new EpochStagingHandle( root, @@ -783,34 +781,6 @@ export class EpochStore { throw new EpochStoreError('EPOCH_STAGING_INVALID', 'The staging root escapes the epoch store.'); } - for (const target of record.targets) { - const targetPath = join(record.root, target); - let targetMetadata; - try { - targetMetadata = await lstat(targetPath); - } catch (error) { - if (isErrno(error, 'ENOENT')) { - throw new EpochStoreError( - 'EPOCH_STAGING_INVALID', - `Staged epoch is missing selected target ${JSON.stringify(target)}.`, - ); - } - throw error; - } - if (!targetMetadata.isDirectory() || targetMetadata.isSymbolicLink()) { - throw new EpochStoreError( - 'EPOCH_STAGING_INVALID', - `Staged epoch target ${JSON.stringify(target)} must be a contained non-symlink directory.`, - ); - } - if (!isInside(stagingRoot, await realpath(targetPath))) { - throw new EpochStoreError( - 'EPOCH_STAGING_INVALID', - `Staged epoch target ${JSON.stringify(target)} escapes the staging root.`, - ); - } - } - const manifestPath = join(record.root, this.#manifestRelativePath(record.epoch)); let manifestMetadata; try { @@ -857,34 +827,6 @@ export class EpochStore { throw new EpochStoreError('EPOCH_METADATA_INVALID', 'Active epoch directory escapes the epoch store.'); } - for (const target of Object.keys(epoch.targetDigests)) { - const targetPath = join(epochRoot, target); - let targetMetadata; - try { - targetMetadata = await lstat(targetPath); - } catch (error) { - if (isErrno(error, 'ENOENT')) { - throw new EpochStoreError( - 'EPOCH_METADATA_INVALID', - `Active epoch is missing target ${JSON.stringify(target)}.`, - ); - } - throw error; - } - if (!targetMetadata.isDirectory() || targetMetadata.isSymbolicLink()) { - throw new EpochStoreError( - 'EPOCH_METADATA_INVALID', - `Active epoch target ${JSON.stringify(target)} must be a non-symlink directory.`, - ); - } - if (!isInside(activeEpochRoot, await realpath(targetPath))) { - throw new EpochStoreError( - 'EPOCH_METADATA_INVALID', - `Active epoch target ${JSON.stringify(target)} escapes the epoch directory.`, - ); - } - } - let manifestRelativePath: string; try { manifestRelativePath = this.#manifestRelativePath(epoch); diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 6d6ba436a..adb213e08 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -13,6 +13,8 @@ import { basename, join, relative, resolve } from 'node:path'; import { Effect, FileSystem } from 'effect'; +import { codexArtifactPaths } from '../adapters/codex.ts'; +import { cursorArtifactPaths } from '../adapters/cursor.ts'; import { stableJson } from '../core/digest.ts'; import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; @@ -67,10 +69,11 @@ interface DevInstallMarker { const mcpDocumentPath = (host: InstallHost): string => { switch (host) { case 'claude': - case 'codex': return '.mcp.json'; + case 'codex': + return codexArtifactPaths.mcp; case 'cursor': - return 'mcp.json'; + return cursorArtifactPaths.mcp; default: { const exhaustive: never = host; throw new TypeError(`Unsupported development install host ${String(exhaustive)}.`); @@ -401,7 +404,8 @@ export class DevHostInstallManager { } async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise { - const prepared = await prepareDevBundle(join(epochRoot, host), host, epochId, this.#projectRoot, this.#run); + // Every selected host installs from the composite epoch root (#555). + const prepared = await prepareDevBundle(epochRoot, host, epochId, this.#projectRoot, this.#run); try { let installed = this.#installed.get(host); if (installed === undefined) { diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index 81ae6369d..00779f29b 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -328,7 +328,8 @@ export class McpSessionService { })); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) return yield* Effect.fail(new DiagnosticError(errors)); - const targetRoot = yield* liftTry(() => joinArtifact(epochRoot, target)); + // Every selected host reads the composite epoch root as its plugin root (#555). + const targetRoot = epochRoot; const server = yield* liftPromise(() => this.#server(targetRoot, target, runtime, options.serverName)); const fs = yield* FileSystem.FileSystem; const pluginDataScope = yield* Scope.make(); diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts index d3dd9bf9a..d5cd72079 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts @@ -7,12 +7,11 @@ import { Effect, FileSystem } from 'effect'; import { canonicalHookEventFor, type TargetHookContract } from '../../adapters/hook-contract.ts'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; import type { ArtifactHook } from '../../build/hook-index.ts'; -import { listArtifactFiles } from '../../build/emit.ts'; import type { CanonicalHookEvent } from '../../core/types.ts'; -import { digest } from '../../core/digest.ts'; import { isErrno } from '../../core/errors.ts'; import { isRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts'; import { HookService } from '../../services/hook-service.ts'; +import { projectionDigests } from '../artifacts/projection-digest.ts'; import { EpochStore, type EpochReference } from '../epoch-store.ts'; import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -166,7 +165,7 @@ const matcherFor = async ( ): Promise => { let document: unknown; try { - document = JSON.parse(await run(readFileString(join(artifact, hook.target, contract.manifestPath)))); + document = JSON.parse(await run(readFileString(join(artifact, contract.manifestPath)))); } catch (error) { if (isErrno(error, 'ENOENT')) return missingManifest(hook.target, hook.event, contract.manifestPath); return undefined; @@ -174,14 +173,10 @@ const matcherFor = async ( if (!isRecord(document) || !isRecord(document.hooks)) return undefined; const groups = document.hooks[nativeSelector]; if (!Array.isArray(groups)) return undefined; - const targetPrefix = `${hook.target}/`; - const wrapperPath = hook.path.startsWith(targetPrefix) - ? hook.path.slice(targetPrefix.length) - : hook.path; for (const group of groups) { if (!isRecord(group) || !Array.isArray(group.hooks)) continue; const hasWrapper = group.hooks.some((entry) => - isRecord(entry) && typeof entry.command === 'string' && entry.command.includes(wrapperPath)); + isRecord(entry) && typeof entry.command === 'string' && entry.command.includes(hook.path)); if (hasWrapper) return typeof group.matcher === 'string' ? group.matcher : undefined; } return undefined; @@ -234,7 +229,7 @@ const assertTargetDigest = async ( ): Promise => { let actual: string; try { - actual = digest(await listArtifactFiles(join(artifact, target))); + actual = (await projectionDigests(artifact, [target]))[target]!; } catch { throw new Error(`Hook playground target ${JSON.stringify(target)} cannot be verified against its stored digest.`); } diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 24dba928f..74a2dd058 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -290,8 +290,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/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index af9309061..b5fe0d2d0 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -3,6 +3,7 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { planComposite } from '../build/compose.ts'; import { readFileBytes } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; import type { DevPlatformRuntime } from './platform-runtime.ts'; @@ -938,10 +939,12 @@ export class ProjectService { ...(command === 'validate' ? routeTypesProgramDiagnostics(root) : []), ...validateModel(model, registry), ]; - for (const target of model.targets) { - if (!registry.has(target.name)) continue; - const adapter = registry.get(target.name); - diagnostics.push(...adapter.plan(model).diagnostics); + // The selected projections are judged as the one composite root the + // build stages (#555), so a collision (AB4103) or scope leak (AB4105) + // the build would refuse is reported here too. An unknown target is + // already AB4100 above and has no planner to run. + if (model.targets.every((target) => registry.has(target.name))) { + diagnostics.push(...planComposite(model, registry).diagnostics); } } catch { return failedPreparation( diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index d53426c4f..4527b0e28 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -360,17 +360,11 @@ export class SkillDocumentService { } throw error; }); - const targetRoot = join(realEpochRoot, target); - const realTargetRoot = await assertedDirectory(targetRoot).catch((error: unknown) => { - if (error instanceof SkillDocumentError) { - throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target is not available in this epoch.'); - } - throw error; - }); - if (!isInsideOrEqual(realEpochRoot, realTargetRoot)) { - throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target escapes its epoch.'); + // Every selected host reads the composite epoch root; only the selection is per host. + if (reference.epoch.targetDigests[target] === undefined) { + throw new SkillDocumentError('SKILL_TARGET_UNAVAILABLE', 'Artifact target is not available in this epoch.'); } - return await operation(realTargetRoot); + return await operation(realEpochRoot); } finally { await reference.close(); } diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index ab14012ef..120b1b2be 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -61,6 +61,11 @@ export interface ArtifactInspectionDirectoryNode { /** One immutable tree node within a declared artifact target. */ export type ArtifactInspectionTreeNode = ArtifactInspectionDirectoryNode | ArtifactInspectionFileNode; +/** + * One selected host projection and the tree it reads. Every projection reads + * the whole composite root (#555), so each target's tree is that root, named + * after the host; S3 collapses the per-target view to the single root. + */ export interface ArtifactInspectionTarget { readonly name: string; readonly tree: ArtifactInspectionDirectoryNode; diff --git a/packages/agent-bundle/src/eval/artifact.ts b/packages/agent-bundle/src/eval/artifact.ts index 1898bed2e..d28b43714 100644 --- a/packages/agent-bundle/src/eval/artifact.ts +++ b/packages/agent-bundle/src/eval/artifact.ts @@ -30,27 +30,19 @@ export interface PreparedEvalArtifact { const manifestName = 'agent-bundle.manifest.json'; const runOwnedArtifactSegments = Object.freeze(['artifacts', 'target']); -/** One digest per generated target, derived from the manifest's own recorded file hashes. */ +/** + * One digest per selected host projection. Every host reads the same composite + * root, so each digest covers the manifest's whole file table; the host name + * keeps the identities distinct. + */ export const evalTargetDigests = (manifest: ArtifactManifest): Readonly> => { - const buckets = new Map( - manifest.targets.map((target) => [target.name, []]), - ); - for (const file of manifest.files) { - const separator = file.path.indexOf('/'); - if (separator <= 0) continue; - const bucket = buckets.get(file.path.slice(0, separator)); - if (bucket !== undefined) bucket.push({ path: file.path.slice(separator + 1), sha256: file.sha256 }); - } - return Object.freeze(Object.fromEntries([...buckets.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([target, files]) => [ - target, - digest({ - files: files.sort((left, right) => left.path.localeCompare(right.path)), - runtime: manifest.runtime, - target, - }), - ]))); + const files = manifest.files + .map((file) => ({ path: file.path, sha256: file.sha256 })) + .sort((left, right) => left.path.localeCompare(right.path)); + return Object.freeze(Object.fromEntries(manifest.targets + .map((target) => target.name) + .sort((left, right) => left.localeCompare(right)) + .map((target) => [target, digest({ files, runtime: manifest.runtime, target })]))); }; const readValidatedArtifact = async ( diff --git a/packages/agent-bundle/src/eval/claude-harness.ts b/packages/agent-bundle/src/eval/claude-harness.ts index 23522dd2c..03eefedae 100644 --- a/packages/agent-bundle/src/eval/claude-harness.ts +++ b/packages/agent-bundle/src/eval/claude-harness.ts @@ -271,7 +271,8 @@ export const runClaudeTrial = async (options: RunClaudeTrialOptions): Promise fs.copy(join(options.artifact.root, target), temporary.candidate, { overwrite: true }), + (fs) => fs.copy(options.artifact.root, temporary.candidate, { overwrite: true }), )); } catch (error) { throw new CodexEvalHarnessError( diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index eb7e69042..1868252ff 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -493,9 +493,6 @@ export const projectEventDocument = ( nativeEvent: string, nativeInput?: Readonly>, ): Readonly> | undefined => { - if (target === 'plugin') { - throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); - } const contexts: string[] = []; appendContext(document.root, contexts); const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); diff --git a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts index 2837a02b1..9eb1a6993 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -10,6 +10,7 @@ import { freezeDiagnostics } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import capabilityTable from '../adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; +import { codexArtifactPaths } from '../adapters/codex.ts'; import hooksSchema from '../adapters/schemas/codex/hooks.schema.json' with { type: 'json' }; import marketplaceSchema from '../adapters/schemas/codex/marketplace.schema.json' with { type: 'json' }; import mcpSchema from '../adapters/schemas/codex/mcp.schema.json' with { type: 'json' }; @@ -83,22 +84,22 @@ interface PinnedDocumentContract { const schemaValidator = createAdapterValidator(); const pinnedDocumentContracts = Object.freeze([ Object.freeze({ - path: '.codex-plugin/plugin.json', + path: codexArtifactPaths.plugin, required: true, validate: validateJsonSchemaDocument(schemaValidator.compile(pluginSchema)), }), Object.freeze({ - path: 'hooks/hooks.json', + path: codexArtifactPaths.hooksManifest, required: false, validate: validateJsonSchemaDocument(schemaValidator.compile(hooksSchema)), }), Object.freeze({ - path: '.mcp.json', + path: codexArtifactPaths.mcp, required: false, validate: validateJsonSchemaDocument(schemaValidator.compile(mcpSchema)), }), Object.freeze({ - path: '.agents/plugins/marketplace.json', + path: codexArtifactPaths.marketplace, required: false, validate: validateJsonSchemaDocument(schemaValidator.compile(marketplaceSchema)), }), diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index fb6f602d1..456ee6e5b 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -39,23 +39,23 @@ type DocumentKind = 'hooks' | 'manifest' | 'marketplace' | 'mcp'; const manifestPath = '.cursor-plugin/plugin.json'; const marketplacePath = '.cursor-plugin/marketplace.json'; -const mcpPath = 'mcp.json'; /** Cursor's folder-discovery default for hooks, used only when the manifest declares no `hooks` field. */ export const cursorDefaultHooksPath = 'hooks/hooks.json'; +/** Cursor's folder-discovery default for MCP servers, used only when the manifest declares no `mcpServers` field. */ +const cursorDefaultMcpPath = 'mcp.json'; const inlineHooksPath = `${manifestPath}#/hooks`; +const inlineMcpPath = `${manifestPath}#/mcpServers`; /** * Where the pinned Cursor loader reads a plugin's hooks from, resolved from the * `.cursor-plugin/plugin.json` `hooks` field the way the loader does: a string * is a plugin-root-relative path that replaces folder discovery (so the default * `hooks/hooks.json` is not also scanned), an object is an inline hooks - * document, and an absent field falls back to `hooks/hooks.json`. The unified - * `plugin` target relies on the replacement: its Cursor manifest points at - * `hooks/hooks-cursor.json` while `hooks/hooks.json` carries the Claude/Codex - * document (#438). + * document, and an absent field falls back to `hooks/hooks.json` (#438). The + * `mcpServers` field resolves the same way against `mcp.json`. */ export type CursorHooksSource = - | Readonly<{ readonly kind: 'default'; readonly path: typeof cursorDefaultHooksPath }> + | Readonly<{ readonly kind: 'default'; readonly path: string }> | Readonly<{ /** The manifest string as written. */ readonly declared: string; @@ -72,9 +72,9 @@ export type CursorHooksSource = const isRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); -export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource => { - const declared = isRecord(manifest) ? manifest['hooks'] : undefined; - if (declared === undefined) return Object.freeze({ kind: 'default', path: cursorDefaultHooksPath }); +const resolveCursorDocumentSource = (manifest: unknown, field: string, defaultPath: string): CursorHooksSource => { + const declared = isRecord(manifest) ? manifest[field] : undefined; + if (declared === undefined) return Object.freeze({ kind: 'default', path: defaultPath }); if (typeof declared === 'string') { const slashed = declared.replaceAll('\\', '/'); const absolute = isAbsolute(declared) || posix.isAbsolute(slashed) || /^[A-Za-z]:\//u.test(slashed); @@ -91,6 +91,9 @@ export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource = return Object.freeze({ kind: 'invalid' }); }; +export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource => + resolveCursorDocumentSource(manifest, 'hooks', cursorDefaultHooksPath); + export type CursorPluginValidationStatus = 'failed' | 'passed' | 'unavailable' | 'warnings'; export interface CursorPluginValidationReport { @@ -176,11 +179,10 @@ const validatorFor = (kind: DocumentKind): ValidateFunction => { } }; -/** The documents every generated Cursor bundle is checked for; the hooks document is added once the manifest names it. */ +/** The documents every generated Cursor bundle is checked for; the hooks and MCP documents are added once the manifest names them. */ const fixedDocumentContracts: readonly DocumentContract[] = Object.freeze([ Object.freeze({ kind: 'marketplace' as const, path: marketplacePath, required: false }), Object.freeze({ kind: 'manifest' as const, path: manifestPath, required: true }), - Object.freeze({ kind: 'mcp' as const, path: mcpPath, required: false }), ]); const recoveryFor = (code: CursorDiagnosticCode, severity: DiagnosticSeverity): string => { @@ -350,22 +352,23 @@ const readDocument = Effect.fnUntraced(function* ( }); /** - * The hooks document is the one the manifest names (or the folder-discovery - * default when it names none), validated with the pinned Cursor hooks schema. - * A declared file that is missing is an error: the loader would deliver no - * hooks even though the bundle promised them. + * A pointed document (`hooks`, `mcpServers`) is the one the manifest names + * (or the folder-discovery default when it names none), validated with its + * pinned Cursor schema. A declared file that is missing is an error: the + * loader would deliver nothing even though the bundle promised it. */ -const readHooksDocument = ( +const readPointedDocument = ( pluginDirectory: string, manifest: ParsedDocument | undefined, target: string, + pointer: Readonly<{ readonly defaultPath: string; readonly field: string; readonly inlinePath: string; readonly kind: 'hooks' | 'mcp' }>, ): Effect.Effect => { - const source = resolveCursorHooksSource(manifest?.value); + const source = resolveCursorDocumentSource(manifest?.value, pointer.field, pointer.defaultPath); switch (source.kind) { case 'default': return readDocument( pluginDirectory, - Object.freeze({ kind: 'hooks', path: source.path, required: false }), + Object.freeze({ kind: pointer.kind, path: source.path, required: false }), `${source.path} is missing.`, target, ); @@ -374,8 +377,8 @@ const readHooksDocument = ( return Effect.succeed(Object.freeze({ diagnostics: freezeDiagnostics([diagnostic( 'AB6027', - `${manifestPath} declares hooks at ${JSON.stringify(source.declared)}, which does not resolve inside the plugin root; ` + - 'generated Cursor bundles keep the hooks document under the plugin root.', + `${manifestPath} declares ${pointer.field} at ${JSON.stringify(source.declared)}, which does not resolve inside the plugin root; ` + + `generated Cursor bundles keep the ${pointer.kind} document under the plugin root.`, 'error', target, )]), @@ -383,26 +386,29 @@ const readHooksDocument = ( } return readDocument( pluginDirectory, - Object.freeze({ kind: 'hooks', path: source.path, required: true }), - `${manifestPath} declares hooks at ${JSON.stringify(source.declared)} but ${source.path} is missing from the Cursor bundle; ` + - 'Cursor would load no hooks for it.', + Object.freeze({ kind: pointer.kind, path: source.path, required: true }), + `${manifestPath} declares ${pointer.field} at ${JSON.stringify(source.declared)} but ${source.path} is missing from the Cursor bundle; ` + + `Cursor would load no ${pointer.kind} for it.`, target, ); } case 'inline': { - const document: ParsedDocument = Object.freeze({ kind: 'hooks', path: inlineHooksPath, value: source.value }); + const document: ParsedDocument = Object.freeze({ kind: pointer.kind, path: pointer.inlinePath, value: source.value }); return Effect.succeed(Object.freeze({ diagnostics: schemaDiagnostics(document, target), document })); } case 'invalid': - // The manifest schema already reports the malformed `hooks` field. + // The manifest schema already reports the malformed field. return Effect.succeed(Object.freeze({ diagnostics: Object.freeze([]) })); default: { const exhaustive: never = source; - throw new Error(`Unexpected Cursor hooks source: ${String(exhaustive)}`); + throw new Error(`Unexpected Cursor document source: ${String(exhaustive)}`); } } }; +const hooksPointer = Object.freeze({ defaultPath: cursorDefaultHooksPath, field: 'hooks', inlinePath: inlineHooksPath, kind: 'hooks' as const }); +const mcpPointer = Object.freeze({ defaultPath: cursorDefaultMcpPath, field: 'mcpServers', inlinePath: inlineMcpPath, kind: 'mcp' as const }); + interface ReadDocumentsResult { readonly diagnostics: readonly Diagnostic[]; readonly documents: readonly ParsedDocument[]; @@ -432,9 +438,11 @@ const readCursorPluginDocuments = Effect.fnUntraced(function* ( documents.push(result.document); if (contract.kind === 'manifest') manifest = result.document; } - const hooks = yield* readHooksDocument(pluginDirectory, manifest, target); - diagnostics.push(...hooks.diagnostics); - if (hooks.document !== undefined) documents.push(hooks.document); + for (const pointer of [mcpPointer, hooksPointer]) { + const pointed = yield* readPointedDocument(pluginDirectory, manifest, target, pointer); + diagnostics.push(...pointed.diagnostics); + if (pointed.document !== undefined) documents.push(pointed.document); + } return Object.freeze({ diagnostics: freezeDiagnostics(diagnostics), documents: Object.freeze(documents), diff --git a/packages/agent-bundle/src/install/cursor-hooks-registration.ts b/packages/agent-bundle/src/install/cursor-hooks-registration.ts index 125111e1c..0a815375a 100644 --- a/packages/agent-bundle/src/install/cursor-hooks-registration.ts +++ b/packages/agent-bundle/src/install/cursor-hooks-registration.ts @@ -20,9 +20,8 @@ import { * Read-only Doctor proof for Cursor hook registration (#407). * * Cursor delivers plugin hooks from the plugin manifest (`.cursor-plugin/plugin.json` - * `hooks` -> the named document, `hooks/hooks.json` for the `cursor` target and - * `hooks/hooks-cursor.json` for the unified `plugin` target; `hooks/hooks.json` by - * folder discovery when the field is absent), substituting `${CURSOR_PLUGIN_ROOT}` and running + * `hooks` -> the named document; `hooks/hooks.json` by folder discovery when + * the field is absent), substituting `${CURSOR_PLUGIN_ROOT}` and running * each command from the plugin root (observed 2026-09-03, Cursor 3.18.25, isolated * HOME: preToolUse/postToolUse/stop fired for the emitted pack exactly like the * known-working ~/.cursor/plugins/local/tracedecay). `~/.cursor/hooks.json` is a diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 8d7c155e7..69cf51452 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -466,16 +466,11 @@ const validateBundleFiles = async ( } }; +/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => { const root = resolve(from); - const manifest = manifestPath(host); - if (await exists(join(root, manifest))) return root; - const targetRoot = join(root, host); - if (await exists(join(targetRoot, manifest))) return targetRoot; - throw new Error( - `No ${host} bundle manifest was found in ${JSON.stringify(root)} or its ` + - `${JSON.stringify(host)} target directory.`, - ); + if (await exists(join(root, manifestPath(host)))) return root; + throw new Error(`No ${host} bundle manifest was found in ${JSON.stringify(root)}.`); }; /** The cwd for `plugin list --json`: the resolved host bundle root under `--from`, else the given directory, else home. */ @@ -2589,9 +2584,9 @@ const doctorHost = async ( const environment = options.environment ?? process.env; const git = stagingGit(run); // Claude `project` / `local` registrations are keyed by the cwd the host verbs ran in, and install runs them - // from the resolved host bundle root (`/claude` for a multi-target artifact root), so the listing the - // bundle comparison and lifecycle use is taken from that same root; `--from` without a manifest for this host - // falls back to the given directory and the bundle step reports the missing manifest. + // from the composite `--from` root (the host manifest sits directly under it), so the listing the bundle + // comparison and lifecycle use is taken from that same root; `--from` without a manifest for this host falls + // back to the given directory and the bundle step reports the missing manifest. const listingCwd = await listingDirectory(options.from, host, home); const listing: PublicHostListing = host === 'cursor' || probed.probe.status !== 'available' ? { detail: `${host} is not available`, status: 'unavailable' } diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 75948cc9a..4683b3e28 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -178,19 +178,11 @@ const readString = ( return value; }; +/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ const resolveBundleRoot = async (from: string, host: InstallHost): Promise => { const root = resolve(from); - const manifest = hostManifestPath(host); - if (await exists(join(root, manifest))) return root; - const targetRoot = join(root, host); - if (await exists(join(targetRoot, manifest))) return targetRoot; - 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.`, - host, - ); + if (await exists(join(root, hostManifestPath(host)))) return root; + throw failure('AB7001', `No ${host} bundle manifest was found in ${JSON.stringify(root)}.`, host); }; /** The plugin identity an install or uninstall acts on, read from the bundle's host manifests. */ diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 2f2ada271..2a4f093a9 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,10 +1,6 @@ import type { NormalizedPlugin } from '../core/types.ts'; -import { - sortedEntries, - sourceInputs, - type TargetArtifactPlan, - type TargetArtifactWrite, -} from '../adapters/types.ts'; +import { type BuiltInHost, builtInHostNames } from '../adapters/composite-layout.ts'; +import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; import { installReceiptFile, installReceiptFormat, @@ -14,8 +10,6 @@ import { preservedRuntimeEntries, } from './receipt.ts'; -export type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; - const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; const pluginId = (model: NormalizedPlugin): string => `${model.metadata.name}@${marketplaceName(model)}`; @@ -280,28 +274,38 @@ 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)}.`); - } +/** + * The built-in hosts among the selected projections, in the fixed order the + * install surface documents them, so the surface never depends on the order + * `targets` was written in (#555 acceptance 5). Advanced registries that + * select a custom target get no section for it. + */ +const selectedBuiltInTargets = (selected: readonly string[]): readonly BuiltInHost[] => + builtInHostNames.filter((target) => selected.includes(target)); + +const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] => { + switch (target) { + case 'claude': + return claudeInstructions(model); + case 'codex': + return codexInstructions(model); + case 'cursor': + return cursorInstructions(model); + case 'portable': + return portableInstructions(); + default: { + const exhaustive: never = target; + throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); } - })(); - return [...header(model), ...sections].join('\n'); + } }; +/** One `INSTALL.md` for the composite root: a section per selected built-in host. */ +const installMarkdown = (model: NormalizedPlugin, selected: readonly string[]): string => [ + ...header(model), + ...selectedBuiltInTargets(selected).flatMap((target) => instructionsFor(model, target)), +].join('\n'); + /** * The `--uninstall` half of the standalone installer, mirroring * `install/uninstall.ts` for the two Cursor deliveries: a receipt naming this @@ -1384,46 +1388,54 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ].join('\n'); }; -const needsCursorInstaller = (target: BuiltInTarget): boolean => - target === 'cursor' || target === 'plugin' || target === 'portable'; +/** The self-contained installer ships when a selected host has no non-interactive install verb. */ +const needsCursorInstaller = (selected: readonly string[]): boolean => + selected.includes('cursor') || selected.includes('portable'); +/** + * The install-surface files a composite root with these selected projections + * must contain: `INSTALL.md` whenever a built-in host is selected, plus + * `install.mjs` when Cursor or the portable format is among them. `hosts` is + * the selection resolved by adapter identity (`TargetRegistry.builtInHosts`), + * so an advanced registry's own adapter named like a built-in host requires + * no surface (#592). + */ export const installSurfaceRequirements = ( - target: string, + hosts: readonly BuiltInHost[], ): 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([]); + if (hosts.length === 0) return Object.freeze([]); + return needsCursorInstaller(hosts) + ? Object.freeze(['INSTALL.md', 'install.mjs']) + : Object.freeze(['INSTALL.md']); }; +/** + * The install surface of one composite root, emitted once over every selected + * built-in host rather than by each host planner (#555); `hosts` is the + * selection resolved by adapter identity (`TargetRegistry.builtInHosts`), so a + * custom adapter sharing a built-in host's name ships no instructions it never + * asked for (#592). The distribution-form aware rewrite of these documents is a + * later step; this composes the existing per-host sections. + */ export const installSurfaceEntries = ( model: NormalizedPlugin, - target: BuiltInTarget, -): readonly TargetArtifactWrite[] => Object.freeze([ - Object.freeze({ - content: installMarkdown(model, target), - kind: 'write' as const, - relativePath: 'INSTALL.md', - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), - }), - ...(needsCursorInstaller(target) - ? [Object.freeze({ - content: cursorInstallerSource(model), - kind: 'write' as const, - relativePath: 'install.mjs', - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), - })] - : []), -]); - -export const withInstallSurface = ( - plan: TargetArtifactPlan, - model: NormalizedPlugin, - target: BuiltInTarget, -): TargetArtifactPlan => Object.freeze({ - ...plan, - entries: sortedEntries([...plan.entries, ...installSurfaceEntries(model, target)]), -}); + hosts: readonly BuiltInHost[], +): readonly TargetArtifactWrite[] => { + if (hosts.length === 0) return Object.freeze([]); + return Object.freeze([ + Object.freeze({ + content: installMarkdown(model, hosts), + kind: 'write' as const, + relativePath: 'INSTALL.md', + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), + }), + ...(needsCursorInstaller(hosts) + ? [Object.freeze({ + content: cursorInstallerSource(model), + kind: 'write' as const, + relativePath: 'install.mjs', + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), + })] + : []), + ]); +}; diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 069c80d84..c698c82c7 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -640,14 +640,20 @@ export type GeneratedRouteExecutionHost = WarmFlightHost; * harness. An artifact with no event routes passes nothing. */ export interface GeneratedEventRuntimeBinding { + /** + * The hosts whose hook wrappers may deliver events here — every selected + * host the composite root's shared runtime serves (#555). The invoking host + * arrives with each request; the artifact itself has no host identity (#592). + */ readonly allowedTargets: readonly string[]; readonly artifactEpoch: string; readonly createCanonicalEventProps: typeof createCanonicalEventProps; readonly createEventRuntimeServer: typeof createEventRuntimeServer; - /** Identifies this artifact's socket, so two installs never share a runtime. */ + /** Identifies this artifact's socket (epoch and root), so two installs never share a runtime. */ readonly endpointId: string; + /** The selected hosts whose MCP documents list this server — the hosts that can have launched it. */ + readonly hosts: readonly string[]; readonly projectEventDocument: typeof projectEventDocument; - readonly target: string; } /** @@ -1056,13 +1062,20 @@ export const createGeneratedRouteMcpServer = async ( const events = options.events === undefined ? undefined : await startEventRuntime(options.events, dispatcher, options.host, afterRender, options.lineage, options.pluginRoot); + // The tool-call lineage fallback is a host projection, never the artifact's + // identity (#592). `hosts` is the selected hosts whose MCP documents list + // this server (`server.targets ∩ selected`) — the hosts that can have + // spawned this process, distinct from `allowedTargets`, the hosts whose hook + // wrappers the runtime it hosts accepts. When exactly one host can, an MCP + // client that does not name itself is assumed to be that host; when several + // can there is no single host to assume and the client's own name decides. + const [onlyHost, ...otherHosts] = options.events?.hosts ?? []; + const lineageHost = onlyHost !== undefined && otherHosts.length === 0 ? lineageHostFor(onlyHost) : undefined; registerGeneratedRoutes(server, options.routes, dispatcher, options.artifactEpoch, { ...(afterRender === undefined ? {} : { afterRender }), ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), ...(options.lineage === undefined ? {} : { lineage: options.lineage, rawArguments: captureRawToolArguments(server) }), - ...(options.events === undefined || lineageHostFor(options.events.target) === undefined - ? {} - : { lineageHost: lineageHostFor(options.events.target) }), + ...(lineageHost === undefined ? {} : { lineageHost }), tasks, }); registerGeneratedMcpApps(server, options.apps ?? []); diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index ea097d204..2f185c241 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -255,7 +255,7 @@ interface ConfigClaimedSources { /** * Modules explicit `bin` entries compile. They leave route discovery too, * except a direct `src/scripts/` child: `dist/bin/.js` and - * `/scripts/.mjs` are disjoint outputs and both envelopes + * the artifact's `scripts/.mjs` are disjoint outputs and both envelopes * run the same `main`, so one entry ships as both an npm bin and an * artifact script instead of silently losing the script (#389). */ diff --git a/packages/agent-bundle/src/rstest/browser-setup-module.ts b/packages/agent-bundle/src/rstest/browser-setup-module.ts index 888521266..4a5a8f335 100644 --- a/packages/agent-bundle/src/rstest/browser-setup-module.ts +++ b/packages/agent-bundle/src/rstest/browser-setup-module.ts @@ -12,7 +12,7 @@ import { } from '../test/browser-registry.ts'; import { BROWSER_APP_PROOF_LEVEL, proofLevelLabel } from '../test/manifest.ts'; -const compiledEntry = async (app: CompiledMcpApp): Promise => { +const compiledEntry = async (app: CompiledMcpApp, host: string): Promise => { const html = await readFile(app.output, 'utf8'); const bytes = Buffer.byteLength(html, 'utf8'); if (bytes > MAX_APP_HTML_BYTES) { @@ -31,7 +31,7 @@ const compiledEntry = async (app: CompiledMcpApp): Promise>, ): Promise => { const apps = Object.fromEntries( - await Promise.all(compiled.map(async (app) => [app.name, await compiledEntry(app)] as const)), + await Promise.all(compiled.map(async (app) => { + const host = hosts[app.name]; + if (host === undefined) { + throw new Error(`Compiled MCP App ${JSON.stringify(app.name)} has no browser mount host.`); + } + return [app.name, await compiledEntry(app, host)] as const; + })), ); const registry: AgentBrowserTestRegistry = Object.freeze({ apps, diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index ca4512c66..3f4c7ae31 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { mkdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; +import { projectionIdentity, sortedProjections } from '../adapters/composite-layout.ts'; import { compileMcpApps } from '../build/mcp-apps.ts'; import type { NormalizedMcpApp } from '../core/types.ts'; import { compileTestManifest, proofLevelLabel, type TestableAppDescriptor } from '../test/manifest.ts'; @@ -20,8 +21,12 @@ export interface AgentBundleBrowserRstestOptions { /** Extra setup files, appended after the generated browser registry. */ readonly setupFiles?: readonly string[]; /** - * Compiles every app for this target. By default each app uses its first - * declared target that is also selected by the project. + * Mounts every app as this host: the preview profile and the binding's + * `target` the page sees. By default each app mounts as its first declared + * target that the project also selects. The apps themselves are compiled + * once for the project's whole selection, as the build stages them (#555) — + * the host is projection context for the harness, never part of the + * compiled bytes (#592). */ readonly target?: string; } @@ -49,7 +54,12 @@ export interface AgentBundleBrowserRstestConfig { }; } -const appTarget = ( +/** + * The host one app mounts as in the browser pool — the override, or the + * app's first declared target the project also selects — which every declared + * app must have: an app reaching none of the selection cannot be mounted. + */ +const appHost = ( app: TestableAppDescriptor, projectTargets: readonly string[], override: string | undefined, @@ -58,7 +68,7 @@ const appTarget = ( const target = override ?? app.targets.find((candidate) => projectTargets.includes(candidate)); if (target === undefined || !projectTargets.includes(target) || !app.targets.includes(target)) { throw new Error([ - `MCP App ${JSON.stringify(app.name)} has no browser compilation target selected by the project.`, + `MCP App ${JSON.stringify(app.name)} has no browser mount host selected by the project.`, ` proof level: ${proofLevelLabel('browser-app')}`, ` app: ${app.name} (${app.resourceUri})`, ` config: ${configPath}`, @@ -113,9 +123,10 @@ export const agentBundleBrowserRstest = async ( } const normalized = apps.map((app) => normalizedApp(app, configPath)); - const targets = Object.fromEntries( - apps.map((app) => [app.id, appTarget(app, manifest.targets, options.target, configPath)]), - ); + const hosts = Object.fromEntries(apps.map((app) => [app.name, appHost(app, manifest.targets, options.target, configPath)])); + // The pool compiles the same composite selection the build stages (#555); + // the host each app mounts as is carried beside the compiled bytes. + const selected = sortedProjections(manifest.targets); const outputRoot = resolve(root, '.agent-bundle', 'test', 'browser-app-build'); await rm(outputRoot, { force: true, recursive: true }); await mkdir(outputRoot, { recursive: true }); @@ -128,14 +139,15 @@ export const agentBundleBrowserRstest = async ( version: manifest.plugin.version, }, outDir: outputRoot, - targets, + selected, + target: projectionIdentity(selected), }); if (compiled.length !== apps.length) { throw new Error( `Browser-App compiler emitted ${String(compiled.length)} of ${String(apps.length)} declared apps for ${JSON.stringify(configPath)}.`, ); } - const setup = await writeBrowserTestSetup(root, compiled); + const setup = await writeBrowserTestSetup(root, compiled, hosts); // The compiled app bundles already carry the stamped identity; the alias // covers test files and view helpers the browser pool bundles itself. const metaModule = await writeTestMetaModule(root, manifest); diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 2fe591227..a9be68c36 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -87,7 +87,8 @@ export const resolveMcpStdioLaunch = async ( const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const targetRoot = joinArtifact(artifact, options.target); + // Every selected host reads the composite root as its plugin root (#555). + const targetRoot = artifact; const manifestPath = joinArtifact(targetRoot, runtime.manifestPath); let document: unknown; try { diff --git a/packages/agent-bundle/src/services/mcp-service.ts b/packages/agent-bundle/src/services/mcp-service.ts index bab42d721..4b4d2c7a0 100644 --- a/packages/agent-bundle/src/services/mcp-service.ts +++ b/packages/agent-bundle/src/services/mcp-service.ts @@ -225,7 +225,8 @@ export class McpService { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const targetRoot = joinArtifact(artifact, options.target); + // Every selected host reads the composite root as its plugin root (#555). + const targetRoot = artifact; // The per-connection plugin-data directory lives exactly as long as the // connection: `withTempDirectory` is the `mkdtemp` + `finally rm` bracket // (cleanup failure wins, as the throwing `finally` did). The client and diff --git a/packages/agent-bundle/src/test/browser-registry.ts b/packages/agent-bundle/src/test/browser-registry.ts index 78520db4f..7ad62cb84 100644 --- a/packages/agent-bundle/src/test/browser-registry.ts +++ b/packages/agent-bundle/src/test/browser-registry.ts @@ -11,6 +11,7 @@ export interface CompiledBrowserTestApp { readonly proofLevel: 'browser-app'; readonly resourceUri: string; readonly serverIds: readonly string[]; + /** The host the app mounts as — its preview profile and the binding's `target` — not the compiled selection's identity. */ readonly target: string; } diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 8e7659b67..d930c57a4 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -4,6 +4,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { codexArtifactPaths } from '../adapters/codex.ts'; +import { cursorArtifactPaths } from '../adapters/cursor.ts'; import { artifactManifestName } from '../build/emit.ts'; import { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.ts'; import { parseArtifactManifest } from '../build/manifest.ts'; @@ -124,21 +126,38 @@ const hostManifestPath = (host: InstallHost): string => { } }; -const hostMcpPath = (host: InstallHost): string => - host === 'cursor' ? 'mcp.json' : '.mcp.json'; +/** + * The MCP document the installed host loads: Claude's conventional `.mcp.json`, + * or the document the Codex and Cursor manifests point at beside themselves. + */ +const hostMcpPath = (host: InstallHost): string => { + switch (host) { + case 'claude': + return '.mcp.json'; + case 'codex': + return codexArtifactPaths.mcp; + case 'cursor': + return cursorArtifactPaths.mcp; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); + } + } +}; /** - * The hook document the installed host loads. Claude and Codex read the - * pinned `hooks/hooks.json`; Cursor reads whatever the installed - * `.cursor-plugin/plugin.json` `hooks` field names (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 reads the conventional + * `hooks/hooks.json`; Codex reads the document its manifest points at beside + * itself; Cursor reads whatever the installed `.cursor-plugin/plugin.json` + * `hooks` field names (#438), falling back to `hooks/hooks.json` folder + * discovery when the field is absent. */ const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { switch (host) { case 'claude': - case 'codex': return 'hooks/hooks.json'; + case 'codex': + return codexArtifactPaths.hooksManifest; case 'cursor': { const source = resolveCursorHooksSource(installedManifest); return source.kind === 'file' ? source.path : cursorDefaultHooksPath; @@ -309,18 +328,21 @@ export const openInstalledHostMcpServer = async ( if (target === undefined) { failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare target ${options.host}` }); } + // The composite root is the bundle root for every selected host; a missing + // host manifest is recorded and the checks below still read the root. const builtRoot = await resolveBundleRoot(artifactRoot, options.host).catch(() => { failures.push({ check: 'manifest-schema', reason: `Doctor could not discover the built ${options.host} bundle root` }); - return join(artifactRoot, options.host); + return artifactRoot; }); - const prefix = `${options.host}/`; - const targetFiles = artifactManifest?.files.filter((file) => file.path.startsWith(prefix)) ?? []; + // The composite root is installed whole: every manifest file is part of the + // selected host's bundle, keyed by its root-relative path. + const targetFiles = artifactManifest?.files ?? []; if (targetFiles.length === 0) { failures.push({ check: 'component-paths', reason: `artifact manifest declared no ${options.host} component files` }); } for (const file of targetFiles) { - const targetRelative = file.path.slice(prefix.length); + const targetRelative = file.path; const [builtHash, installedHash] = await Promise.all([ fileHash(join(artifactRoot, file.path)), fileHash(join(installedRoot, targetRelative)), @@ -336,12 +358,9 @@ export const openInstalledHostMcpServer = async ( } } - const resourceFiles = targetFiles.filter((file) => { - const path = file.path.slice(prefix.length); - return path.startsWith('assets/') || path.startsWith('skills/') || path.startsWith('commands/'); - }); - for (const resource of resourceFiles) { - const path = resource.path.slice(prefix.length); + const resourceFiles = targetFiles.filter(({ path }) => + path.startsWith('assets/') || path.startsWith('skills/') || path.startsWith('commands/')); + for (const { path } of resourceFiles) { if (await fileHash(join(installedRoot, path)) === undefined) { failures.push({ check: 'resources', reason: `installed resource ${path} was missing` }); } @@ -403,9 +422,8 @@ export const openInstalledHostMcpServer = async ( failures.push({ check: 'hook-commands', reason: 'installed hook document exposed no commands' }); } for (const hook of installedHooks) { - const path = hook.path.startsWith(prefix) ? hook.path.slice(prefix.length) : hook.path; - if (await fileHash(join(installedRoot, path)) === undefined) { - failures.push({ check: 'hook-commands', reason: `installed hook command target ${path} was missing` }); + if (await fileHash(join(installedRoot, hook.path)) === undefined) { + failures.push({ check: 'hook-commands', reason: `installed hook command target ${hook.path} was missing` }); } } } @@ -449,10 +467,13 @@ export const openInstalledHostMcpServer = async ( ...expandedDeclaredEnvironment, }; + // The runtime's endpoint is identified by the artifact alone — its epoch and + // the installed root the entry lives under (`mcp/`) — however many host + // projections the root carries (#555, #592). const eventRuntimeEndpointPath = artifactManifest === undefined || resolvedEntry === undefined ? undefined : eventRuntimeEndpoint( - `${artifactManifest.project.revision}:${options.host}:${dirname(dirname(resolvedEntry))}`, + `${artifactManifest.project.revision}:${dirname(dirname(resolvedEntry))}`, ); if (eventRuntimeEndpointPath === undefined && failures.length === 0) { failures.push({ check: 'mcp-command', reason: 'installed event runtime endpoint could not be derived' }); diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 36c0a3219..5b7801812 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -557,8 +557,8 @@ export const openInMemoryMcpServer = async < role: () => 'owner', })) as never, endpointId: `${artifactEpoch}:in-memory`, + hosts: [options.lineageHost], projectEventDocument: (() => undefined) as never, - target: options.lineageHost, }, }), ...(options.limits === undefined ? {} : { limits: options.limits }), diff --git a/packages/agent-bundle/src/test/packed.ts b/packages/agent-bundle/src/test/packed.ts index 88de1f760..b03d03620 100644 --- a/packages/agent-bundle/src/test/packed.ts +++ b/packages/agent-bundle/src/test/packed.ts @@ -59,7 +59,7 @@ export interface PackedMcpSessionOptions { * deleted-source proof. */ readonly deletedSource?: DeletedSourceReceipt; - /** Absolute path of the generated stdio entry (`//mcp/.mjs`). */ + /** Absolute path of the generated stdio entry (`/mcp/.mjs`, emitted once at the composite root). */ readonly entry: string; /** Environment for the spawned process; defaults to the current one. */ readonly env?: Readonly>; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index f221f2bdd..bbaa35fd8 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -4,15 +4,12 @@ import { AGENT_NOTICE_DELIVERY_ROUTES, resolveNoticeDisclosure, selectNoticeDeli import type { AgentNoticeDeliveryAdvertisement, AgentNoticeDeliveryRoute } from '@agent-bundle/runtime/notices'; import { - capabilityBooleanView, capabilityEvidence, capabilityIsSupported, - intersectCapabilityStates, intersectNoticeDeliveryAdvertisements, noticeDeliveryAdvertisementFrom, supportedCapability, unavailableCapability, - unionCapabilityStates, } from '../src/adapters/capability-state.ts'; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; @@ -29,23 +26,6 @@ const evidence = (target: string): CapabilityEvidence => Object.freeze({ observedVersion: `${target}-version`, target, }); -const state = (value: CapabilityState): CapabilityState => Object.freeze(value); - -it('keeps the plugin Boolean capability view as the three-host intersection except for LSP', () => { - const registry = createDefaultRegistry(); - - for (const capability of ['commands', 'marketplace', 'hooks', 'mcp', 'rules', 'skills']) { - expect(registry.supports('plugin', capability)).toBe( - registry.supports('claude', capability) && - registry.supports('codex', capability) && - registry.supports('cursor', capability), - ); - } - expect(registry.supports('plugin', 'lsp')).toBe( - registry.supports('claude', 'lsp') && registry.supports('codex', 'lsp'), - ); -}); - it('records an honest four-state commands row on every adapter', () => { const registry = createDefaultRegistry(); for (const target of ['cursor', 'claude'] as const) { @@ -62,13 +42,6 @@ it('records an honest four-state commands row on every adapter', () => { reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', state: 'unavailable', }); - expect(registry.get('plugin').capabilities.commands).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.commands!, - registry.get('codex').capabilities.commands!, - ), - registry.get('cursor').capabilities.commands!, - )); }); it('records an honest four-state rules row on every adapter', () => { @@ -89,13 +62,6 @@ it('records an honest four-state rules row on every adapter', () => { reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', state: 'unavailable', }); - expect(registry.get('plugin').capabilities.rules).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.rules!, - registry.get('codex').capabilities.rules!, - ), - registry.get('cursor').capabilities.rules!, - )); }); const codexParityCapabilityRows = { @@ -127,7 +93,6 @@ const codexParityCapabilityRows = { it('records dated Codex interface, apps, policy, and hook-environment capability rows', () => { const registry = createDefaultRegistry(); const codex = registry.get('codex'); - const unified = registry.get('plugin'); const expectedStates = { apps: { registeredMcpMappings: 'supported' }, hookEnvironment: { @@ -175,11 +140,7 @@ it('records dated Codex interface, apps, policy, and hook-environment capability }), state: expectedState, }); - // The unified bundle intersects every Codex-only surface with an - // honest unavailable row for the hosts that lack it. - expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); expect(registry.supports('codex', capability)).toBe(expectedState === 'supported'); - expect(registry.supports('plugin', capability)).toBe(false); } } @@ -190,7 +151,7 @@ it('records dated Codex interface, apps, policy, and hook-environment capability }); }); -it('reports Claude LSP support and honest unavailable composite coverage', () => { +it('reports Claude LSP support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.lsp).toMatchObject({ @@ -204,13 +165,8 @@ it('reports Claude LSP support and honest unavailable composite coverage', () => reason: expect.stringContaining('no LSP server surface'), state: 'unavailable', }); - expect(registry.get('plugin').capabilities.lsp).toMatchObject({ - reason: expect.stringContaining('no LSP server surface'), - state: 'unavailable', - }); expect(registry.supports('claude', 'lsp')).toBe(true); expect(registry.supports('codex', 'lsp')).toBe(false); - expect(registry.supports('plugin', 'lsp')).toBe(false); }); it('publishes a dated four-state lsp row on every adapter so no host is judged by silence (#100)', () => { @@ -228,16 +184,9 @@ it('publishes a dated four-state lsp row on every adapter so no host is judged b reason: codexCapabilityTable.plugin.components.lsp.reason, state: 'unavailable', }); - // Emission dispatch: the composite still writes Claude's `.lsp.json`, so - // inspection judges the `lsp` kind by the union while the three-host - // intersection above stays honestly unavailable. - expect(registry.get('plugin').componentCapabilities?.lsp).toMatchObject({ - evidence: { target: 'claude' }, - state: 'supported', - }); }); -it('records dated unavailable native-diagnostics and native-extension rows on every host and the composite (#100)', () => { +it('records dated unavailable native-diagnostics and native-extension rows on every host (#100)', () => { const registry = createDefaultRegistry(); const tables = { claude: claudeCapabilityTable.plugin, @@ -257,8 +206,6 @@ it('records dated unavailable native-diagnostics and native-extension rows on ev reason: expect.stringContaining('Agent Plugin contract (1.0.0)'), state: 'unavailable', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ state: 'unavailable' }); - expect(registry.get('plugin').componentCapabilities?.[capability]).toMatchObject({ state: 'unavailable' }); } // Claude's row points at the LSP `diagnostics` option rather than inventing a component. expect(claudeCapabilityTable.plugin.nativeDiagnostics.reason).toContain('`lsp` kind'); @@ -271,7 +218,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) const codex = registry.get('codex').capabilities; const cursor = registry.get('cursor').capabilities; const portable = registry.get('portable').capabilities; - const plugin = registry.get('plugin'); // Commands: Claude documents the five frontmatter fields; Cursor's commands // surface is frontmatter-free, so every field row is unavailable there. @@ -283,9 +229,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) expect(cursor[`commands.${field}`]).toEqual({ reason: cursorCapabilityTable.plugin.commandFrontmatter.reason, state: 'unavailable' }); expect(codex[`commands.${field}`]).toBeUndefined(); expect(portable[`commands.${field}`]).toBeUndefined(); - // Composite: intersection stays honest, emission dispatch follows the Claude half. - expect(plugin.capabilities[`commands.${field}`]).toMatchObject({ state: 'unavailable' }); - expect(plugin.componentCapabilities?.[`commands.${field}`]).toMatchObject({ evidence: { target: 'claude' }, state: 'supported' }); } expect(cursorCapabilityTable.plugin.commandFrontmatter.evidence.some((entry) => entry.startsWith('2026-09-03: '))).toBe(true); @@ -293,7 +236,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) for (const field of ['alwaysApply', 'description', 'globs']) { expect(cursor[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); expect(claude[`rules.${field}`]).toBeUndefined(); - expect(plugin.componentCapabilities?.[`rules.${field}`]).toMatchObject({ evidence: { target: 'cursor' }, state: 'supported' }); } expect(cursorCapabilityTable.plugin.ruleFrontmatter.evidence[0]).toMatch(/^retrieved 2026-09-03: https:\/\/cursor\.com\/docs\/context\/rules/u); @@ -313,22 +255,6 @@ it('publishes dated component feature rows per kind and host (#100 feature sets) for (const capabilities of [codex, cursor, portable]) { expect(capabilities['skills.markdownTokens']).toMatchObject({ reason: expect.stringContaining('AB3008'), state: 'unavailable' }); } - // The composite's shared skills/ tree falls back to the portable document for - // any skill with a host extension or token, so neither feature reaches it. - for (const capability of ['skills.hostFrontmatter', 'skills.markdownTokens']) { - expect(plugin.componentCapabilities?.[capability]).toMatchObject({ reason: expect.stringContaining('portable document'), state: 'unavailable' }); - expect(plugin.capabilities[capability]).toEqual(plugin.componentCapabilities?.[capability]); - } -}); - -it('judges composite event routes by the same intersection validation applies (#100 event-route kind)', () => { - const registry = createDefaultRegistry(); - const plugin = registry.get('plugin'); - for (const [capability, state] of Object.entries(plugin.capabilities)) { - if (!capability.startsWith('event:')) continue; - expect(plugin.componentCapabilities?.[capability]).toEqual(state); - } - expect(plugin.componentCapabilities?.['event:session/start']).toMatchObject({ state: 'supported' }); }); it('reports Claude bin support without inventing coverage on other native hosts', () => { @@ -341,21 +267,13 @@ it('reports Claude bin support without inventing coverage on other native hosts' }, state: 'supported', }); - expect(registry.get('plugin').capabilities.bin).toMatchObject({ - reason: expect.stringContaining('Claude-only bin'), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.bin).toBeUndefined(); } expect(registry.supports('claude', 'bin')).toBe(true); - expect(registry.supports('plugin', 'bin')).toBe(false); }); -it.each([ - ['outputStyles', 'output styles'], - ['workflows', 'workflows'], -] as const)('reports Claude %s support and honest unavailable composite coverage', (capability, label) => { +it.each(['outputStyles', 'workflows'] as const)('reports Claude %s support and honest unavailable coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -365,16 +283,11 @@ it.each([ }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toEqual({ - reason: `The unified bundle emits Claude-only ${label}, but the pinned Codex and Cursor contracts declare no shared ${label} surface.`, - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); }); it('reports Claude plugin settings support and honest unavailable composite coverage', () => { @@ -387,10 +300,6 @@ it('reports Claude plugin settings support and honest unavailable composite cove }, state: 'supported', }); - expect(registry.get('plugin').capabilities.settings).toMatchObject({ - reason: expect.stringContaining('no plugin settings-defaults surface'), - state: 'unavailable', - }); // Codex and Cursor declare no settings row at all, so an absent capability // stays an honest "not declared" rather than an inferred support claim. for (const target of ['codex', 'cursor', 'portable'] as const) { @@ -398,7 +307,6 @@ it('reports Claude plugin settings support and honest unavailable composite cove expect(registry.supports(target, 'settings')).toBe(false); } expect(registry.supports('claude', 'settings')).toBe(true); - expect(registry.supports('plugin', 'settings')).toBe(false); }); const claudeAgentCapabilityRows = { @@ -422,7 +330,7 @@ const claudeAgentCapabilityRows = { tools: 'agents.tools', } as const; -it('records dated unavailable Claude agent rows and mirrors them through the unified adapter', () => { +it('records dated unavailable Claude agent rows', () => { const registry = createDefaultRegistry(); const agents = ( claudeCapabilityTable.plugin as unknown as { @@ -457,22 +365,7 @@ it('records dated unavailable Claude agent rows and mirrors them through the uni reason: row.reason, state: 'unavailable', }); - expect(registry.get('plugin').capabilities[capability]).toEqual(rowName === 'component' - ? intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.agents!, - registry.get('cursor').capabilities.agents!, - ), - unavailableCapability('The pinned Codex plugin contract publishes no plugin agents component.'), - ) - : intersectCapabilityStates( - registry.get('claude').capabilities[capability]!, - unavailableCapability( - 'The pinned Codex plugin contract publishes no plugin agents component, and the pinned Cursor agents component documents only name and description frontmatter, so no shared agent-frontmatter surface exists.', - ), - )); expect(registry.supports('claude', capability)).toBe(false); - expect(registry.supports('plugin', capability)).toBe(false); } }); @@ -517,7 +410,7 @@ it('records the dated G5-gated Cursor agents component row beside the documented expect(registry.supports('cursor', 'agents')).toBe(false); }); -it('reports Claude userConfig support and honest unavailable composite coverage', () => { +it('reports Claude userConfig support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.userConfig).toMatchObject({ @@ -527,18 +420,13 @@ it('reports Claude userConfig support and honest unavailable composite coverage' }, state: 'supported', }); - expect(registry.get('plugin').capabilities.userConfig).toMatchObject({ - reason: expect.stringContaining('Claude-only userConfig'), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.userConfig).toBeUndefined(); } expect(registry.supports('claude', 'userConfig')).toBe(true); - expect(registry.supports('plugin', 'userConfig')).toBe(false); }); -it('reports Claude channels support and honest unavailable composite coverage', () => { +it('reports Claude channels support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.channels).toMatchObject({ @@ -548,21 +436,13 @@ it('reports Claude channels support and honest unavailable composite coverage', }, state: 'supported', }); - expect(registry.get('plugin').capabilities.channels).toEqual({ - reason: 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.channels).toBeUndefined(); } expect(registry.supports('claude', 'channels')).toBe(true); - expect(registry.supports('plugin', 'channels')).toBe(false); }); -it.each([ - ['themes', 'experimental themes'], - ['monitors', 'background monitors'], -] as const)('reports Claude %s support without inventing shared composite coverage', (capability, reason) => { +it.each(['themes', 'monitors'] as const)('reports Claude %s support without inventing coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -572,19 +452,14 @@ it.each([ }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - reason: expect.stringContaining(reason), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); }); -it('reports Claude dependency support and honest unavailable composite coverage', () => { +it('reports Claude dependency support and honest unavailable coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.dependencies).toMatchObject({ @@ -594,16 +469,11 @@ it('reports Claude dependency support and honest unavailable composite coverage' }, state: 'supported', }); - expect(registry.get('plugin').capabilities.dependencies).toMatchObject({ - reason: expect.stringContaining('Claude Code only'), - state: 'unavailable', - }); for (const target of ['codex', 'cursor', 'portable'] as const) { expect(registry.get(target).capabilities.dependencies).toBeUndefined(); expect(registry.supports(target, 'dependencies')).toBe(false); } expect(registry.supports('claude', 'dependencies')).toBe(true); - expect(registry.supports('plugin', 'dependencies')).toBe(false); }); const claudeDistributionPolicyCapabilities = [ @@ -679,9 +549,6 @@ it('records dated unavailable Claude distribution and policy capability rows', ( state: 'unavailable', }); } - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - state: 'unavailable', - }); } expect(distributionPolicy.pluginCliLifecycle.commands).toEqual([ 'init', @@ -740,9 +607,6 @@ it('records dated Claude package, cache, and data lifecycle capability rows', () reason: row.reason, state: row.state, }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - state: 'unavailable', - }); } }); @@ -774,10 +638,7 @@ it('pins the documented Claude dependency precedence and substitution field tabl }); }); -it.each([ - ['marketplaceManifest', 'completed marketplace manifest'], - ['allowCrossMarketplaceDependenciesOn', 'cross-marketplace dependency allowlist'], -] as const)('reports Claude %s support and honest unavailable composite coverage', (capability, reason) => { +it.each(['marketplaceManifest', 'allowCrossMarketplaceDependenciesOn'] as const)('reports Claude %s support and honest unavailable coverage on other hosts', (capability) => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities[capability]).toMatchObject({ @@ -787,10 +648,6 @@ it.each([ }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - reason: expect.stringContaining(reason), - state: 'unavailable', - }); for (const target of ['codex', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); @@ -808,7 +665,6 @@ it.each([ expect(registry.supports('cursor', capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); }); it('pins the authored Claude marketplace source matrix and version gates', () => { @@ -826,7 +682,7 @@ it('pins the authored Claude marketplace source matrix and version gates', () => }); }); -it('reports Claude manifestPaths support without inventing shared composite coverage', () => { +it('reports Claude manifestPaths support without inventing coverage on other hosts', () => { const registry = createDefaultRegistry(); expect(registry.get('claude').capabilities.manifestPaths).toMatchObject({ @@ -836,10 +692,6 @@ it('reports Claude manifestPaths support without inventing shared composite cove }, state: 'supported', }); - expect(registry.get('plugin').capabilities.manifestPaths).toMatchObject({ - reason: expect.stringContaining('custom manifest path rules'), - state: 'unavailable', - }); // Neither the pinned Cursor contract nor Agent Plugins 1.0.0 (#307) defines // custom manifest path rules. for (const target of ['cursor', 'portable'] as const) { @@ -847,10 +699,9 @@ it('reports Claude manifestPaths support without inventing shared composite cove expect(registry.supports(target, 'manifestPaths')).toBe(false); } expect(registry.supports('claude', 'manifestPaths')).toBe(true); - expect(registry.supports('plugin', 'manifestPaths')).toBe(false); }); -it('reports manifest metadata support on every native host and the three-host composite', () => { +it('reports manifest metadata support on every native host', () => { const registry = createDefaultRegistry(); for (const target of ['claude', 'codex', 'cursor'] as const) { @@ -860,10 +711,6 @@ it('reports manifest metadata support on every native host and the three-host co }); expect(registry.supports(target, 'manifestMetadata')).toBe(true); } - expect(registry.get('plugin').capabilities.manifestMetadata).toMatchObject({ - evidence: { target: 'claude+codex+cursor' }, - state: 'supported', - }); // Agent Plugins 1.0.0 §5.4 defines manifest metadata for the portable manifest (#307). expect(registry.get('portable').capabilities.manifestMetadata).toMatchObject({ evidence: { observedVersion: '1.0.0', target: 'portable' }, @@ -941,106 +788,6 @@ it('records dated Codex manifest and package capability rows', () => { ]); }); -it('mirrors Codex manifest metadata and path states through the unified adapter', () => { - const registry = createDefaultRegistry(); - - expect(registry.get('codex').capabilities.manifestMetadata).toMatchObject({ - evidence: { target: 'codex' }, - state: 'supported', - }); - expect(registry.get('codex').capabilities.manifestPaths).toMatchObject({ - evidence: { target: 'codex' }, - reason: expect.stringContaining('canonical'), - state: 'degraded', - }); - expect(registry.get('plugin').capabilities.manifestMetadata).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.manifestMetadata!, - registry.get('codex').capabilities.manifestMetadata!, - ), - registry.get('cursor').capabilities.manifestMetadata!, - )); - expect(registry.get('plugin').capabilities.manifestPaths).toEqual(intersectCapabilityStates( - intersectCapabilityStates( - registry.get('claude').capabilities.manifestPaths!, - registry.get('codex').capabilities.manifestPaths!, - ), - unavailableCapability( - 'The pinned Cursor plugin contract does not share the Codex and Claude custom manifest path rules.', - ), - )); -}); - -it('intersects supported composite capabilities and merges both evidence records', () => { - const intersection = intersectCapabilityStates( - supportedCapability(evidence('claude')), - supportedCapability(evidence('codex')), - ); - - expect(intersection.state).toBe('supported'); - if (intersection.state !== 'supported') throw new Error('Expected a supported capability intersection.'); - expect(intersection.evidence).toMatchObject({ - observedVersion: 'claude@claude-version+codex@codex-version', - target: 'claude+codex', - }); -}); - -it('applies prohibited, unavailable, degraded, and supported intersection precedence', () => { - const supported = supportedCapability(evidence('supported')); - const degraded = state({ state: 'degraded', reason: 'degraded host', evidence: evidence('degraded') }); - const unavailable = state({ state: 'unavailable', reason: 'unavailable host' }); - const prohibited = state({ state: 'prohibited', reason: 'prohibited host' }); - - for (const other of [supported, degraded, unavailable]) { - expect(intersectCapabilityStates(other, prohibited)).toEqual(prohibited); - expect(intersectCapabilityStates(prohibited, other)).toEqual(prohibited); - } - for (const other of [supported, degraded]) { - expect(intersectCapabilityStates(other, unavailable)).toEqual(unavailable); - expect(intersectCapabilityStates(unavailable, other)).toEqual(unavailable); - } - expect(intersectCapabilityStates(supported, degraded)).toMatchObject({ - state: 'degraded', - reason: 'degraded host', - }); - expect(intersectCapabilityStates(degraded, supported)).toMatchObject({ - state: 'degraded', - reason: 'degraded host', - }); -}); - -it('unions host capability states according to composite emission dispatch', () => { - const supported = supportedCapability(evidence('supported')); - const unavailable = unavailableCapability('unavailable host'); - const prohibited = state({ state: 'prohibited', reason: 'prohibited host' }); - - expect(unionCapabilityStates(supported, unavailable)).toEqual(supported); - expect(unionCapabilityStates(unavailable, supported)).toEqual(supported); - expect(unionCapabilityStates( - unavailableCapability('second unavailable host'), - unavailable, - )).toEqual({ - reason: 'second unavailable host; unavailable host', - state: 'unavailable', - }); - expect(unionCapabilityStates(prohibited, supported)).toEqual(supported); - expect(unionCapabilityStates(supported, prohibited)).toEqual(supported); -}); - -it('keeps the Boolean compatibility view thin and exhaustive', () => { - expect(capabilityBooleanView({ - degraded: { state: 'degraded', reason: 'partial' }, - prohibited: { state: 'prohibited', reason: 'policy' }, - supported: supportedCapability(evidence('supported')), - unavailable: { state: 'unavailable', reason: 'missing' }, - })).toEqual({ - degraded: false, - prohibited: false, - supported: true, - unavailable: false, - }); -}); - const malformed = (value: unknown): CapabilityState => value as CapabilityState; it('recognizes only the four contract states with their required fields', () => { @@ -1063,15 +810,11 @@ it('recognizes only the four contract states with their required fields', () => it('raises a typed error for an unknown state instead of fabricating a truthy one', () => { const unknown = malformed({ state: 'suported' }); - const supported = supportedCapability(evidence('cursor')); // The bug this covers: the exhaustive default returned the capability object, // so an untyped adapter's typo read as truthy support. expect(() => capabilityIsSupported(unknown)).toThrow(CapabilityStateError); expect(() => capabilityIsSupported(unknown)).toThrow(/outside the degraded\/prohibited\/supported\/unavailable contract/u); - expect(() => capabilityBooleanView({ mcp: unknown })).toThrow(CapabilityStateError); - expect(() => intersectCapabilityStates(unknown, supported)).toThrow(CapabilityStateError); - expect(() => intersectCapabilityStates(supported, unknown)).toThrow(CapabilityStateError); const thrown = (() => { try { @@ -1168,31 +911,6 @@ it('publishes the routed CLI bin capability with its bin layout on every built-i expect(registry.hostsComponent('unknown-target', 'cli')).toBe(false); }); -it('validates lowersConfigExtensions at registration and answers extension lowering per target (#100)', () => { - const source = createDefaultRegistry().get('cursor'); - for (const malformedValue of ['claude', ['claude', 42], [' ']]) { - expect(() => new TargetRegistry().register({ - ...source, - lowersConfigExtensions: malformedValue as never, - })).toThrow(/lowersConfigExtensions must be an array of nonempty extension keys/u); - } - const registry = createDefaultRegistry(); - // Own key, declared composite sides, and nothing else. - expect(registry.lowersConfigExtension('claude', 'claude')).toBe(true); - expect(registry.lowersConfigExtension('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); - // Ownership is answered from the registration snapshot: mutating a live - // adapter's configExtension afterwards changes nothing. - const mutable = { ...createDefaultRegistry().get('cursor'), configExtension: { key: 'mutable' }, name: 'mutable-host' }; - const snapshotted = new TargetRegistry().register(mutable); - mutable.configExtension.key = 'renamed'; - expect(snapshotted.lowersConfigExtension('mutable-host', 'mutable')).toBe(true); - expect(snapshotted.lowersConfigExtension('mutable-host', 'renamed')).toBe(false); -}); - it('rejects a malformed inspection component capability when the adapter registers', () => { const source = createDefaultRegistry().get('cursor'); @@ -1281,33 +999,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 +1011,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 +1188,9 @@ it('pins every documented Cursor hook event exactly once across canonical routes }); }); -it('records dated Cursor contract rows and mirrors every one through the unified adapter (#189)', () => { +it('records dated Cursor contract rows (#189)', () => { const registry = createDefaultRegistry(); const cursor = registry.get('cursor'); - const unified = registry.get('plugin'); const expectedStates = { agentPluginFormat: 'unavailable', agents: 'unavailable', @@ -1547,13 +1235,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'], @@ -1567,8 +1248,8 @@ it('records dated Cursor contract rows and mirrors every one through the unified }); expect(cursorCapabilityTable.plugin.componentDiscovery.emitted).toEqual({ commands: './commands/', - hooks: './hooks/hooks.json', - mcpServers: './mcp.json', + hooks: './.cursor-plugin/hooks.json', + mcpServers: './.cursor-plugin/mcp.json', rules: './rules/', skills: './skills/', }); @@ -1598,16 +1279,6 @@ it('exposes each host advertisement through the adapter and registry, typed for kind: 'selected', routes: ['mcp-resource-updated', 'mcp-inbox'], }); - // The unified bundle serves all three hosts, so it advertises their intersection. - const plugin = registry.noticeDelivery('plugin')!; - expect(plugin).toEqual(intersectNoticeDeliveryAdvertisements( - intersectNoticeDeliveryAdvertisements(registry.noticeDelivery('claude')!, registry.noticeDelivery('codex')!), - registry.noticeDelivery('cursor')!, - )); - expect(selectNoticeDeliveryRoutes(plugin)).toEqual({ - kind: 'selected', - routes: ['mcp-resource-updated', 'mcp-inbox', 'next-event'], - }); expect(() => registry.noticeDelivery('unknown')).toThrow(/Unknown target adapter/u); }); @@ -1643,16 +1314,13 @@ it('advertises dated sensitivity ceilings per route and host (#99 acceptance ite expect(ceiling('portable', 'mcp-inbox')).toBe('internal'); expect(ceiling('portable', 'mcp-resource-updated')).toBe('internal'); // Every named ceiling carries dated evidence. - for (const host of ['claude', 'codex', 'cursor', 'portable', 'plugin']) { + for (const host of ['claude', 'codex', 'cursor', 'portable']) { for (const route of NOTICE_DELIVERY_ROUTES) { const entry = registry.noticeDelivery(host)![route]; if (entry.state !== 'supported' || entry.sensitivity === undefined) continue; expect(entry.sensitivityEvidence).toMatch(/2026-09-03/u); } } - // The composite plugin target takes the lowest ceiling of its hosts. - expect(ceiling('plugin', 'next-event')).toBe('secret'); - expect(ceiling('plugin', 'mcp-inbox')).toBe('internal'); // The runtime resolves the same ceilings into disclosure decisions. expect(resolveNoticeDisclosure('mcp-inbox', 'secret', registry.noticeDelivery('claude')!)) .toEqual({ kind: 'withheld', reason: 'sensitivity-exceeds-route' }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 266b262db..57091806b 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -170,7 +170,6 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.29.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 14746a769..1cb4aa494 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -256,7 +256,7 @@ it('accepts claude.userConfig through the public inspection and build APIs', asy await build({ output: artifact, root }); const manifest = JSON.parse( - await readFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), 'utf8'), + await readFile(join(artifact, '.claude-plugin', 'plugin.json'), 'utf8'), ) as Record; expect(manifest).toHaveProperty('userConfig.api_token.sensitive', true); } finally { @@ -419,7 +419,7 @@ it('build runs the Claude developer validator and load check over built claude t const root = await createProject(); try { const artifact = join(root, 'artifact'); - const claudeBundle = join(artifact, 'claude'); + const claudeBundle = artifact; const calls: string[][] = []; const runner: ClaudePluginCommandRunner = async (request) => { calls.push([...request.args]); @@ -454,7 +454,7 @@ it('build runs the Claude developer validator and load check over built claude t ['plugin', 'validate', join(claudeBundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'], ['--plugin-dir', claudeBundle, 'plugin', 'list', '--json'], ]); - // Codex is built too, but only claude/plugin targets have a Claude validator. + // Codex is built too, but only the claude projection has a Claude validator. expect(validated.build.manifest.targets.map((target) => target.name).sort()).toEqual(['claude', 'codex']); expect(validated.hostValidation).toEqual([ expect.objectContaining({ host: 'claude', load: { status: 'loaded' }, status: 'warnings', target: 'claude', version: '2.1.259' }), @@ -477,7 +477,7 @@ it('build surfaces a Claude load refusal as AB7325 even when plugin validate --s const root = await createProject(); try { const artifact = join(root, 'artifact'); - const claudeBundle = join(artifact, 'claude'); + const claudeBundle = artifact; const errors = ['Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file.']; const runner: ClaudePluginCommandRunner = async (request) => { if (request.args[0] === '--version') return { exitCode: 0, signal: null, stderr: '', stdout: '2.1.259 (Claude Code)\n' }; @@ -512,7 +512,7 @@ it('build reports one informational AB6019 skip for all Claude-validated targets await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'plugin', 'codex'],", + " targets: ['claude', 'codex'],", '};', '', ].join('\n')); @@ -528,7 +528,6 @@ it('build reports one informational AB6019 skip for all Claude-validated targets expect(spawns).toBe(1); expect(result.hostValidation?.map((report) => [report.target, report.status, report.diagnostics.length])).toEqual([ ['claude', 'unavailable', 1], - ['plugin', 'unavailable', 0], ]); expect(result.diagnostics.filter((entry) => entry.code === 'AB6019')).toEqual([ expect.objectContaining({ severity: 'info', target: 'claude' }), @@ -844,12 +843,15 @@ it('reports skipped target/component pairs against each target emission surface' mkdir(join(root, 'src', 'rules'), { recursive: true }), ]); await Promise.all([ - writeFile(join(root, 'src', 'commands', 'shared.md'), '---\ndescription: Shared command\n---\nShared command prompt.\n'), + // Claude Code and Cursor both discover `commands/` in the one composite + // root (#555): a command shared by both must lower to identical bytes + // (so no frontmatter, which only Claude would emit), and a command scoped + // to one of them cannot be isolated there (AB4105), so the host-scoped + // command selects both and is excluded only from Codex and portable. + writeFile(join(root, 'src', 'commands', 'shared.md'), 'Shared command prompt.\n'), writeFile( - // Cursor's commands surface is frontmatter-free, so a Cursor-required - // command carries only the authoring-only `targets` key (#100 feature sets). - join(root, 'src', 'commands', 'cursor-only.md'), - '---\ntargets:\n - cursor\n---\nCursor command prompt.\n', + join(root, 'src', 'commands', 'cursor-claude.md'), + '---\ntargets:\n - cursor\n - claude\n---\nCursor and Claude command prompt.\n', ), writeFile(join(root, 'src', 'report.ts'), 'export const report = true;\n'), writeFile(join(root, 'src', 'rules', 'shared.mdc'), '---\ndescription: Shared rule\n---\nShared guidance.\n'), @@ -862,7 +864,7 @@ it('reports skipped target/component pairs against each target emission surface' " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", - " targets: ['portable', 'codex', 'claude', 'cursor', 'plugin'],", + " targets: ['portable', 'codex', 'claude', 'cursor'],", '};', '', ].join('\n')), @@ -872,7 +874,7 @@ it('reports skipped target/component pairs against each target emission surface' const planFor = (target: string) => result.plans.find((plan) => plan.target === target); expect(planFor('portable')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'command', name: 'cursor-claude', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'hook', name: 'sessionStart', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), @@ -917,13 +919,12 @@ it('reports skipped target/component pairs against each target emission surface' ); } expect(planFor('codex')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'command', name: 'cursor-claude', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), ]); expect(planFor('claude')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), @@ -935,15 +936,6 @@ it('reports skipped target/component pairs against each target emission surface' component.kind === 'command' && component.name === 'shared')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'command')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'rule')).toBe(false); - expect(planFor('plugin')?.skipped).toEqual([ - expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), - expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), - expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), - ]); - expect(planFor('plugin')?.entries).toEqual(expect.arrayContaining([ - expect.objectContaining({ relativePath: 'commands/shared.md' }), - expect.objectContaining({ relativePath: 'rules/shared.mdc' }), - ])); expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -956,7 +948,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 +963,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 +977,9 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per ]; // The Claude-declared LSP server is one `lsp` component. Its declaration - // is host-scoped, so it targets only the adapters that lower `claude.*` - // (Claude and the composite, which plans the Claude side); every other - // host reads as excluded by the declaration and still carries its own - // dated `lsp` judgment so the omission is explained in the host's words. + // is host-scoped, so it targets only the adapter that lowers `claude.*`; + // every other host reads as excluded by the declaration and still carries + // its own dated `lsp` judgment so the omission is explained in the host's words. expect(componentsOf('claude', 'lsp')).toEqual([expect.objectContaining({ capability: expect.objectContaining({ evidence: expect.objectContaining({ target: 'claude' }), name: 'lsp', state: 'supported' }), id: 'lsp:claude:typescript', @@ -996,11 +987,6 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per outcome: 'selected', })]); expect(planFor('claude').entries).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: '.lsp.json' })])); - expect(componentsOf('plugin', 'lsp')).toEqual([expect.objectContaining({ - capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), - outcome: 'selected', - })]); - expect(planFor('plugin').entries).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: '.lsp.json' })])); for (const target of ['codex', 'cursor', 'portable']) { expect(componentsOf(target, 'lsp')).toEqual([expect.objectContaining({ capability: { name: 'lsp', reason: expect.stringMatching(/no LSP server/u), state: 'unavailable' }, @@ -1015,7 +1001,7 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per id: 'lsp:claude:typescript', name: 'typescript', provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, - targets: ['claude', 'plugin'], + targets: ['claude'], }]); // Filesystem event routes report separately from config-declared hooks, @@ -1025,7 +1011,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 +1066,6 @@ it('accounts lsp servers and event routes as distinct canonical kinds with a per expect(planFor('portable').kinds.find((report) => report.kind === 'agent')).toMatchObject({ capability: { name: 'agents', reason: 'The portable adapter publishes no agents capability row.', state: 'unavailable' }, }); - // The composite judges kinds by emission dispatch but keeps every published - // intersection row, so its G5 agents deferral reason survives into inspect. - expect(planFor('plugin').kinds.find((report) => report.kind === 'agent')).toMatchObject({ - capability: { name: 'agents', reason: expect.stringContaining('#220'), state: 'unavailable' }, - }); expect(planFor('portable').kinds.find((report) => report.kind === 'event-route')).toEqual({ kind: 'event-route', selected: 0, skipped: 1 }); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -1147,13 +1128,11 @@ it('accounts an admitted degraded event route as selected, matching the validati } }); -it('judges event-route admission and lsp inheritance by the component-emission override, not the top-level row (#100)', async () => { +it('judges event-route admission and lsp emission by the component-emission override, not the top-level row (#100)', async () => { const root = await createProject(); const degradedContract = { ...syntheticHookContract, eventRouteNames: { 'session/start': 'SessionStart' } } satisfies TargetHookContract; - // The declaring adapter follows the composite-emission pattern: its - // top-level rows are honest intersections, its component overrides decide - // emission. A second adapter that lowers the `synthetic` extension inherits - // the LSP declaration because the override, not the intersection, governs. + // The declaring adapter publishes honest top-level rows while its component + // overrides decide emission: the override, not the top-level row, governs. const registry = new TargetRegistry() .register({ ...syntheticAdapter, @@ -1168,15 +1147,7 @@ it('judges event-route admission and lsp inheritance by the component-emission o const hooks = planHooks(model, syntheticTarget, degradedContract); return Object.freeze({ diagnostics: hooks.diagnostics, entries: Object.freeze([]), hookEntries: hooks.hookEntries }); }, - }, { default: true }) - .register({ - ...syntheticAdapter, - capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), - configExtension: undefined, - lowersConfigExtensions: ['synthetic'], - name: 'composite', - plan: () => Object.freeze({ diagnostics: [], entries: Object.freeze([]) }), - }); + }, { default: true }); try { await mkdir(join(root, 'src', 'events', 'session'), { recursive: true }); await Promise.all([ @@ -1191,7 +1162,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 +1176,7 @@ it('judges event-route admission and lsp inheritance by the component-emission o expect.objectContaining({ capability: expect.objectContaining({ name: 'event:session/start', state: 'supported' }), kind: 'event-route' }), expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), ])); - expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', targets: [syntheticTarget, 'composite'] })]); + expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', targets: [syntheticTarget] })]); } finally { await rm(join(root, '..'), { force: true, recursive: true }); } @@ -1256,15 +1227,22 @@ it('reports omitted component features per target from the host feature rows (#1 'export default {', " hooks: { beforeTool: { handler: './src/hook.ts', timeout: 3, tools: ['shell'] } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'cursor', 'plugin'],", + " targets: ['claude', 'cursor'],", '};', '', ].join('\n')), ]); - const result = await readyInspection({ root }); + // A command with frontmatter lowers to different bytes on Claude Code and + // Cursor, so the two hosts cannot share one composite root (AB4103); each + // host's feature accounting is read from its own root. + const [claudeResult, cursorResult] = await Promise.all([ + readyInspection({ root, targets: ['claude'] }), + readyInspection({ root, targets: ['cursor'] }), + ]); const selectedOn = (target: string, kind: string) => - result.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === kind)!; + (target === 'claude' ? claudeResult : cursorResult).plans + .find((plan) => plan.target === target)!.selected.find((component) => component.kind === kind)!; // Cursor ships the command body only: both authored fields are reported as // omitted with the host's own `commands.` judgment, in feature order. @@ -1273,39 +1251,37 @@ it('reports omitted component features per target from the host feature rows (#1 { capability: { name: 'commands.description', reason: expect.stringContaining('frontmatter-free'), state: 'unavailable' }, feature: 'description' }, ]); expect(Object.isFrozen(selectedOn('cursor', 'command').omittedFeatures)).toBe(true); - // Claude documents every field and the composite emits Claude-format - // commands, so neither omits anything; a component with no omissions has no key. + // Claude documents every field, so it omits nothing; a component with no + // omissions has no key. expect(selectedOn('claude', 'command')).not.toHaveProperty('omittedFeatures'); - expect(selectedOn('plugin', 'command')).not.toHaveProperty('omittedFeatures'); // Cursor documents every .mdc field; hooks pin timeout and matchers on both hosts. expect(selectedOn('cursor', 'rule')).not.toHaveProperty('omittedFeatures'); - expect(selectedOn('plugin', 'rule')).not.toHaveProperty('omittedFeatures'); - for (const target of ['claude', 'cursor', 'plugin']) { + for (const target of ['claude', 'cursor']) { expect(selectedOn(target, 'hook')).not.toHaveProperty('omittedFeatures'); } - // A skill with a Claude host extension keeps it on Claude, but the - // composite's shared skills/ tree lowers it to the portable document, so - // the composite reports the host frontmatter as omitted. + // A skill with a Claude host extension keeps it on Claude alone. await writeFile( join(root, 'src', 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Reviews changes\ntargets:\n claude:\n model: sonnet\n---\n# Review\n', ); - const withExtension = await readyInspection({ root }); + const withExtension = await readyInspection({ root, targets: ['claude'] }); const skillOn = (target: string) => withExtension.plans.find((plan) => plan.target === target)!.selected.find((component) => component.kind === 'skill')!; expect(skillOn('claude')).not.toHaveProperty('omittedFeatures'); - expect(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'); - // `validate` surfaces the matching omit-with-reason warnings for the - // implicit Cursor target; inspect stays a ready plan. + expect(withExtension.model.skills[0]!.hostDocuments?.claude?.frontmatter).toHaveProperty('model', 'sonnet'); + // `validate` of the configured claude+cursor selection surfaces the + // omit-with-reason warnings for Cursor beside the composite-root refusal + // of the command the two hosts would lower differently. const validated = await validate({ root }); expect(validated.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4928')).toEqual([ expect.objectContaining({ message: expect.stringContaining('Command "deploy" uses argumentHint, which cursor omits'), severity: 'warning', target: 'cursor' }), expect.objectContaining({ message: expect.stringContaining('Command "deploy" uses description, which cursor omits'), severity: 'warning', target: 'cursor' }), ]); + expect(validated.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4103')).toEqual([ + expect.objectContaining({ generatedPath: 'commands/deploy.md', severity: 'error' }), + // The Claude host extension lowers into Claude's SKILL.md alone. + expect.objectContaining({ generatedPath: 'skills/review/SKILL.md', severity: 'error' }), + ]); expect(validated.diagnostics.some((diagnostic) => diagnostic.code === 'AB4927' || diagnostic.code === 'AB4907' || diagnostic.code === 'AB4908')).toBe(false); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -1319,55 +1295,64 @@ it('never counts an opaque third-party lspServers declaration as emitted by a ho capabilities: supportedCapabilities('hooks', 'lsp', 'mcp'), }); try { - await writeFile(join(root, 'agent-bundle.config.ts'), [ + // A third-party adapter cannot share Claude Code's root (AB4106), so its + // declaration is judged from Claude's own root: normalized as the + // synthetic extension's, reaching no selected projection. + const configFor = (target: string): string => [ 'export default {', " synthetic: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['claude', 'synthetic'],", + ` targets: [${JSON.stringify(target)}],`, '};', '', - ].join('\n')); + ].join('\n'); + await writeFile(join(root, 'agent-bundle.config.ts'), configFor('claude')); const result = await readyInspection({ registry, root }); const planFor = (target: string) => result.plans.find((plan) => plan.target === target)!; // Claude publishes `lsp: supported`, but its planner reads only // `claude.lspServers`; the synthetic declaration is excluded for Claude - // and writes no `.lsp.json` there, while the declaring adapter selects it. - expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: ['synthetic'] })]); + // and writes no `.lsp.json` there. + expect(result.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: [] })]); expect(planFor('claude').skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'lsp', name: 'rust', reason: 'excluded-by-targets' }), ])); expect(planFor('claude').selected.some((component) => component.kind === 'lsp')).toBe(false); expect(planFor('claude').entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); - expect(planFor('synthetic').selected).toEqual(expect.arrayContaining([ + + // In its own root the declaring adapter selects the same declaration. + await writeFile(join(root, 'agent-bundle.config.ts'), configFor('synthetic')); + const syntheticSelected = await readyInspection({ registry, root }); + expect(syntheticSelected.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'synthetic', name: 'rust', targets: ['synthetic'] })]); + expect(syntheticSelected.plans.find((plan) => plan.target === 'synthetic')!.selected).toEqual(expect.arrayContaining([ expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'supported' }), kind: 'lsp', name: 'rust' }), ])); - // A composite that lowers a host's extension inherits its LSP declaration - // only when that host can lower LSP servers: `codex.lspServers` reaches - // neither the Codex half (unavailable) nor the composite bundle. + // A host extension's LSP declaration is emitted only when that host can + // lower LSP servers: `codex.lspServers` stays unavailable on Codex and + // reaches no other selected projection. await writeFile(join(root, 'agent-bundle.config.ts'), [ 'export default {', " codex: { lspServers: { rust: { command: 'rust-analyzer' } } },", " hooks: { sessionStart: { handler: './src/hook.ts' } },", " plugin: { name: 'api-fixture', version: '1.0.0' },", - " targets: ['codex', 'plugin'],", + " targets: ['codex', 'claude'],", '};', '', ].join('\n')); const codexDeclared = await readyInspection({ registry, root }); expect(codexDeclared.model.lspServers).toEqual([expect.objectContaining({ declaredBy: 'codex', targets: ['codex'] })]); const codexPlan = codexDeclared.plans.find((plan) => plan.target === 'codex')!; - const compositePlan = codexDeclared.plans.find((plan) => plan.target === 'plugin')!; + const claudePlan = codexDeclared.plans.find((plan) => plan.target === 'claude')!; expect(codexPlan.skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ capability: expect.objectContaining({ name: 'lsp', state: 'unavailable' }), kind: 'lsp', reason: 'unsupported-capability' }), ])); - expect(compositePlan.skipped).toEqual(expect.arrayContaining([ + expect(claudePlan.skipped).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'lsp', name: 'rust', reason: 'excluded-by-targets' }), ])); - expect(compositePlan.selected.some((component) => component.kind === 'lsp')).toBe(false); - expect(compositePlan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); + expect(claudePlan.selected.some((component) => component.kind === 'lsp')).toBe(false); + expect(claudePlan.entries.some((entry) => entry.relativePath === '.lsp.json')).toBe(false); } finally { await rm(join(root, '..'), { force: true, recursive: true }); } @@ -1472,7 +1457,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati })]); expect(built.build.manifest.targets).toEqual([expect.objectContaining({ name: syntheticTarget })]); expect(built.build.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'synthetic/synthetic-mcp.json' }), + expect.objectContaining({ path: 'synthetic-mcp.json' }), ])); const filesystem = await inspectArtifactFilesystem(artifact); expect(filesystem.entries @@ -1540,9 +1525,11 @@ it('prepares a factory-configured project into a frozen inspection and build res const hookArtifact = join(root, 'hooks-artifact'); const hooks = await build({ output: hookArtifact, root }); + // Both selected hosts share one hooks/ folder; a hook shared by both is + // emitted once per host under a host-suffixed wrapper name. for (const target of ['claude', 'codex']) { expect(hooks.build.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: expect.stringMatching(new RegExp(`^${target}/hooks/.+\\.mjs$`, 'u')) }), + expect.objectContaining({ path: expect.stringMatching(new RegExp(`^hooks/.+\\.${target}\\.mjs$`, 'u')) }), ])); } await expect(validate({ artifact: hookArtifact, root })).resolves.toEqual({ diagnostics: [] }); @@ -1585,7 +1572,7 @@ it('keeps rule and command model digests root-independent and sensitive to conte const config = [ 'export default {', " plugin: { name: 'rule-digest-fixture', version: '1.0.0' },", - " targets: ['cursor', 'claude'],", + " targets: ['cursor'],", '};', '', ].join('\n'); @@ -1894,8 +1881,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 +1969,8 @@ it('copies every supported top-level script output suffix byte-for-byte with sou [sourceShell, 'shell.sh'], [sourcePython, 'python.py'], ] as const; - const checks = await Promise.all(['claude', 'codex', 'portable'].flatMap((target) => - copyOutputs.map(([source, name]) => [source, join(output, target, 'scripts', name)] as const), + const checks = await Promise.all(copyOutputs.map(([source, name]) => + [source, join(output, 'scripts', name)] as const, ).map(async ([source, generated]) => { const [sourceContents, generatedContents, sourceMetadata, generatedMetadata] = await Promise.all([ readFile(source!), @@ -2016,30 +2003,30 @@ it('copies every supported top-level script output suffix byte-for-byte with sou expect.objectContaining({ kind: 'copy', mode: 0o741, - path: 'portable/scripts/bash.bash', + path: 'scripts/bash.bash', sourceInputs: ['agent-bundle.config.ts', 'src/run.BASH'], }), expect.objectContaining({ kind: 'bundle', - path: 'portable/scripts/bundle.mjs', + path: 'scripts/bundle.mjs', sourceInputs: ['agent-bundle.config.ts', 'src/bundle.ts'], }), expect.objectContaining({ kind: 'copy', mode: 0o751, - path: 'portable/scripts/shell.sh', + path: 'scripts/shell.sh', sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'], }), expect.objectContaining({ kind: 'copy', mode: 0o711, - path: 'portable/scripts/python.py', + path: 'scripts/python.py', sourceInputs: ['agent-bundle.config.ts', 'src/run.Py'], }), ])); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); - await chmod(join(output, 'portable', 'scripts', 'shell.sh'), 0o644); + await chmod(join(output, 'scripts', 'shell.sh'), 0o644); await expect(validate({ artifact: output, root })).resolves.toMatchObject({ diagnostics: [{ code: 'AB6004', generatedPath: 'agent-bundle.manifest.json' }], }); @@ -2068,23 +2055,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..d67721102 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -26,7 +26,7 @@ const writeProjectFile = async (root: string, path: string, contents: string): P }; const pluginName = 'cli-bin-artifact'; -const hostTargets = ['claude', 'codex', 'cursor', 'portable', 'plugin'] as const; +const hostTargets = ['claude', 'codex', 'cursor', 'portable'] as const; /** * A host adapter that publishes no `cli` capability row: it stands in for a @@ -35,7 +35,9 @@ const hostTargets = ['claude', 'codex', 'cursor', 'portable', 'plugin'] as const */ const legacyHostAdapter: TargetAdapter = Object.freeze({ artifactLayout: Object.freeze({ - rootDocuments: Object.freeze(['plugin.json']), + // Its one root document is its manifest; a third-party adapter always + // gets a root of its own (AB4106). + rootDocuments: Object.freeze(['legacy-host.json']), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), }), capabilities: Object.freeze({}), @@ -46,7 +48,7 @@ const legacyHostAdapter: TargetAdapter = Object.freeze({ entries: Object.freeze([{ content: `${JSON.stringify({ name: model.metadata.name })}\n`, kind: 'write' as const, - relativePath: 'plugin.json', + relativePath: 'legacy-host.json', sourceInputs: Object.freeze([model.metadata.provenance.sourcePath]), }]), }), @@ -179,53 +181,66 @@ const createFixture = async (options: { const parseJsonLine = (stdout: string): unknown => JSON.parse(stdout) as unknown; /** - * The artifact-hosted routed CLI proof (#387): one build ships the compiled - * `src/cli/**` command graph into every host artifact whose adapter publishes - * the `cli` capability, as `bin/.mjs` (+ Flight worker), the bin - * runs end to end under `node`, a script route reaches it as a sibling, a - * skill reaches it through the plugin-root token, validation accepts the new - * `bin/` layout, and a target without the capability omits it with an inspect - * entry and an AB4765 warning. + * The artifact-hosted routed CLI proof (#387, #555): one build ships the + * compiled `src/cli/**` command graph once into the composite root, as + * `bin/.mjs` (+ Flight worker), whenever a selected host + * publishes the `cli` capability; the bin runs end to end under `node`, a + * script route reaches it as a sibling, a skill reaches it through the + * plugin-root token, validation accepts the `bin/` layout, and a selected + * host without the capability is reported with an inspect entry and an + * AB4765 warning. That host is a third-party adapter, so it cannot join the + * built-in hosts' root (`AB4106`, `build-compose.test.ts`): the same sources + * are built into a root of its own. */ it('emits the routed CLI bin into every capable host artifact and omits it elsewhere', { retry: 1, timeout: 300_000 }, async () => { - const root = await createFixture({ targets: [...hostTargets, 'legacy-host'] }); const registry = registryWithLegacyHost(); + const [root, legacyRoot] = await Promise.all([ + createFixture({ targets: [...hostTargets] }), + createFixture({ targets: ['legacy-host'] }), + ]); - const result = await build({ output: 'artifact', registry, root }); + const [result, legacyResult] = await Promise.all([ + build({ output: 'artifact', registry, root }), + build({ output: 'artifact', registry, root: legacyRoot }), + ]); const artifactRoot = join(root, 'artifact'); + const legacyArtifactRoot = join(legacyRoot, 'artifact'); - // Every capable target hosts the executable and its rendered-command worker. - expect(result.build.compiledCliBins.map((bin) => bin.target).sort()).toEqual([...hostTargets].sort()); - for (const target of hostTargets) { - const binPath = join(artifactRoot, target, 'bin', `${pluginName}.mjs`); - await expect(stat(binPath)).resolves.toMatchObject({}); - await expect(stat(join(artifactRoot, target, 'bin', `${pluginName}-flight.mjs`))).resolves.toMatchObject({}); - const binSource = await readFile(binPath, 'utf8'); - expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); - expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/meta['"]/u); + // The composite root hosts one executable and its rendered-command worker, + // attributed to the whole selection. + const identity = [...hostTargets].sort().join('+'); + expect(result.build.compiledCliBins.map((bin) => bin.target)).toEqual([identity]); + const binPath = join(artifactRoot, 'bin', `${pluginName}.mjs`); + await expect(stat(binPath)).resolves.toMatchObject({}); + await expect(stat(join(artifactRoot, 'bin', `${pluginName}-flight.mjs`))).resolves.toMatchObject({}); + const binSource = await readFile(binPath, 'utf8'); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); + expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/meta['"]/u); + 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', - }); - } - // 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({ + // `node /bin/.mjs ` prints the routed CLI output. + const status = await execFile(process.execPath, [binPath, 'status', 'ticket-7', '--json']); + expect(parseJsonLine(status.stdout)).toEqual({ + invocation: 'cli', + status: 'idle', + surface: 'status', + ticket: 'ticket-7', + }); + // The host without the capability gets no bin in its own root — reported, + // never silent — while its other compiled surfaces are emitted as usual. + expect(legacyResult.build.compiledCliBins).toEqual([]); + await expect(stat(join(legacyArtifactRoot, 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(legacyResult.build.manifest.files.filter((file) => file.path.startsWith('bin/'))).toEqual([]); + await expect(stat(join(legacyArtifactRoot, 'scripts', 'hauler.mjs'))).resolves.toMatchObject({}); + expect(legacyResult.diagnostics).toContainEqual(expect.objectContaining({ code: 'AB4765', severity: 'warning', target: 'legacy-host', })); - expect(result.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + expect(legacyResult.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); // Help, version, and the rendered .tsx command ride the same executable. - const claudeBin = join(artifactRoot, 'claude', 'bin', `${pluginName}.mjs`); + const claudeBin = binPath; const help = await execFile(process.execPath, [claudeBin, '--help']); expect(help.stdout).toContain(`${pluginName} 3.8.7`); expect(help.stdout).toContain('Artifact routed CLI fixture.'); @@ -239,23 +254,24 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew await expect(execFile(process.execPath, [claudeBin, 'unknown'])).rejects.toMatchObject({ code: 2, stdout: '' }); // A script route reaches the bin as its documented sibling and forwards argv. - const forwarded = await execFile(process.execPath, [join(artifactRoot, 'codex', 'scripts', 'hauler.mjs'), 'status', '--verbose', '--json']); + const forwarded = await execFile(process.execPath, [join(artifactRoot, 'scripts', 'hauler.mjs'), 'status', '--verbose', '--json']); expect(parseJsonLine(forwarded.stdout)).toEqual({ invocation: 'cli', status: 'idle', surface: 'status (verbose)' }); - // The composite bundle's AGENTS.md documents the shared executable. - const agents = await readFile(join(artifactRoot, 'plugin', 'AGENTS.md'), 'utf8'); - expect(agents).toContain(`\`bin/${pluginName}.mjs\``); - // The manifest inventories the bin with bundle provenance naming every command route. - const manifestFile = result.build.manifest.files.find((file) => file.path === `portable/bin/${pluginName}.mjs`); + const manifestFile = result.build.manifest.files.find((file) => file.path === `bin/${pluginName}.mjs`); expect(manifestFile).toMatchObject({ kind: 'bundle' }); expect(manifestFile?.sourceInputs).toEqual(expect.arrayContaining(['src/cli/report.tsx', 'src/cli/status.ts'])); - expect(result.build.manifest.files.find((file) => file.path === `portable/bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); - expect(result.build.manifest.files.some((file) => file.path.startsWith('legacy-host/bin/'))).toBe(false); + expect(result.build.manifest.files.find((file) => file.path === `bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); + expect(result.build.manifest.files.filter((file) => file.path.startsWith('bin/'))).toHaveLength(2); - // Artifact validation accepts the framework-owned `bin/` layout on every target. - const validation = await validateArtifact({ artifactRoot, registry }); + // Artifact validation accepts the framework-owned `bin/` layout on every + // target, and a root without the bin as well. + const [validation, legacyValidation] = await Promise.all([ + validateArtifact({ artifactRoot, registry }), + validateArtifact({ artifactRoot: legacyArtifactRoot, registry }), + ]); expect(validation.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(legacyValidation.filter((entry) => entry.severity === 'error')).toEqual([]); // `inspect` accounts for the bin as a `cli` component per target. const inspected = await inspect({ registry, root }); @@ -268,7 +284,10 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew kind: 'cli', name: pluginName, }); - const legacyPlan = inspected.plans.find((plan) => plan.target === 'legacy-host'); + const legacyInspected = await inspect({ registry, root: legacyRoot }); + expect(legacyInspected.state).toBe('ready'); + if (legacyInspected.state !== 'ready') throw new Error('unreachable'); + const legacyPlan = legacyInspected.plans.find((plan) => plan.target === 'legacy-host'); expect(legacyPlan?.skipped).toContainEqual({ capability: { name: 'cli', reason: expect.stringContaining('publishes no cli capability row'), state: 'unavailable' }, id: `bin:${pluginName}`, @@ -277,7 +296,7 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew reason: 'unsupported-capability', }); - // `inspect --bundler` dumps the per-target bin composition beside the + // `inspect --bundler` dumps the composite bin composition beside the // scripts; the npm package bin (no target) keeps its own entry. const bundler = await inspect({ focus: 'bundler', registry, root }); if (bundler.state !== 'ready') throw new Error('unreachable'); @@ -285,15 +304,16 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew .filter((entry) => entry.kind === 'bin' && entry.target !== undefined); expect((bundler.selected?.bundler?.entries ?? []).some((entry) => entry.kind === 'bin' && entry.target === undefined && entry.outputPath === `dist/bin/${pluginName}.js`)).toBe(true); - expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual(hostTargets - .flatMap((target) => [`${target}/bin/${pluginName}.mjs`, `${target}/bin/${pluginName}-flight.mjs`]) - .sort()); + expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual([ + `bin/${pluginName}-flight.mjs`, + `bin/${pluginName}.mjs`, + ]); }); it('lets a skill reach the artifact bin through the plugin-root token, and the bin applies the operator .env layer before its route and provider modules evaluate (#469)', { retry: 1, timeout: 240_000 }, async () => { const root = await createFixture({ skill: true, targets: ['claude'] }); const result = await build({ output: 'artifact', root }); - const claudeRoot = join(root, 'artifact', 'claude'); + const claudeRoot = join(root, 'artifact'); // The skill's `${CLAUDE_PLUGIN_ROOT}` reference lowers to a path the same // artifact really ships, and that file is the working routed CLI. diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 74cafca50..52e36598b 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -248,32 +248,32 @@ const runtimeFiles = (): readonly FixtureFile[] => [ event: 'beforeTool', id: 'hook-1', name: 'Check command', - path: 'synthetic/hooks/run.mjs', + path: 'hooks/run.mjs', target: fixtureTarget, }]), { contents: '{"mcpServers":{"runner":{"args":["./mcp/runner.mjs"],"command":"node","env":{"SECRET":"do-not-expose"},"type":"stdio"}}}\n', kind: 'generated', - path: 'synthetic/mcp.json', + path: 'mcp.json', }, - { contents: 'export const runner = true;\n', kind: 'bundle', mode: 0o755, path: 'synthetic/mcp/runner.mjs', sourceInputs: [runnerSourcePath] }, - { contents: '{}\n', kind: 'generated', path: 'synthetic/hooks/hooks.json' }, - { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'synthetic/hooks/run.mjs', sourceInputs: [runnerSourcePath] }, - { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'synthetic/scripts/alpha.mjs' }, - { contents: 'export const zeta = true;\n', kind: 'copy', path: 'synthetic/scripts/zeta.mjs' }, + { contents: 'export const runner = true;\n', kind: 'bundle', mode: 0o755, path: 'mcp/runner.mjs', sourceInputs: [runnerSourcePath] }, + { contents: '{}\n', kind: 'generated', path: 'hooks/hooks.json' }, + { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'hooks/run.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'scripts/alpha.mjs' }, + { contents: 'export const zeta = true;\n', kind: 'copy', path: 'scripts/zeta.mjs' }, ]; const diffFiles = (variant: 'base' | 'candidate'): readonly FixtureFile[] => { const candidate = variant === 'candidate'; return [ - ...(candidate ? [{ contents: 'export const added = true;\n', kind: 'generated' as const, path: 'synthetic/scripts/added.mjs' }] : []), - { contents: candidate ? 'b' : 'a', kind: 'generated', path: 'synthetic/scripts/digest.mjs' }, - { contents: candidate ? 'longer' : 'short', kind: 'generated', path: 'synthetic/scripts/bytes.mjs' }, - { contents: 'export const mode = true;\n', kind: 'generated', mode: candidate ? 0o744 : 0o755, path: 'synthetic/scripts/mode.mjs' }, - { contents: 'export const kind = true;\n', kind: candidate ? 'copy' : 'generated', path: 'synthetic/scripts/kind.mjs' }, - { contents: 'export const source = true;\n', kind: 'generated', path: 'synthetic/scripts/source.mjs', sourceInputs: candidate ? [runnerSourcePath] : [configPath] }, - { contents: 'export const same = true;\n', kind: 'generated', path: 'synthetic/scripts/unchanged.mjs' }, - ...(candidate ? [] : [{ contents: 'export const removed = true;\n', kind: 'generated' as const, path: 'synthetic/scripts/removed.mjs' }]), + ...(candidate ? [{ contents: 'export const added = true;\n', kind: 'generated' as const, path: 'scripts/added.mjs' }] : []), + { contents: candidate ? 'b' : 'a', kind: 'generated', path: 'scripts/digest.mjs' }, + { contents: candidate ? 'longer' : 'short', kind: 'generated', path: 'scripts/bytes.mjs' }, + { contents: 'export const mode = true;\n', kind: 'generated', mode: candidate ? 0o744 : 0o755, path: 'scripts/mode.mjs' }, + { contents: 'export const kind = true;\n', kind: candidate ? 'copy' : 'generated', path: 'scripts/kind.mjs' }, + { contents: 'export const source = true;\n', kind: 'generated', path: 'scripts/source.mjs', sourceInputs: candidate ? [runnerSourcePath] : [configPath] }, + { contents: 'export const same = true;\n', kind: 'generated', path: 'scripts/unchanged.mjs' }, + ...(candidate ? [] : [{ contents: 'export const removed = true;\n', kind: 'generated' as const, path: 'scripts/removed.mjs' }]), ]; }; @@ -299,7 +299,7 @@ class TrackingEpochStore extends EpochStore { class ReadFailingEpochStore extends TrackingEpochStore { override async acquireEpochReference(epochId: string) { const reference = await super.acquireEpochReference(epochId); - await rm(join(reference.root, 'synthetic', 'mcp', 'runner.mjs')); + await rm(join(reference.root, 'mcp', 'runner.mjs')); return reference; } } @@ -343,37 +343,48 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( }); expect(inspection.files.map((file) => file.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/hooks/hooks.json', - 'synthetic/hooks/run.mjs', - 'synthetic/mcp.json', - 'synthetic/mcp/runner.mjs', - 'synthetic/scripts/alpha.mjs', - 'synthetic/scripts/zeta.mjs', + 'hooks/hooks.json', + 'hooks/run.mjs', + 'mcp.json', + 'mcp/runner.mjs', + 'scripts/alpha.mjs', + 'scripts/zeta.mjs', ]); + // The projection's tree is the composite root itself, named after the host (#555). expect(inspection.targets).toEqual([ - expect.objectContaining({ name: fixtureTarget, tree: expect.objectContaining({ path: fixtureTarget }) }), + expect.objectContaining({ + name: fixtureTarget, + tree: expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ kind: 'directory', name: 'hooks', path: 'hooks' }), + expect.objectContaining({ kind: 'file', name: 'mcp.json', path: 'mcp.json' }), + ]), + name: fixtureTarget, + path: '.', + }), + }), ]); expect(inspection.provenance).toContainEqual({ - outputPath: 'synthetic/mcp/runner.mjs', + outputPath: 'mcp/runner.mjs', sourceInputs: [{ path: runnerSourcePath, sha256: fixtureInputs[1]!.sha256 }], }); expect(inspection.runtime.executables.map((file) => file.path)).toEqual([ - 'synthetic/hooks/run.mjs', - 'synthetic/mcp/runner.mjs', + 'hooks/run.mjs', + 'mcp/runner.mjs', ]); expect(inspection.runtime.hooks).toEqual([ - expect.objectContaining({ path: 'synthetic/hooks/run.mjs', target: fixtureTarget }), + expect.objectContaining({ path: 'hooks/run.mjs', target: fixtureTarget }), ]); expect(inspection.runtime.mcpServers).toEqual([{ - entryPaths: ['synthetic/mcp/runner.mjs'], + entryPaths: ['mcp/runner.mjs'], kind: 'stdio', - manifestPath: 'synthetic/mcp.json', + manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, }]); expect(inspection.runtime.scripts).toEqual([ - expect.objectContaining({ id: 'script:alpha', name: 'alpha', target: fixtureTarget, file: expect.objectContaining({ path: 'synthetic/scripts/alpha.mjs' }) }), - expect.objectContaining({ id: 'script:zeta', name: 'zeta', target: fixtureTarget, file: expect.objectContaining({ path: 'synthetic/scripts/zeta.mjs' }) }), + expect.objectContaining({ id: 'script:alpha', name: 'alpha', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/alpha.mjs' }) }), + expect.objectContaining({ id: 'script:zeta', name: 'zeta', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/zeta.mjs' }) }), ]); expect(JSON.stringify(inspection)).not.toContain('do-not-expose'); } finally { @@ -394,7 +405,7 @@ it('revalidates an epoch on each inspection so post-publication corruption is vi epochId: 'epoch-revalidation', }); await writeFile( - join(root, '.agent-bundle', 'epochs', 'epoch-revalidation', 'synthetic', 'scripts', 'alpha.mjs'), + join(root, '.agent-bundle', 'epochs', 'epoch-revalidation', 'scripts', 'alpha.mjs'), 'export const alpha = false;\n', ); @@ -450,9 +461,9 @@ it('uses callback facts captured during validation and excludes unmanifested mut expect(calls.reads).toBe(1); expect(calls.resolutions).toBe(1); expect(inspection.runtime.mcpServers).toEqual([{ - entryPaths: ['synthetic/mcp/runner.mjs'], + entryPaths: ['mcp/runner.mjs'], kind: 'stdio', - manifestPath: 'synthetic/mcp.json', + manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, }]); @@ -483,7 +494,7 @@ it('preserves the supplied runtime resolver call sequence while inspecting valid expect(inspectionCalls).toEqual(['./mcp/runner.mjs', './mcp/runner.mjs']); expect(inspection.runtime.mcpServers).toEqual([expect.objectContaining({ - entryPaths: ['synthetic/mcp/runner.mjs'], + entryPaths: ['mcp/runner.mjs'], name: 'runner', })]); } finally { @@ -534,21 +545,21 @@ it('retains immutable inspection evidence when manifest and hook bytes are repla event: 'beforeTool', id: 'replacement-hook', name: 'Replacement hook', - path: 'synthetic/hooks/replacement.mjs', + path: 'hooks/replacement.mjs', target: fixtureTarget, }]), ...runtimeFiles().filter((file) => file.path !== 'agent-bundle.hooks.json'), - { contents: 'export const replacement = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'synthetic/hooks/replacement.mjs' }, + { contents: 'export const replacement = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'hooks/replacement.mjs' }, ]; await writeFile(join(artifactRoot, 'agent-bundle.hooks.json'), replacementFiles[0]!.contents); - await writeFile(join(artifactRoot, 'synthetic', 'hooks', 'replacement.mjs'), replacementFiles.at(-1)!.contents); + await writeFile(join(artifactRoot, 'hooks', 'replacement.mjs'), replacementFiles.at(-1)!.contents); await writeFile( join(artifactRoot, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(registry, replacementFiles)).bytes, ); expect(result.snapshot).toMatchObject({ - manifest: { files: expect.not.arrayContaining([expect.objectContaining({ path: 'synthetic/hooks/replacement.mjs' })]) }, + manifest: { files: expect.not.arrayContaining([expect.objectContaining({ path: 'hooks/replacement.mjs' })]) }, runtime: { hooks: [expect.objectContaining({ id: 'hook-1' })] }, }); expect(Object.isFrozen(result.snapshot)).toBe(true); @@ -646,18 +657,18 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () const service = new ArtifactInspectionService(store, registry); const diff = await service.diff('epoch-base', 'epoch-candidate'); - expect(diff.added.map((record) => record.path)).toEqual(['synthetic/scripts/added.mjs']); - expect(diff.removed.map((record) => record.path)).toEqual(['synthetic/scripts/removed.mjs']); + expect(diff.added.map((record) => record.path)).toEqual(['scripts/added.mjs']); + expect(diff.removed.map((record) => record.path)).toEqual(['scripts/removed.mjs']); expect(diff.changed.map((record) => record.path)).toEqual([ - 'synthetic/scripts/bytes.mjs', - 'synthetic/scripts/digest.mjs', - 'synthetic/scripts/kind.mjs', - 'synthetic/scripts/mode.mjs', - 'synthetic/scripts/source.mjs', + 'scripts/bytes.mjs', + 'scripts/digest.mjs', + 'scripts/kind.mjs', + 'scripts/mode.mjs', + 'scripts/source.mjs', ]); expect(diff.unchanged.map((record) => record.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/scripts/unchanged.mjs', + 'scripts/unchanged.mjs', ]); expect(diff.changed.find((record) => record.path.endsWith('/source.mjs'))).toMatchObject({ after: { sourceInputs: [{ path: runnerSourcePath }] }, @@ -675,13 +686,13 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () expect(same).toMatchObject({ added: [], changed: [], removed: [] }); expect(same.unchanged.map((record) => record.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/scripts/bytes.mjs', - 'synthetic/scripts/digest.mjs', - 'synthetic/scripts/kind.mjs', - 'synthetic/scripts/mode.mjs', - 'synthetic/scripts/removed.mjs', - 'synthetic/scripts/source.mjs', - 'synthetic/scripts/unchanged.mjs', + 'scripts/bytes.mjs', + 'scripts/digest.mjs', + 'scripts/kind.mjs', + 'scripts/mode.mjs', + 'scripts/removed.mjs', + 'scripts/source.mjs', + 'scripts/unchanged.mjs', ]); expect(store).toMatchObject({ acquired: 4, closed: 4 }); } finally { @@ -729,7 +740,7 @@ it('compares canonical file source-input paths rather than project input hashes' const files = [{ contents: 'export const source = true;\n', kind: 'generated' as const, - path: 'synthetic/scripts/source.mjs', + path: 'scripts/source.mjs', sourceInputs: [runnerSourcePath], }]; const changedProjectInputs = Object.freeze([ @@ -753,7 +764,7 @@ it('compares canonical file source-input paths rather than project input hashes' expect(diff.changed).toEqual([]); expect(diff.unchanged.map((record) => record.path)).toEqual([ 'agent-bundle.hooks.json', - 'synthetic/scripts/source.mjs', + 'scripts/source.mjs', ]); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/artifact-routes.test.ts b/packages/agent-bundle/tests/artifact-routes.test.ts index c13e47e9d..c832d4de2 100644 --- a/packages/agent-bundle/tests/artifact-routes.test.ts +++ b/packages/agent-bundle/tests/artifact-routes.test.ts @@ -97,7 +97,7 @@ it('surfaces artifact validation diagnostics instead of one opaque failure', asy const service = new RecordingService(); const diagnostics = deepFreeze([{ code: 'AB4300', - generatedPath: 'claude/hooks/guard.mjs', + generatedPath: 'hooks/guard.mjs', message: 'Emitted hook wrapper is not executable.', severity: 'error' as const, }]); diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index d643e27ff..1fc5dd227 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -17,6 +17,7 @@ import { type TargetArtifactDocumentValidator, type TargetArtifactWrite, } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; import { digest, sha256Hex } from '../src/core/digest.ts'; @@ -241,12 +242,12 @@ it('validates and owns every concrete document matched by an optional schema fam const registry = wildcardRegistry(); const target = targetFromRegistry(registry, customTarget); const validRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '{"base":"dark"}\n', kind: 'generated', path: 'custom/themes/dracula.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '{"base":"dark"}\n', kind: 'generated', path: 'themes/dracula.json' }, ], true, [target]); const invalidRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '{"name":"Missing base"}\n', kind: 'generated', path: 'custom/themes/invalid.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '{"name":"Missing base"}\n', kind: 'generated', path: 'themes/invalid.json' }, ], true, [target]); try { @@ -254,7 +255,7 @@ it('validates and owns every concrete document matched by an optional schema fam expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( expect.objectContaining({ code: 'AB6012', - generatedPath: 'custom/themes/invalid.json', + generatedPath: 'themes/invalid.json', target: customTarget, }), ); @@ -276,11 +277,11 @@ const skillMarkdown = (name: string, body: string): string => [ ].join('\n'); const customSkillFiles = (body: string, resources: readonly ArtifactFixtureFile[] = []): readonly ArtifactFixtureFile[] => [ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: skillMarkdown('artifact-skill', body), kind: 'copy', - path: 'custom/skills/artifact-skill/SKILL.md', + path: 'skills/artifact-skill/SKILL.md', }, ...resources, ]; @@ -289,12 +290,12 @@ it('admits only direct .mdc files in a declared rules layout', async () => { const registry = customRegistry(); const target = targetFromRegistry(registry, customTarget); const validRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Rule\n', kind: 'generated', path: 'custom/rules/review.mdc' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Rule\n', kind: 'generated', path: 'rules/review.mdc' }, ], true, [target]); const invalidRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Rule\n', kind: 'generated', path: 'custom/rules/review.md' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Rule\n', kind: 'generated', path: 'rules/review.md' }, ], true, [target]); try { @@ -302,8 +303,7 @@ it('admits only direct .mdc files in a declared rules layout', async () => { expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( expect.objectContaining({ code: 'AB6014', - generatedPath: 'custom/rules/review.md', - target: customTarget, + generatedPath: 'rules/review.md', }), ); } finally { @@ -318,12 +318,12 @@ it('admits only direct .md files in a declared commands layout', async () => { const registry = customRegistry(); const target = targetFromRegistry(registry, customTarget); const validRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Command\n', kind: 'generated', path: 'custom/commands/review.md' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Command\n', kind: 'generated', path: 'commands/review.md' }, ], true, [target]); const invalidRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: '# Command\n', kind: 'generated', path: 'custom/commands/review.mdc' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: '# Command\n', kind: 'generated', path: 'commands/review.mdc' }, ], true, [target]); try { @@ -331,8 +331,7 @@ it('admits only direct .md files in a declared commands layout', async () => { expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( expect.objectContaining({ code: 'AB6014', - generatedPath: 'custom/commands/review.mdc', - target: customTarget, + generatedPath: 'commands/review.mdc', }), ); } finally { @@ -530,7 +529,7 @@ it('validates an emitted Skill and copied resources from the artifact only', asy [{ contents: '# Resource\n', kind: 'copy', - path: 'custom/skills/artifact-skill/resources/with space.md', + path: 'skills/artifact-skill/resources/with space.md', }], ); const root = await writeArtifact(files, true, [customManifestTarget]); @@ -544,7 +543,7 @@ it('validates an emitted Skill and copied resources from the artifact only', asy it('returns frozen validated evidence without changing the diagnostics-only validator API', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -582,7 +581,7 @@ it('returns frozen validated evidence without changing the diagnostics-only vali it('rejects a rehashed top-level artifact file outside declared target namespaces', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, { contents: 'not compiler metadata\n', kind: 'generated' as const, path: 'top-level.txt' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); @@ -602,15 +601,15 @@ it('rejects a rehashed top-level artifact file outside declared target namespace it('rejects a rehashed file outside a declared target emitted layout', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, - { contents: 'not a compiler output\n', kind: 'generated' as const, path: 'custom/unexpected.txt' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, + { contents: 'not a compiler output\n', kind: 'generated' as const, path: 'unexpected.txt' }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'custom/unexpected.txt', target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'unexpected.txt' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -622,11 +621,11 @@ it('rejects a rehashed file outside a declared target emitted layout', async () it('accepts a manifested target asset emitted by the core build', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: '{"version":"1.0.0"}\n', kind: 'copy', - path: 'custom/assets/release/release-manifest.json', + path: 'assets/release/release-manifest.json', }, ], true, [customManifestTarget]); @@ -639,16 +638,16 @@ it('accepts a manifested target asset emitted by the core build', async () => { it('rejects malformed and unmanifested target asset paths', async () => { const malformedRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: 'not an asset path\n', kind: 'copy', path: 'custom/assets' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: 'not an asset path\n', kind: 'copy', path: 'assets' }, ], true, [customManifestTarget]); const unmanifestedRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { - await mkdir(join(unmanifestedRoot, 'custom', 'assets', 'release'), { recursive: true }); - await writeFile(join(unmanifestedRoot, 'custom', 'assets', 'release', 'unmanifested.json'), '{}\n'); + await mkdir(join(unmanifestedRoot, 'assets', 'release'), { recursive: true }); + await writeFile(join(unmanifestedRoot, 'assets', 'release', 'unmanifested.json'), '{}\n'); const [malformedDiagnostics, unmanifestedDiagnostics] = await Promise.all([ validateArtifact({ artifactRoot: malformedRoot, registry: customRegistry() }), @@ -656,7 +655,7 @@ it('rejects malformed and unmanifested target asset paths', async () => { ]); expect(malformedDiagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'custom/assets', target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'assets' }), ])); expect(unmanifestedDiagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -668,16 +667,16 @@ it('rejects malformed and unmanifested target asset paths', async () => { }); it('rejects an artifact symlink even when the manifest remains self-consistent', async () => { - const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; + const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }]; const root = await writeArtifact(files, true, [customManifestTarget]); try { - await symlink(join(root, 'custom', 'document.json'), join(root, 'custom', 'unexpected-link.json')); + await symlink(join(root, 'document.json'), join(root, 'unexpected-link.json')); const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6013', generatedPath: 'custom/unexpected-link.json' }), + expect.objectContaining({ code: 'AB6013', generatedPath: 'unexpected-link.json' }), ])); - expect(diagnostics.filter((entry) => entry.code === 'AB6013' && entry.generatedPath === 'custom/unexpected-link.json')).toHaveLength(1); + expect(diagnostics.filter((entry) => entry.code === 'AB6013' && entry.generatedPath === 'unexpected-link.json')).toHaveLength(1); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), ])); @@ -688,7 +687,7 @@ it('rejects an artifact symlink even when the manifest remains self-consistent', it('rejects a special manifest without following its symlink target', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-outside-manifest-')); @@ -712,7 +711,7 @@ it('rejects a special manifest without following its symlink target', async () = it('rejects a canonical manifest whose runtime is below the generated floor', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -731,7 +730,7 @@ it('rejects a canonical manifest whose runtime is below the generated floor', as it('settles promptly when the artifact manifest is a FIFO', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -754,7 +753,7 @@ it('settles promptly when the artifact manifest is a FIFO', async () => { it('rejects empty declared and undeclared target directories independently of manifest hashes', async () => { const emptyRoot = await writeArtifact([], true, [customManifestTarget]); const declaredRoot = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); try { @@ -767,7 +766,7 @@ it('rejects empty declared and undeclared target directories independently of ma ]); expect(emptyDiagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: customTarget, target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: customTarget }), ])); expect(undeclaredDiagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6014', generatedPath: 'undeclared' }), @@ -782,15 +781,15 @@ it('rejects empty declared and undeclared target directories independently of ma }); it('rejects a nested empty directory under an otherwise valid target namespace', async () => { - const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }]; + const files = [{ contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }]; const root = await writeArtifact(files, true, [customManifestTarget]); try { - await mkdir(join(root, 'custom', 'skills', 'orphan'), { recursive: true }); + await mkdir(join(root, 'skills', 'orphan'), { recursive: true }); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'custom/skills/orphan', target: customTarget }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'skills/orphan' }), ]), ); } finally { @@ -802,20 +801,20 @@ it('rejects forged hook output for a target without a hook contract', async () = const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ - { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, - { contents: 'forged hook\n', kind: 'generated' as const, path: 'portable/hooks/junk.txt' }, + { contents: 'forged hook\n', kind: 'generated' as const, path: 'hooks/junk.txt' }, ]; const root = await writeArtifact(files, true, [portable]); try { await expect(validateArtifact({ artifactRoot: root, registry })).resolves.toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'portable/hooks/junk.txt', target: 'portable' }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'hooks/junk.txt' }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -829,16 +828,16 @@ it('rejects a canonically rehashed script with an unsupported extension', async { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, - { contents: 'forged script\n', kind: 'copy' as const, path: 'portable/scripts/junk.exe' }, + { contents: 'forged script\n', kind: 'copy' as const, path: 'scripts/junk.exe' }, ]; const root = await writeArtifact(files, true, [portable]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6014', generatedPath: 'portable/scripts/junk.exe', target: 'portable' }), + expect.objectContaining({ code: 'AB6014', generatedPath: 'scripts/junk.exe' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -852,12 +851,12 @@ it('fails ordinary artifact validation when an emitted portable tree breaks the const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ - { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, { contents: JSON.stringify({ @@ -872,7 +871,7 @@ it('fails ordinary artifact validation when an emitted portable tree breaks the }, }) + '\n', kind: 'generated' as const, - path: 'portable/mcp.json', + path: 'mcp.json', }, ]; const root = await writeArtifact(files, true, [portable]); @@ -905,12 +904,12 @@ it('does not follow a symlinked portable document into the byte lane once the in const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const root = await writeArtifact([ - { contents: '# Install portable-test\n', kind: 'generated', path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated', path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated', path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated', path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated', - path: 'portable/plugin.json', + path: 'plugin.json', }, ], true, [portable]); const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-outside-mcp-')); @@ -920,11 +919,11 @@ it('does not follow a symlinked portable document into the byte lane once the in $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', mcpServers: { forged: { command: 'bin/server', type: 'stdio' } }, })); - await symlink(join(outside, 'forged-mcp.json'), join(root, 'portable', 'mcp.json')); + await symlink(join(outside, 'forged-mcp.json'), join(root, 'mcp.json')); const diagnostics = await validateArtifact({ artifactRoot: root, registry }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6013', generatedPath: 'portable/mcp.json' }), + expect.objectContaining({ code: 'AB6013', generatedPath: 'mcp.json' }), ])); // The forged content was never read: no schema or normative finding from behind the link. expect(diagnostics.filter((entry) => ['AB6035', 'AB6036', 'AB6037'].includes(entry.code))).toEqual([]); @@ -947,14 +946,15 @@ 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 { const diagnostics = await validateArtifact({ artifactRoot: root, registry }); - // No AB6035 for the absent Agent Plugins plugin.json: the byte lane keys on the - // built-in adapter identity. Only the name-keyed install-surface requirement remains. - expect(diagnostics.map((entry) => entry.code).sort()).toEqual(['AB6023', 'AB6024']); + // No AB6035 for the absent Agent Plugins plugin.json, and no AB6023/AB6024 + // for the absent install surface: both lanes key on the built-in adapter + // identity, so a custom adapter under the portable name owes neither (#592). + expect(diagnostics).toEqual([]); } finally { await rm(root, { force: true, recursive: true }); } @@ -964,14 +964,14 @@ it('admits nested project assets in the target-owned recursive asset namespace', const registry = createDefaultRegistry(); const portable = targetFromRegistry(registry, 'portable'); const files = [ - { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'portable/INSTALL.md' }, - { contents: 'export {};\n', kind: 'generated' as const, path: 'portable/install.mjs' }, + { contents: '# Install portable-test\n', kind: 'generated' as const, path: 'INSTALL.md' }, + { contents: 'export {};\n', kind: 'generated' as const, path: 'install.mjs' }, { contents: '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid portable plugin.","name":"portable-test","version":"1.0.0"}\n', kind: 'generated' as const, - path: 'portable/plugin.json', + path: 'plugin.json', }, - { contents: '\n', kind: 'copy' as const, path: 'portable/assets/branding/logo.svg' }, + { contents: '\n', kind: 'copy' as const, path: 'assets/branding/logo.svg' }, ]; const root = await writeArtifact(files, true, [portable]); @@ -984,9 +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,9 +1008,8 @@ it.each([ expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6016', - generatedPath: 'custom/skills/artifact-skill/SKILL.md', + generatedPath: 'skills/artifact-skill/SKILL.md', recovery: artifactDiagnosticRecoveries.AB6016, - target: customTarget, }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ @@ -1029,9 +1028,8 @@ it('rejects emitted Skill Markdown without instruction body content', async () = expect.arrayContaining([ expect.objectContaining({ code: 'AB6034', - generatedPath: 'custom/skills/artifact-skill/SKILL.md', + generatedPath: 'skills/artifact-skill/SKILL.md', recovery: artifactDiagnosticRecoveries.AB6034, - target: customTarget, }), ]), ); @@ -1042,11 +1040,11 @@ it('rejects emitted Skill Markdown without instruction body content', async () = it('validates emitted Skill frontmatter against the pinned contract and directory name', async () => { const files = [ - { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated' as const, path: 'document.json' }, { contents: skillMarkdown('wrong-name', '# Wrong-name instructions'), kind: 'copy' as const, - path: 'custom/skills/artifact-skill/SKILL.md', + path: 'skills/artifact-skill/SKILL.md', }, ]; const root = await writeArtifact(files, true, [customManifestTarget]); @@ -1056,9 +1054,8 @@ it('validates emitted Skill frontmatter against the pinned contract and director expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6015', - generatedPath: 'custom/skills/artifact-skill/SKILL.md', + generatedPath: 'skills/artifact-skill/SKILL.md', recovery: artifactDiagnosticRecoveries.AB6015, - target: customTarget, }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ @@ -1093,9 +1090,9 @@ it('rejects noncanonical and duplicate-key manifests as strict parse failures', it('matches a canonical nested manifest file table by path instead of directory traversal position', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, - { contents: skillMarkdown('table', '# Table instructions'), kind: 'copy', path: 'custom/skills/table/SKILL.md' }, - { contents: '{}\n', kind: 'copy', path: 'custom/skills/table/resources/entry.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, + { contents: skillMarkdown('table', '# Table instructions'), kind: 'copy', path: 'skills/table/SKILL.md' }, + { contents: '{}\n', kind: 'copy', path: 'skills/table/resources/entry.json' }, ], true, [customManifestTarget]); try { @@ -1126,23 +1123,28 @@ it('preserves structural artifact diagnostics after a strict manifest passes', a const files = [ { contents: brokenBundle, kind: 'bundle' as const, path: 'broken.mjs' }, { contents: '{', kind: 'generated' as const, path: 'invalid.json' }, - { contents: '{"mcpServers":{"local":{"args":["mcp/mcp-local-deadbeef.mjs"]}}}', kind: 'generated' as const, path: 'mcp.json' }, + { + contents: '{"mcpServers":{"local":{"args":["mcp/mcp-local-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', + kind: 'generated' as const, + path: 'native/servers.json', + }, + { contents: 'export const local = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-local-deadbeef.mjs' }, ]; - const root = await writeArtifact(files); + const root = await writeArtifact(files, true, [coherenceManifestTarget]); try { - await writeFile(join(root, 'mcp.json'), '{"mcpServers":{"local":{"args":["mcp/mcp-local-deadbeef.mjs"]}}}\n'); - await writeFile(join(root, 'invalid.json'), '{'); - await writeFile(join(root, 'broken.mjs'), brokenBundle); - const diagnostics = await validateArtifact({ artifactRoot: root }); + // The manifest still lists the compiled server the MCP document points at; + // only the file is gone. + await rm(join(root, 'mcp', 'mcp-local-deadbeef.mjs')); + const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', generatedPath: 'broken.mjs' }), expect.objectContaining({ code: 'AB6006', generatedPath: 'invalid.json' }), - expect.objectContaining({ code: 'AB6007', generatedPath: 'mcp.json' }), + expect.objectContaining({ code: 'AB6007', generatedPath: 'native/servers.json' }), ])); await writeFile(join(root, 'broken.mjs'), 'export const repaired = true;\n'); - expect(await validateArtifact({ artifactRoot: root })).toEqual(expect.arrayContaining([ + expect(await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() })).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004', generatedPath: 'agent-bundle.manifest.json' }), ])); } finally { @@ -1155,19 +1157,19 @@ it('reports an orphan compiler MCP output after the artifact is rehashed', async { contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', kind: 'generated' as const, - path: 'coherent/native/servers.json', + path: 'native/servers.json', }, - { contents: 'export const server = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, - { contents: 'export const orphan = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-junk-deadbeef.mjs' }, - { contents: 'export const orphanWorker = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-junk-deadbeef-flight.mjs' }, + { contents: 'export const server = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-server-deadbeef.mjs' }, + { contents: 'export const orphan = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-junk-deadbeef.mjs' }, + { contents: 'export const orphanWorker = true;\n', kind: 'bundle' as const, path: 'mcp/mcp-junk-deadbeef-flight.mjs' }, ]; const root = await writeArtifact(files, true, [coherenceManifestTarget]); try { const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-junk-deadbeef.mjs', target: coherenceTarget }), - expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-junk-deadbeef-flight.mjs', target: coherenceTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-junk-deadbeef.mjs' }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'mcp/mcp-junk-deadbeef-flight.mjs' }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), @@ -1200,9 +1202,9 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', { contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', kind: 'generated', - path: 'coherent/native/servers.json', + path: 'native/servers.json', }, - { contents: 'export const coherent = true;\n', kind: 'bundle', path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, + { contents: 'export const coherent = true;\n', kind: 'bundle', path: 'mcp/mcp-server-deadbeef.mjs' }, { contents: '{"mcpServers":{"server":{"args":["mcp/mcp-server-deadbeef.mjs"],"command":"node","type":"stdio"}}}\n', kind: 'generated', @@ -1234,12 +1236,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 +1249,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 +1257,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 +1289,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 +1308,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 +1331,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 +1353,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 +1378,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 +1387,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 +1399,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 +1418,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 +1427,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 +1439,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 +1449,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 +1463,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 +1472,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 +1484,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 +1499,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 +1513,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 +1528,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 +1545,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 +1582,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 +1593,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 +1609,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 +1620,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 +1632,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 +1651,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 +1666,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 +1675,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 +1695,9 @@ it('rejects a special entry added during validation without returning a snapshot it('rejects an empty directory added during validation without returning a snapshot', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); - const emptyDirectory = join(root, 'custom', 'late-empty'); + const emptyDirectory = join(root, 'late-empty'); const registry = customRegistry(() => { mkdirSync(emptyDirectory); return []; @@ -1704,7 +1706,7 @@ it('rejects an empty directory added during validation without returning a snaps try { const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry }); - expect(result.diagnostics.filter((entry) => entry.code === 'AB6014' && entry.generatedPath === 'custom/late-empty')).toHaveLength(1); + expect(result.diagnostics.filter((entry) => entry.code === 'AB6014' && entry.generatedPath === 'late-empty')).toHaveLength(1); expect(result.snapshot).toBeUndefined(); } finally { await rm(root, { force: true, recursive: true }); @@ -1713,7 +1715,7 @@ it('rejects an empty directory added during validation without returning a snaps it('does not re-enter artifact validation after taking final evidence snapshots', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); const manifestPath = join(root, 'agent-bundle.manifest.json'); const initialManifest = readFileSync(manifestPath); @@ -1740,9 +1742,9 @@ it('does not re-enter artifact validation after taking final evidence snapshots' it('does not allow a late registry re-entry to create an unvalidated empty directory', async () => { const root = await writeArtifact([ - { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, ], true, [customManifestTarget]); - const emptyDirectory = join(root, 'custom', 'too-late-empty'); + const emptyDirectory = join(root, 'too-late-empty'); const registry = customRegistry(); const artifactValidation = registry.artifactValidation.bind(registry); let artifactValidationCalls = 0; @@ -1774,7 +1776,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 +1794,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 +1806,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 +1825,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 +1858,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 +1894,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 +1930,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 +1959,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 +1971,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 +1995,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 +2003,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 +2068,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 +2099,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 +2164,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 +2176,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 +2191,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 +2246,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 +2256,7 @@ it.each(malformedValidatorCases)('reports $0 through the stable schema diagnosti })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6012', - generatedPath: 'custom/document.json', + generatedPath: 'document.json', message: expect.stringContaining('schema validation failed.'), target: customTarget, }), @@ -2311,22 +2312,22 @@ const installSurfaceModel = (target: string): NormalizedPlugin => ({ }); const installSurfaceArtifact = async ( - target: 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable', + target: 'claude' | 'codex' | 'cursor' | 'portable', omitted: string, ): Promise => { const registry = createDefaultRegistry(); - const files = registry.get(target).plan(installSurfaceModel(target)).entries + const files = composeProjections(installSurfaceModel(target), registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .filter((entry) => entry.relativePath !== omitted) .map((entry) => ({ contents: entry.content, kind: 'generated' as const, - path: `${target}/${entry.relativePath}`, + path: entry.relativePath, })); return writeArtifact(files, true, [targetFromRegistry(registry, target)]); }; -it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( +it.each(['claude', 'codex', 'cursor', 'portable'] as const)( 'rejects a %s artifact without INSTALL.md', async (target) => { const root = await installSurfaceArtifact(target, 'INSTALL.md'); @@ -2334,8 +2335,7 @@ it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6023', - generatedPath: `${target}/INSTALL.md`, - target, + generatedPath: 'INSTALL.md', }), ])); } finally { @@ -2344,7 +2344,7 @@ it.each(['claude', 'codex', 'cursor', 'plugin', 'portable'] as const)( }, ); -it.each(['cursor', 'plugin', 'portable'] as const)( +it.each(['cursor', 'portable'] as const)( 'rejects a %s fallback artifact without install.mjs', async (target) => { const root = await installSurfaceArtifact(target, 'install.mjs'); @@ -2352,8 +2352,7 @@ it.each(['cursor', 'plugin', 'portable'] as const)( await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6024', - generatedPath: `${target}/install.mjs`, - target, + generatedPath: 'install.mjs', }), ])); } finally { @@ -2388,14 +2387,14 @@ const claudeSettingsFiles = async (settings: string): Promise => { }, }, }; - const files = registry.get('claude').plan(model).entries + const files = composeProjections(model, registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => ({ contents: entry.relativePath === 'settings.json' ? settings : entry.content, kind: 'generated' as const, - path: `claude/${entry.relativePath}`, + path: entry.relativePath, })); - expect(files.some((file) => file.path === 'claude/settings.json')).toBe(true); + expect(files.some((file) => file.path === 'settings.json')).toBe(true); return writeArtifact(files, true, [targetFromRegistry(registry, 'claude')]); }; @@ -2413,7 +2412,7 @@ it('rejects a rehashed Claude settings document that carries an unsupported key' try { await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([expect.objectContaining({ code: 'AB6012', - generatedPath: 'claude/settings.json', + generatedPath: 'settings.json', message: expect.stringContaining('"settings"'), target: 'claude', })]); @@ -2422,32 +2421,24 @@ it('rejects a rehashed Claude settings document that carries an unsupported key' } }); +// The manifest's `dependencies` are rewritten below, so the composed plan +// starts from a dependency-free model: an unresolved dependency would fail the +// composition before any file existed to rewrite. const claudeDependenciesFiles = async (dependencies: unknown): Promise => { const registry = createDefaultRegistry(); - const model: NormalizedPlugin = { - ...installSurfaceModel('claude'), - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, - target: 'claude', - value: { dependencies: ['audit-logger'] }, - }, - }, - }; - const files = registry.get('claude').plan(model).entries + const model = installSurfaceModel('claude'); + const files = composeProjections(model, registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => { if (entry.relativePath !== '.claude-plugin/plugin.json') { - return { contents: entry.content, kind: 'generated' as const, path: `claude/${entry.relativePath}` }; + return { contents: entry.content, kind: 'generated' as const, path: entry.relativePath }; } const manifest = JSON.parse(entry.content) as Record; manifest.dependencies = dependencies; return { contents: `${JSON.stringify(manifest)}\n`, kind: 'generated' as const, - path: 'claude/.claude-plugin/plugin.json', + path: '.claude-plugin/plugin.json', }; }); return writeArtifact(files, true, [targetFromRegistry(registry, 'claude')]); @@ -2470,7 +2461,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 +2470,7 @@ it('rejects a rehashed Claude plugin manifest carrying invalid dependencies', as } }); -const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ +const logoSurfaceModel = (target: 'cursor'): NormalizedPlugin => ({ ...installSurfaceModel(target), metadata: { ...installSurfaceModel(target).metadata, @@ -2493,19 +2484,19 @@ const logoSurfaceModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ it('fails artifact validation when a Cursor manifest logo is missing from the deploy tree', async () => { const registry = createDefaultRegistry(); - const files = registry.get('cursor').plan(logoSurfaceModel('cursor')).entries + const files = composeProjections(logoSurfaceModel('cursor'), registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => ({ contents: entry.content, kind: 'generated' as const, - path: `cursor/${entry.relativePath}`, + path: entry.relativePath, })); const root = await writeArtifact(files, true, [targetFromRegistry(registry, 'cursor')]); try { await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6025', - generatedPath: 'cursor/.cursor-plugin/plugin.json', + generatedPath: '.cursor-plugin/plugin.json', target: 'cursor', }), ])); @@ -2517,17 +2508,17 @@ it('fails artifact validation when a Cursor manifest logo is missing from the de it('accepts a Cursor manifest logo that resolves inside the artifact', async () => { const registry = createDefaultRegistry(); const files = [ - ...registry.get('cursor').plan(logoSurfaceModel('cursor')).entries + ...composeProjections(logoSurfaceModel('cursor'), registry).entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => ({ contents: entry.content, kind: 'generated' as const, - path: `cursor/${entry.relativePath}`, + path: entry.relativePath, })), { contents: '\n', kind: 'copy' as const, - path: 'cursor/assets/docs/media/logo.svg', + path: 'assets/docs/media/logo.svg', }, ]; const root = await writeArtifact(files, true, [targetFromRegistry(registry, 'cursor')]); diff --git a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts index 80bdb0fc0..a6e6f0877 100644 --- a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts +++ b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts @@ -148,7 +148,8 @@ class AgentBundleRemoteTransport implements Transport { } const generatedServer = async (artifact: string, target: string, serverName: string, workspaceRoot: string): Promise => { - const targetRoot = join(artifact, target); + // The composite root is the plugin root for every selected host. + const targetRoot = artifact; const runtime = createDefaultRegistry().mcpRuntime(target); if (runtime === undefined) throw new Error(`Missing MCP runtime for ${JSON.stringify(target)}.`); const document: unknown = JSON.parse(await readFile(join(targetRoot, runtime.manifestPath), 'utf8')); diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts new file mode 100644 index 000000000..80aed8bbe --- /dev/null +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -0,0 +1,486 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { claudeAdapter } from '../src/adapters/claude.ts'; +import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorAdapter, cursorArtifactPaths } from '../src/adapters/cursor.ts'; +import { portableAdapter } from '../src/adapters/portable.ts'; +import type { TargetAdapter } from '../src/adapters/types.ts'; +import { build, type BuildProjectResult, createDefaultRegistry, TargetRegistry, validate } from '../src/api.ts'; +import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; +import { parseArtifactManifest } from '../src/build/manifest.ts'; +import { sha256Hex } from '../src/core/digest.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; +import { createMcpPathTokenResolver, standardMcpPathTokens } from '../src/services/mcp-path-tokens.ts'; +import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../src/services/mcp-runtime.ts'; +import { supportedCapabilities } from './support/adapter-capabilities.ts'; + +/** + * Acceptance tests for the composite plugin root (#555, Wave 1): every + * selected host projects into ONE artifact directory. Assertions are on the + * emitted tree, never on planner internals. + */ + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, content: string): Promise => { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); +}; + +const nativeHookDocument = (command: string): string => `${JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command, type: 'command' }] }] }, +})}\n`; + +interface FixtureOptions { + /** Frontmatter `targets` of the conventional command; omitted → every selected host. */ + readonly commandTargets?: readonly string[]; + /** Extra frontmatter lines for the shared skill. */ + readonly skillFrontmatter?: readonly string[]; + readonly targets?: readonly string[]; +} + +/** + * One project with a shared skill, a shared script, a shared MCP server, a + * generated hook, a native hook document for Claude Code and for Codex, and + * one conventional command. No runtime dependencies: every compiled surface + * is plain TypeScript, so the builds stay fast. + */ +const writeProject = async (root: string, options: FixtureOptions = {}): Promise => { + const targets = options.targets === undefined ? '' : ` targets: ${JSON.stringify(options.targets)},\n`; + const commandTargets = options.commandTargets === undefined + ? '' + : `targets: ${JSON.stringify(options.commandTargets)}\n`; + await Promise.all([ + writeProjectFile(root, 'package.json', `${JSON.stringify({ name: 'composite-fixture', type: 'module', version: '1.0.0' })}\n`), + writeProjectFile(root, 'agent-bundle.config.ts', [ + // A plain object: the fixture has no node_modules to resolve `agent-bundle/config` from. + 'export default {', + " claude: { nativeHooks: './native/claude.json' },", + " codex: { nativeHooks: './native/codex.json' },", + " hooks: { sessionStart: './src/hooks/session-start.ts' },", + " mcp: { servers: { fixture: { entry: './src/mcp/fixture.ts' } } },", + " plugin: { description: 'Composite root fixture.', name: 'composite-fixture', version: '1.0.0' },", + " scripts: { hello: './src/tools/hello.ts' },", + targets.trimEnd(), + '};', + '', + ].filter((line) => line.length > 0).join('\n')), + writeProjectFile(root, 'native/claude.json', nativeHookDocument('echo claude-native')), + writeProjectFile(root, 'native/codex.json', nativeHookDocument('echo codex-native')), + writeProjectFile(root, 'src/hooks/session-start.ts', "export default () => ({ outcome: 'continue' as const, additionalContext: 'started' });\n"), + writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\n"), + writeProjectFile(root, 'src/tools/hello.ts', "console.log('hello');\n"), + writeProjectFile(root, 'src/skills/review/SKILL.md', [ + '---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '', + ].join('\n')), + writeProjectFile(root, 'src/commands/summarize.md', `---\ndescription: Summarize the diff\n${commandTargets}---\nSummarize the current diff.\n`), + ]); +}; + +/** Every regular file under `root`, as POSIX paths relative to it, with its SHA-256. */ +const digestTree = async (root: string): Promise> => { + const digests = new Map(); + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) digests.set(relative(root, path).replaceAll('\\', '/'), sha256Hex(await readFile(path))); + } + }; + await walk(root); + return digests; +}; + +const buildFixture = async ( + targets: readonly string[] | undefined, + { registry, ...options }: Omit & { readonly registry?: TargetRegistry } = {}, +): Promise<{ readonly output: string; readonly result: BuildProjectResult }> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-')); + roots.push(root); + await writeProject(root, { ...options, ...(targets === undefined ? {} : { targets }) }); + const output = join(root, 'artifact'); + const result = await build({ output, ...(registry === undefined ? {} : { registry }), root }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + return { output, result }; +}; + +const syntheticTarget = 'synthetic'; +const syntheticMcpRuntime = createTargetMcpRuntime({ + manifestPath: 'synthetic-mcp.json', + remoteTypes: [], + resolveStdioArgument: resolveTargetRelativeStdioArgument, + resolveValue: createMcpPathTokenResolver({ + knownTokens: standardMcpPathTokens, + target: syntheticTarget, + tokens: { cwd: { '${PLUGIN_ROOT}': 'pluginRoot' } }, + }), +}); + +/** + * An adapter an advanced registry adds beside the built-in hosts. It lowers + * the fixture's MCP server into a document of its own and admits the shared + * compiled surfaces (MCP entries, scripts), so alone it builds a clean root; + * beside another target only `AB4106` can be at issue. + */ +const syntheticAdapterNamed = (name: string): TargetAdapter => Object.freeze({ + artifactLayout: Object.freeze({ + mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), + scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), + }), + capabilities: supportedCapabilities('mcp'), + mcpRuntime: syntheticMcpRuntime, + metadata: Object.freeze({ adapterRevision: 'test', observedVersion: 'test', schemas: Object.freeze([]) }), + name, + plan: (model: NormalizedPlugin) => { + const servers = Object.fromEntries(model.mcpServers + .filter((server) => server.targets.includes(name)) + .map((server) => [server.name, { + ...(server.args === undefined ? {} : { args: server.args }), + command: server.command, + type: 'stdio', + }])); + return Object.freeze({ + diagnostics: Object.freeze([]), + entries: Object.freeze([{ + content: `${JSON.stringify({ mcpServers: servers })}\n`, + kind: 'write' as const, + relativePath: syntheticMcpRuntime.manifestPath, + sourceInputs: Object.freeze([model.metadata.provenance.sourcePath]), + }]), + }); + }, +}); + +const syntheticAdapter = syntheticAdapterNamed(syntheticTarget); + +const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); + +const topLevel = async (root: string): Promise => (await readdir(root)).sort(); + +describe('composite plugin root (#555)', () => { + it('emits one root whose top-level entries are exactly the selected projections and shared surfaces (acceptance 1)', { timeout: 120_000 }, async () => { + const { output } = await buildFixture(['claude', 'codex']); + + // The pinned layout of a Claude Code + Codex root. Every entry has one + // obvious purpose; a future step that adds an entry here must justify it. + expect(await topLevel(output)).toEqual([ + '.agents', // Codex marketplace catalog (.agents/plugins/marketplace.json) + '.claude-plugin', // Claude Code manifest + marketplace catalog + '.codex-plugin', // Codex manifest, hooks document, MCP document + '.mcp.json', // Claude Code MCP document (conventional root path) + 'INSTALL.md', + 'agent-bundle.hooks.json', + 'agent-bundle.manifest.json', + 'commands', + 'hooks', // Claude Code hooks document + every compiled hook wrapper + 'mcp', // compiled MCP entries, shared by every selected host + 'scripts', // compiled scripts, shared by every selected host + 'skills', + ]); + expect(await topLevel(join(output, '.claude-plugin'))).toEqual(['marketplace.json', 'plugin.json']); + expect(await topLevel(join(output, '.codex-plugin'))).toEqual(['hooks.json', 'mcp.json', 'plugin.json']); + expect(await topLevel(join(output, '.agents'))).toEqual(['plugins']); + expect(await topLevel(join(output, 'hooks'))).toEqual([ + 'hooks.json', + 'session-start-session-start-7ab7e8a5.claude.mjs', + 'session-start-session-start-7ab7e8a5.codex.mjs', + ]); + expect(await topLevel(join(output, 'scripts'))).toEqual(['hello.mjs']); + expect(await topLevel(join(output, 'skills'))).toEqual(['review']); + expect(await topLevel(join(output, 'commands'))).toEqual(['summarize.md']); + const mcpEntries = await topLevel(join(output, 'mcp')); + expect(mcpEntries).toHaveLength(1); + expect(mcpEntries[0]).toMatch(/^mcp-fixture-[a-f\d]{8}\.mjs$/u); + }); + + it('omits every unselected projection: a Codex-only root carries no Claude, Cursor, or portable files (acceptance 1, 7)', { timeout: 120_000 }, async () => { + const { output } = await buildFixture(['codex']); + + expect(await topLevel(output)).toEqual([ + '.agents', + '.codex-plugin', + 'INSTALL.md', + 'agent-bundle.hooks.json', + 'agent-bundle.manifest.json', + 'hooks', + 'mcp', + 'scripts', + 'skills', + ]); + // A hook reaching one selected host keeps the unsuffixed wrapper name, + // and only Codex's native hook document is merged in. + // (The hook name hashes its target set, so a Codex-only selection names it differently.) + expect(await topLevel(join(output, 'hooks'))).toEqual(['session-start-session-start-db39ea0c.mjs']); + const hooks = await readJson(join(output, codexArtifactPaths.hooksManifest)) as { + hooks: { SessionStart: { hooks: { command: string }[] }[] }; + }; + expect(hooks.hooks.SessionStart.flatMap((group) => group.hooks.map((hook) => hook.command))).toEqual([ + 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-db39ea0c.mjs"', + 'echo codex-native', + ]); + }); + + it('defaults to the portable projection when targets are omitted (acceptance 4)', { timeout: 120_000 }, async () => { + const { output, result } = await buildFixture(undefined); + + expect(result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + expect(await topLevel(output)).toEqual([ + 'INSTALL.md', + 'agent-bundle.hooks.json', // always written; empty here since portable hosts no hooks + 'agent-bundle.manifest.json', + 'install.mjs', // the self-contained local installer (S5 narrows it to Cursor) + 'mcp', + 'mcp.json', // portable Agent Plugins MCP document + 'plugin.json', // portable Agent Plugins manifest + 'scripts', + 'skills', + ]); + }); + + it('emits byte-identical roots however the targets are ordered (acceptance 5)', { timeout: 180_000 }, async () => { + // One project, selected twice through `--target` in opposite orders, so + // the only variable is the order itself. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-order-')); + roots.push(root); + await writeProject(root); + // The same output directory both times: the build excludes its own output + // from the project's source inputs, so a second directory would show up + // as source of the second build. + const output = join(root, 'artifact'); + const digestBuild = async (targets: readonly string[]): Promise> => { + const result = await build({ output, root, targets }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + return digestTree(output); + }; + const forwardTree = await digestBuild(['claude', 'codex']); + const reversedTree = await digestBuild(['codex', 'claude']); + + expect([...reversedTree.keys()].sort()).toEqual([...forwardTree.keys()].sort()); + const differing = [...forwardTree].filter(([path, digest]) => reversedTree.get(path) !== digest).map(([path]) => path); + expect(differing).toEqual([]); + }); + + it('compiles shared surfaces once and points every selected host document at the same file (acceptance 6)', { timeout: 120_000 }, async () => { + const { output, result } = await buildFixture(['claude', 'codex', 'portable']); + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + + // One compiled MCP entry, one compiled script, one skill tree — each + // listed once in the manifest file table. + const [mcpEntry] = result.build.compiledMcpEntries; + expect(result.build.compiledMcpEntries).toHaveLength(1); + expect(result.build.compiledEntries.map((entry) => relative(output, entry.output))).toEqual(['scripts/hello.mjs']); + const bundles = manifest.files.filter((file) => file.path.startsWith('mcp/') || file.path.startsWith('scripts/')); + expect(bundles.map((file) => file.path).sort()).toEqual([relative(output, mcpEntry!.output), 'scripts/hello.mjs']); + expect(manifest.files.filter((file) => file.path === 'skills/review/SKILL.md')).toHaveLength(1); + + // Each host document names the shared entry in its own dialect. + const entryName = relative(output, mcpEntry!.output); + const serverArguments = (document: unknown): readonly string[] => + (document as { mcpServers: { fixture: { args: string[] } } }).mcpServers.fixture.args; + expect(serverArguments(await readJson(join(output, '.mcp.json')))).toEqual([`\${CLAUDE_PLUGIN_ROOT}/${entryName}`]); + expect(serverArguments(await readJson(join(output, codexArtifactPaths.mcp)))).toEqual([`./${entryName}`]); + expect(serverArguments(await readJson(join(output, 'mcp.json')))).toEqual([entryName]); + }); + + it('keeps every selected host\'s native hook document and drops unselected hosts\' hook files (acceptance 7)', { timeout: 180_000 }, async () => { + const [both, cursorOnly] = await Promise.all([buildFixture(['claude', 'codex']), buildFixture(['cursor'])]); + + const claude = await readJson(join(both.output, 'hooks', 'hooks.json')) as { hooks: { SessionStart: { hooks: { command: string }[] }[] } }; + const codex = await readJson(join(both.output, codexArtifactPaths.hooksManifest)) as typeof claude; + const commands = (document: typeof claude): readonly string[] => + document.hooks.SessionStart.flatMap((group) => group.hooks.map((hook) => hook.command)); + // The old composite stripped native hooks; the composite root keeps each + // host's own document, generated groups first, native groups after. + expect(commands(claude)).toEqual([ + 'node "${CLAUDE_PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.claude.mjs"', + 'echo claude-native', + ]); + expect(commands(codex)).toEqual([ + 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.codex.mjs"', + 'echo codex-native', + ]); + expect(await topLevel(both.output)).not.toContain('.cursor-plugin'); + + // A Cursor-only root has Cursor's documents and none of the other hosts'. + expect(await topLevel(cursorOnly.output)).toEqual([ + '.cursor-plugin', + 'INSTALL.md', + 'agent-bundle.hooks.json', + 'agent-bundle.manifest.json', + 'commands', + 'hooks', + 'install.mjs', + 'mcp', + 'scripts', + 'skills', + ]); + expect(await topLevel(join(cursorOnly.output, '.cursor-plugin'))).toEqual(['hooks.json', 'mcp.json', 'plugin.json']); + expect(await topLevel(join(cursorOnly.output, 'hooks'))).toEqual(['session-start-session-start-9781e2c5.mjs']); + const cursor = await readJson(join(cursorOnly.output, cursorArtifactPaths.hooks)) as { hooks: Record }; + expect(cursor.hooks['sessionStart']).toEqual([{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/session-start-session-start-9781e2c5.mjs"' }]); + }); + + it('records only the selected projections in the artifact manifest and hook index (acceptance 8)', { timeout: 120_000 }, async () => { + const { output } = await buildFixture(['codex', 'claude']); + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + const index = parseArtifactHookIndex(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')); + + expect(manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); + expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + ['claude', 'hooks/session-start-session-start-7ab7e8a5.claude.mjs'], + ['codex', 'hooks/session-start-session-start-7ab7e8a5.codex.mjs'], + ]); + // Nothing in the tree is namespaced by host. + for (const path of manifest.files.map((file) => file.path)) { + expect(path).not.toMatch(/^(?:claude|codex|cursor|portable|plugin)\//u); + } + }); + + it('refuses one path planned with different bytes by two selected projections (AB4103)', { timeout: 120_000 }, async () => { + // A Claude-only frontmatter extension lowers the skill to different + // Markdown for Claude Code than for Codex, yet both hosts read + // `skills/review/SKILL.md`; one root cannot hold both documents. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-collision-')); + roots.push(root); + await writeProject(root, { skillFrontmatter: ['targets:', ' claude:', ' effort: high'], targets: ['codex', 'claude'] }); + const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics).toEqual([expect.objectContaining({ + code: 'AB4103', + generatedPath: 'skills/review/SKILL.md', + message: expect.stringContaining('planned with different contents by the claude and codex projections'), + severity: 'error', + })]); + }); + + it('refuses a host-scoped component another selected host would discover conventionally (AB4105, decision D5)', { timeout: 120_000 }, async () => { + // Claude Code and Cursor both read `commands/`; a Claude-only command + // cannot be isolated inside one root they share. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-leak-')); + roots.push(root); + await writeProject(root, { commandTargets: ['claude'], targets: ['claude', 'cursor'] }); + const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics).toEqual([expect.objectContaining({ + code: 'AB4105', + message: expect.stringContaining('Command "summarize" is scoped to "claude" but "cursor" also discover "commands/" conventionally'), + severity: 'error', + })]); + + // Codex does not read `commands/`, so the same component beside Codex is + // simply Claude's: it is emitted once and only Claude's manifest sees it. + const { output } = await buildFixture(['claude', 'codex'], { commandTargets: ['claude'] }); + expect(await topLevel(join(output, 'commands'))).toEqual(['summarize.md']); + }); + + it('refuses an advanced-registry adapter selected beside any other target, and builds it alone (AB4106)', { timeout: 180_000 }, async () => { + // The built-in hosts agree on how one root is shared; an adapter a + // registry adds has made no such agreement, so beside Claude Code it is + // refused on the model — by `validate` and by `build` alike — and named + // with the config that selected it. + const registry = createDefaultRegistry().register(syntheticAdapter); + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-registry-')); + roots.push(root); + await writeProject(root, { targets: ['claude', 'synthetic'] }); + const refused = { + code: 'AB4106', + message: 'Target "synthetic" cannot share one composite root with the other selected targets (claude): only the built-in hosts (claude, codex, cursor, portable) project into a shared root.', + recovery: 'Build "synthetic" alone — targets: ["synthetic"] — into its own --output, and the other targets into another.', + severity: 'error', + sourcePath: join(root, 'agent-bundle.config.ts'), + target: 'synthetic', + }; + const validated = await validate({ registry, root }); + expect(validated.diagnostics.filter((entry) => entry.code === 'AB4106')).toEqual([refused]); + const failure = await build({ output: join(root, 'artifact'), registry, root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics.filter((entry) => entry.code === 'AB4106')).toEqual([refused]); + + // Alone, the same adapter gets a root of its own; the built-in hosts still + // share one, with the adapter registered but unselected. + const [alone, builtIn] = await Promise.all([ + buildFixture(['synthetic'], { registry }), + buildFixture(['claude', 'codex'], { registry }), + ]); + expect(alone.result.build.manifest.targets.map((target) => target.name)).toEqual(['synthetic']); + expect(await topLevel(alone.output)).toContain(syntheticMcpRuntime.manifestPath); + expect(builtIn.result.build.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); + expect(await topLevel(builtIn.output)).not.toContain(syntheticMcpRuntime.manifestPath); + }); + + it('judges the built-in hosts by adapter identity, so a custom adapter named like one earns no install surface (#592)', { timeout: 120_000 }, async () => { + // An advanced registry may register its own adapter under a built-in + // host's name. The install surface belongs to the shipped adapters, so the + // composite root emits none for it — and the artifact validators demand + // none — while the shipped adapters keep theirs under their own names. + const registry = new TargetRegistry() + .register(syntheticAdapterNamed('portable'), { default: true }) + .register(claudeAdapter) + .register(codexAdapter) + .register(cursorAdapter); + expect(registry.builtInHost('portable')).toBeUndefined(); + expect(registry.builtInHost('claude')).toBe('claude'); + expect(registry.builtInHosts(['portable', 'cursor', 'unknown', 'claude'])).toEqual(['cursor', 'claude']); + expect(createDefaultRegistry().builtInHost('portable')).toBe('portable'); + + const [custom, shipped] = await Promise.all([ + buildFixture(['portable'], { registry }), + buildFixture(['portable'], {}), + ]); + expect(custom.result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + const customTree = await topLevel(custom.output); + expect(customTree).toContain(syntheticMcpRuntime.manifestPath); + expect(customTree).not.toContain('INSTALL.md'); + expect(customTree).not.toContain('install.mjs'); + expect(custom.result.diagnostics.filter((entry) => entry.code === 'AB6023' || entry.code === 'AB6024')).toEqual([]); + expect(await topLevel(shipped.output)).toEqual(expect.arrayContaining(['INSTALL.md', 'install.mjs', 'plugin.json'])); + + // The same identity judgment gates the shared root (`AB4106`) and the + // host validators: beside Claude Code the custom `portable` is refused + // like any advanced-registry adapter, and alone it is held to no shipped + // host's validator. + const mixed = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-identity-')); + roots.push(mixed); + await writeProject(mixed, { targets: ['claude', 'portable'] }); + const refused = await validate({ registry, root: mixed }); + expect(refused.diagnostics.filter((entry) => entry.code === 'AB4106').map((entry) => entry.target)).toEqual(['portable']); + const validated = await validate({ artifact: custom.output, hostValidation: true, registry, root: dirname(custom.output) }); + expect(validated.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(validated.hostValidation).toBeUndefined(); + + // `build --host-validation` runs the Claude developer validator for the + // shipped Claude adapter only: a custom adapter named `claude` spawns no + // host CLI and gets no report. + const claudeNamed = new TargetRegistry() + .register(syntheticAdapterNamed('claude'), { default: true }) + .register(codexAdapter) + .register(cursorAdapter) + .register(portableAdapter); + const spawned: string[][] = []; + const project = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-identity-')); + roots.push(project); + await writeProject(project, { targets: ['claude'] }); + const built = await build({ + hostValidation: true, + hostValidationRunner: async (request) => { + spawned.push([...request.args]); + return { exitCode: 0, signal: null, stderr: '', stdout: '' }; + }, + output: join(project, 'artifact'), + registry: claudeNamed, + root: project, + }); + expect(built.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + expect(built.hostValidation).toEqual([]); + expect(spawned).toEqual([]); + }); +}); diff --git a/packages/agent-bundle/tests/build-reproducibility.test.ts b/packages/agent-bundle/tests/build-reproducibility.test.ts index 793a78e77..4c1b60901 100644 --- a/packages/agent-bundle/tests/build-reproducibility.test.ts +++ b/packages/agent-bundle/tests/build-reproducibility.test.ts @@ -157,11 +157,15 @@ it('emits byte-identical artifacts from two builds of one source into two output // entry imports as a namespace is named by its project-rooted virtual // path — not by the staged directory, the output directory, or the machine. const bundles = [...firstDigests.keys()].filter((path) => path.endsWith('.mjs')); - expect(bundles.some((path) => /^portable\/mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))).toBe(true); + expect(bundles.some((path) => /^mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))).toBe(true); + // One composite root: the hook shared by three selected hosts compiles one + // host-suffixed wrapper each; every other surface is emitted once (#555). expect(bundles).toEqual(expect.arrayContaining([ - 'claude/hooks/event-route-session-start.mjs', - 'portable/bin/reproducible-fixture.mjs', - 'portable/scripts/summarize.mjs', + 'bin/reproducible-fixture.mjs', + 'hooks/event-route-session-start.claude.mjs', + 'hooks/event-route-session-start.codex.mjs', + 'hooks/event-route-session-start.cursor.mjs', + 'scripts/summarize.mjs', ])); const forbidden = [root, parked, '.artifact.stage-', ...stageTokens]; for (const path of bundles) { @@ -170,6 +174,6 @@ it('emits byte-identical artifacts from two builds of one source into two output expect(source, `${path} names ${token}`).not.toContain(token); } } - const mcpEntry = bundles.find((path) => /^portable\/mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))!; + const mcpEntry = bundles.find((path) => /^mcp\/mcp-harness-[a-f\d]{8}\.mjs$/u.test(path))!; expect(await readFile(join(first, mcpEntry), 'utf8')).toMatch(/NAMESPACE OBJECT: \.\/\.agent-bundle-virtual\/mcp-harness-[a-f\d]{8}-\d+\.mjs/u); }); diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 8f560ead1..b24cd350d 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -324,7 +324,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 }, ]); @@ -369,35 +369,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; @@ -413,7 +413,7 @@ it('low-level build writes and returns the exact canonical manifest for a config path: resource.path, sha256: sha256Hex(contents), sourceInputs: expect.arrayContaining([ - resource.path.replace('portable/', 'src/'), + resource.path.replace('', 'src/'), 'src/skills/review/SKILL.md', ]), })); @@ -482,11 +482,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); } @@ -511,7 +511,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', @@ -519,7 +519,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', @@ -841,7 +841,7 @@ it('emits a configured Skill script deterministically and preserves the prior ar } }); -it('rejects duplicate planned destinations before replacing an existing artifact', async () => { +it('rejects same-path different-bytes planned entries (AB4103) before replacing an existing artifact', async () => { const project = await createProject(); const duplicateAdapter: TargetAdapter = { capabilities: {}, @@ -869,23 +869,22 @@ it('rejects duplicate planned destinations before replacing an existing artifact projectRoot: project.root, registry, }), - ).rejects.toThrow(/duplicate/i); + ).rejects.toThrow(/AB4103/); await expect(readFile(join(project.outputRoot, 'previous.txt'), 'utf8')).resolves.toBe('previous\n'); } finally { await cleanupProject(project); } }); -it('rejects an escaped target name before it can write outside the staging artifact', async () => { +it('rejects an escaped planned entry path before it can write outside the staging artifact', async () => { const project = await createProject(); - const targetName = '../escaped-target'; const adapter: TargetAdapter = { capabilities: {}, metadata: testAdapterMetadata, - name: targetName, + name: 'portable', plan: () => ({ diagnostics: [], - entries: [{ content: 'escaped\n', kind: 'write', relativePath: 'plugin.json', sourceInputs: [] }], + entries: [{ content: 'escaped\n', kind: 'write', relativePath: '../escaped-target/plugin.json', sourceInputs: [] }], hookEntries: [], }), }; @@ -896,11 +895,7 @@ it('rejects an escaped target name before it can write outside the staging artif await expect( build({ - model: { - ...modelFor(project), - scripts: [], - targets: [{ ...modelFor(project).targets[0]!, name: targetName }], - }, + model: { ...modelFor(project), scripts: [] }, outputRoot: project.outputRoot, projectRoot: project.root, registry: new TargetRegistry().register(adapter, { default: true }), @@ -969,7 +964,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 { @@ -1425,7 +1420,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..0acb229a2 100644 --- a/packages/agent-bundle/tests/claude-hook-event-name.test.ts +++ b/packages/agent-bundle/tests/claude-hook-event-name.test.ts @@ -4,7 +4,6 @@ import { expect, it } from '@rstest/core'; import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.260.json' with { type: 'json' }; import { claudeAdapter } from '../src/adapters/claude.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import type { NormalizedHook, NormalizedHookEvent, NormalizedPlugin } from '../src/core/types.ts'; import { validateNativeEventEnvelope } from '../src/events/projection.ts'; import type { CanonicalAgentEvent } from '../src/routes/public.ts'; @@ -174,8 +173,7 @@ it('bakes the pinned Claude hook_event_name into every Claude event-route wrappe expect(Object.keys(document.hooks), entry.route).toContain(expectedNativeEvent); expect(wrapper!.nativeEvent, entry.route).toBe(expectedNativeEvent); expect(wrapper!.virtualSource, entry.route).toContain(`const nativeEvent = ${JSON.stringify(expectedNativeEvent)};`); - expect(wrapper!.virtualSource, entry.route).toContain('const artifactTarget = "claude";'); - expect(wrapper!.virtualSource, entry.route).toContain('const target = artifactTarget;'); + expect(wrapper!.virtualSource, entry.route).toContain('const target = "claude";'); expect(wrapper!.virtualSource, entry.route).toContain('validateNativeEventEnvelope(parsed, { canonicalEvent, nativeEvent, target })'); const native = await nativeEnvelope(entry.native); @@ -209,45 +207,14 @@ it('accepts the live PostToolUse:Bash envelope under the Claude wrapper and name )).toThrow('Agent Bundle event route error: native hook_event_name must equal preToolUse'); }); -it('keeps the shared and Cursor wrappers of the unified plugin bundle on their own host spellings', () => { - const plan = pluginAdapter.plan(model('plugin', [ - routeHook('tool/after', 'afterTool', ['plugin']), - routeHook('session/start', 'sessionStart', ['plugin']), - ])); - expect(plan.diagnostics).toEqual([]); - - const shared = (plan.hookEntries ?? []).find((entry) => - entry.event === 'afterTool' && !entry.relativePath.endsWith('.cursor.mjs')); - const cursor = (plan.hookEntries ?? []).find((entry) => - entry.event === 'afterTool' && entry.relativePath.endsWith('.cursor.mjs')); - expect(shared?.nativeEvent).toBe('PostToolUse'); - expect(cursor?.nativeEvent).toBe('postToolUse'); - expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); - expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); - - const documents = writes(plan); - expect(Object.keys((JSON.parse(documents['hooks/hooks.json']!) as { hooks: object }).hooks).sort()).toEqual(['PostToolUse', 'SessionStart']); - expect(Object.keys((JSON.parse(documents['hooks/hooks-cursor.json']!) as { hooks: object }).hooks).sort()).toEqual(['postToolUse', 'sessionStart']); -}); - it('emits no manifest hooks pointer for Claude Code, which auto-loads hooks/hooks.json and flags a pointer at it as a duplicate', () => { // Claude Code 2.1.259 (observed): `hooks/hooks.json` is loaded on its own // and `manifest.hooks` is for additional documents only. Naming the // conventional file records a `hook-load-failed` plugin error, "Duplicate // hooks file detected ... The standard hooks/hooks.json is loaded // automatically, so manifest.hooks should only reference additional hook - // files." Claude Code never scans `hooks/` for other documents, so the - // unified bundle's `hooks/hooks-cursor.json` needs no pointer to hide it. + // files." Claude Code never scans `hooks/` for other documents. const claude = writes(claudeAdapter.plan(model('claude', [routeHook('tool/after', 'afterTool', ['claude'])]))); expect(claude['hooks/hooks.json']).toBeDefined(); expect(JSON.parse(claude['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - - const bundle = writes(pluginAdapter.plan(model('plugin', [routeHook('tool/after', 'afterTool', ['plugin'])]))); - expect(bundle['hooks/hooks.json']).toBeDefined(); - expect(bundle['hooks/hooks-cursor.json']).toBeDefined(); - expect(JSON.parse(bundle['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - // Codex discovers the same conventional file; Cursor's own contract needs - // the explicit pointer because its document does not live at the default. - expect(JSON.parse(bundle['.codex-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - expect(JSON.parse(bundle['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks-cursor.json' }); }); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 198dd0be4..55fcdc5d2 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -396,8 +396,8 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // The rendered .tsx script (#102 stage 3) ships beside plain scripts in // the target artifact with the same output contract. - const scriptPath = join(root, 'artifact', 'portable', 'scripts', 'summarize.mjs'); - await expect(stat(join(root, 'artifact', 'portable', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); + const scriptPath = join(root, 'artifact', 'scripts', 'summarize.mjs'); + await expect(stat(join(root, 'artifact', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); // The rendered script's provider sees `invocation.kind === 'script'` (#313). @@ -405,11 +405,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 0df1fde20..c979aa081 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -291,7 +291,9 @@ it('builds a selected target through the built executable from a path containing build: { outputRoot: resolve(project.output) }, model: { metadata: { name: 'cli-fixture' }, - targets: [{ name: 'portable' }, { name: 'codex' }], + // Selected projections are recorded in composite order (#555), so the + // model and the manifest agree whatever the CLI order was. + targets: [{ name: 'codex' }, { name: 'portable' }], }, }); expect(JSON.parse(await readFile(join(project.output, 'agent-bundle.manifest.json'), 'utf8'))).toMatchObject({ @@ -302,6 +304,27 @@ it('builds a selected target through the built executable from a path containing } }, 30_000 * timeScale); +it('rejects --target plugin as an unknown target (#555 acceptance 3)', async () => { + // `plugin` is not a public target: the composite root is the only output + // shape, and `claude|codex|cursor|portable` select the projections inside it. + const project = await createCliProject(); + try { + const result = await runSourceCliWithOutput([ + 'build', '--root', project.root, '--output', project.output, '--target', 'plugin', '--json', + ]); + + expect(result).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(result.stderr)).toEqual([expect.objectContaining({ + code: 'AB4100', + message: 'Unknown target "plugin".', + severity: 'error', + target: 'plugin', + })]); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}); + /** * Runs the built CLI under the module-load recorder and returns the process * result plus every non-builtin module URL the invocation resolved. @@ -511,9 +534,9 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( expect(firstInspectionDocument).toMatchObject({ model: { metadata: { name: 'cli-fixture' }, - targets: [{ name: 'portable' }, { name: 'codex' }], + targets: [{ name: 'codex' }, { name: 'portable' }], }, - plans: [{ target: 'portable' }, { target: 'codex' }], + plans: [{ target: 'codex' }, { target: 'portable' }], state: 'ready', }); expect(firstInspectionDocument.plans).toHaveLength(2); @@ -523,7 +546,7 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( ]); expect(filteredInspection).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(filteredInspection.stdout)).toMatchObject({ - model: { targets: [{ name: 'portable' }, { name: 'codex' }] }, + model: { targets: [{ name: 'codex' }, { name: 'portable' }] }, plans: [{ target: 'portable' }], }); expect((JSON.parse(filteredInspection.stdout) as { readonly plans: readonly unknown[] }).plans).toHaveLength(1); @@ -645,7 +668,9 @@ it('build compiles a declared MCP App view and reports its document and measured }; readonly diagnostics: readonly unknown[]; }; - expect(document.build.compiledMcpApps).toMatchObject([{ name: 'dashboard', target: 'portable' }]); + // The view is compiled once for the composite root and attributed to the + // selection's identity (#555), even though only portable declares it. + expect(document.build.compiledMcpApps).toMatchObject([{ name: 'dashboard', target: 'codex+portable' }]); const size = document.build.compiledMcpApps[0]!.size; expect(size.bytes).toBeGreaterThan(0); expect(size.gzipBytes).toBeGreaterThan(0); @@ -659,7 +684,7 @@ it('build compiles a declared MCP App view and reports its document and measured expect(human).toMatchObject({ code: 0, stderr: '' }); expect(human.stdout).toContain(`Built cli-fixture to ${project.output}\n`); expect(human.stdout).toMatch( - /^MCP App dashboard \(portable\): mcp-apps\/dashboard\.html \d+(?:\.\d)? [KM]iB \(\d+(?:\.\d)? [KM]iB gzip\)$/mu, + /^MCP App dashboard \(codex\+portable\): mcp-apps\/dashboard\.html \d+(?:\.\d)? [KM]iB \(\d+(?:\.\d)? [KM]iB gzip\)$/mu, ); } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); @@ -739,7 +764,7 @@ it('explains selected and omitted components per target on human inspect output' const human = await runSourceCliWithOutput(['inspect', '--root', project.root]); expect(human).toMatchObject({ code: 0, stderr: '' }); - expect(human.stdout).toContain('Inspected cli-fixture: portable, codex\n'); + expect(human.stdout).toContain('Inspected cli-fixture: codex, portable\n'); expect(human.stdout).toContain('portable: 1 component(s) selected, 1 omitted\n'); expect(human.stdout).toContain('codex: 1 component(s) selected, 1 omitted\n'); expect(human.stdout).toMatch(/^ {2}omitted rule shared: rules unavailable — .+$/mu); @@ -865,7 +890,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( const script = document.selected.bundler.entries.find((entry) => entry.kind === 'script'); expect(script).toMatchObject({ config: { - output: { distPath: { root: '/portable' } }, + output: { distPath: { root: '' } }, tools: { rspack: [ { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, diff --git a/packages/agent-bundle/tests/codex-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..dfabf8892 100644 --- a/packages/agent-bundle/tests/codex-hook-contract.test.ts +++ b/packages/agent-bundle/tests/codex-hook-contract.test.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { expect, it } from '@rstest/core'; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; -import { codexAdapter } from '../src/adapters/codex.ts'; +import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; import { encodeNativeHookPlaygroundOutput } from '../src/adapters/hook-contract.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import hooksSchema from '../src/adapters/schemas/codex/hooks.schema.json' with { type: 'json' }; @@ -99,14 +99,13 @@ const planCodes = (model: NormalizedPlugin): readonly string[] => codexAdapter.plan(model).diagnostics.map((diagnostic) => diagnostic.code); const emittedHooks = (model: NormalizedPlugin): unknown => { - const entry = codexAdapter.plan(model).entries.find((candidate) => candidate.relativePath === 'hooks/hooks.json'); + const entry = codexAdapter.plan(model).entries.find((candidate) => candidate.relativePath === codexArtifactPaths.hooksManifest); if (entry?.kind !== 'write') return undefined; return JSON.parse(entry.content); }; -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 entry.relativePath === 'hooks/hooks.json')).toBe(false); + expect(plan.entries.some((entry) => entry.relativePath === codexArtifactPaths.hooksManifest)).toBe(false); }); it('rejects handler fields the Codex host would ignore or refuse for the event', () => { @@ -258,7 +255,7 @@ it('rejects handler fields the Codex host would ignore or refuse for the event', message: expect.stringContaining('at most 3 seconds'), recovery: expect.stringContaining('3 seconds or less'), }); - expect(rejected.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); + expect(rejected.entries.some((entry) => entry.relativePath === codexArtifactPaths.hooksManifest)).toBe(false); }); it('emits a native document that uses every documented handler field unchanged', () => { diff --git a/packages/agent-bundle/tests/codex-plugin-validation.test.ts b/packages/agent-bundle/tests/codex-plugin-validation.test.ts index b5fd17eb9..60a089e4d 100644 --- a/packages/agent-bundle/tests/codex-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/codex-plugin-validation.test.ts @@ -37,18 +37,18 @@ const validDocuments = Object.freeze({ longDescription: 'A fixture plugin.', shortDescription: 'A fixture plugin.', }, - hooks: './hooks/hooks.json', - mcpServers: './.mcp.json', + hooks: './.codex-plugin/hooks.json', + mcpServers: './.codex-plugin/mcp.json', name: 'fixture', skills: './skills/', version: '1.0.0', }, - '.mcp.json': { + '.codex-plugin/mcp.json': { mcpServers: { fixture: { command: 'node', type: 'stdio' }, }, }, - 'hooks/hooks.json': { + '.codex-plugin/hooks.json': { hooks: { Stop: [{ hooks: [{ command: 'node ./hooks/stop.mjs', type: 'command' }], @@ -292,6 +292,24 @@ it('reports app-server-only schema output as unassessable information even in st } }); +it('judges only the Codex documents under .codex-plugin/ in a root shared with Claude (#555)', async () => { + // The composite root is every selected host's plugin root: Claude's + // `.mcp.json` and `hooks/hooks.json` sit beside Codex's `.codex-plugin/*`. + // Claude's documents carry shapes Codex's schemas reject (an `http` server, + // an empty command), and the Codex validator must not read them. + const pluginDirectory = await writeBundle({ + '.mcp.json': { mcpServers: { remote: { type: 'http', url: 'https://example.test/mcp' } } }, + 'hooks/hooks.json': { hooks: { Stop: [{ hooks: [{ command: '', type: 'command' }] }] } }, + }); + try { + const report = await validateCodexPlugin({ pluginDirectory, run: runWith('match').run, target: 'codex' }); + expect(report.diagnostics.filter((entry) => entry.code === 'AB6032')).toEqual([]); + expect(report.status).toBe('passed'); + } finally { + await rm(pluginDirectory, { force: true, recursive: true }); + } +}); + it('rejects malformed fixtures for every locally validated Codex schema', async () => { const malformed = [ ['.codex-plugin/plugin.json', { ...validDocuments['.codex-plugin/plugin.json'], name: 'Invalid Name' }], @@ -309,8 +327,8 @@ it('rejects malformed fixtures for every locally validated Codex schema', async ...validDocuments['.codex-plugin/plugin.json'], interface: { ...validDocuments['.codex-plugin/plugin.json'].interface, screenshots: ['./assets/../outside.png'] }, }], - ['hooks/hooks.json', { hooks: { Stop: [{ hooks: [{ command: '', type: 'command' }] }] } }], - ['.mcp.json', { mcpServers: { fixture: { type: 'streamable-http', url: 'not a uri' } } }], + ['.codex-plugin/hooks.json', { hooks: { Stop: [{ hooks: [{ command: '', type: 'command' }] }] } }], + ['.codex-plugin/mcp.json', { mcpServers: { fixture: { type: 'streamable-http', url: 'not a uri' } } }], ['.agents/plugins/marketplace.json', { ...validDocuments['.agents/plugins/marketplace.json'], plugins: [], diff --git a/packages/agent-bundle/tests/target-stages.test.ts b/packages/agent-bundle/tests/compile-stages.test.ts similarity index 93% rename from packages/agent-bundle/tests/target-stages.test.ts rename to packages/agent-bundle/tests/compile-stages.test.ts index 0a1538931..57248be44 100644 --- a/packages/agent-bundle/tests/target-stages.test.ts +++ b/packages/agent-bundle/tests/compile-stages.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { generatedMetaModulePath, metaModuleSpecifier } from '../src/build/meta.ts'; import { buildRslibSurfaces, compileRslibSurfaces, entryLibId, settledRslibSurface, type RslibEntry } from '../src/build/rslib.ts'; -import { planTargetStages } from '../src/build/target-stages.ts'; +import { planCompileStages } from '../src/build/compile-stages.ts'; import type { AgentBundleMeta } from '../src/meta.ts'; const meta: AgentBundleMeta = Object.freeze({ @@ -16,9 +16,9 @@ const meta: AgentBundleMeta = Object.freeze({ version: '1.0.0', }); -const root = '/staged/claude'; +const root = '/staged/artifact'; -describe('planTargetStages', () => { +describe('planCompileStages', () => { const nodeOutputs = { compiledCliBins: [{ output: `${root}/bin/tool.mjs`, workerOutput: `${root}/bin/tool-flight.mjs` }], compiledEntries: [ @@ -32,8 +32,8 @@ describe('planTargetStages', () => { compiledMcpEntries: [{ output: `${root}/mcp/server.mjs`, workerOutput: `${root}/mcp/server-flight.mjs` }], }; - it('skips the browser stage entirely for a target without MCP Apps and lowers every host surface in one node stage', () => { - const stages = planTargetStages({ ...nodeOutputs, compiledMcpApps: [] }); + it('skips the browser stage entirely for a root without MCP Apps and lowers every host surface in one node stage', () => { + const stages = planCompileStages({ ...nodeOutputs, compiledMcpApps: [] }); expect(stages.map((stage) => stage.kind)).toEqual(['node-surfaces']); expect(stages[0]!.outputs).toEqual([ `${root}/bin/tool.mjs`, @@ -48,8 +48,8 @@ describe('planTargetStages', () => { ]); }); - it('runs the browser stage before the node stage only when the target declares MCP Apps', () => { - const stages = planTargetStages({ + it('runs the browser stage before the node stage only when the selection reaches an MCP App', () => { + const stages = planCompileStages({ ...nodeOutputs, compiledMcpApps: [{ output: `${root}/mcp-apps/dashboard.html` }], }); @@ -60,7 +60,7 @@ describe('planTargetStages', () => { }); it('keeps each react-server Flight worker in the same stage as the host surface that spawns it', () => { - const [stage] = planTargetStages({ ...nodeOutputs, compiledMcpApps: [] }); + const [stage] = planCompileStages({ ...nodeOutputs, compiledMcpApps: [] }); for (const [host, worker] of [ [`${root}/bin/tool.mjs`, `${root}/bin/tool-flight.mjs`], [`${root}/scripts/report.mjs`, `${root}/scripts/report-flight.mjs`], @@ -72,8 +72,8 @@ describe('planTargetStages', () => { } }); - it('plans an empty node stage for a target with nothing to compile', () => { - expect(planTargetStages({ + it('plans an empty node stage for a root with nothing to compile', () => { + expect(planCompileStages({ compiledCliBins: [], compiledEntries: [], compiledHooks: [], @@ -107,8 +107,8 @@ const surfaceEntry = (name: string, outputRelativePath: string, source: string): }); describe('buildRslibSurfaces', () => { - it('lowers every surface of a target through one Rslib instance and hands each surface its own evidence', async () => { - const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-target-stages-')); + it('lowers every surface of the root through one Rslib instance and hands each surface its own evidence', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-compile-stages-')); const project = '/project'; const surfaces = [ { entries: [surfaceEntry('bin-tool', 'bin/tool.mjs', `${project}/src/cli/index.ts`)], ignoredSourcePaths: [`${project}/runtime/cli`] }, diff --git a/packages/agent-bundle/tests/composite-rules.test.ts b/packages/agent-bundle/tests/composite-rules.test.ts new file mode 100644 index 000000000..fc8b54ff6 --- /dev/null +++ b/packages/agent-bundle/tests/composite-rules.test.ts @@ -0,0 +1,307 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; + +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; +import { build, type BuildProjectResult, inspect, validate } from '../src/api.ts'; +import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; +import { type ArtifactManifest, parseArtifactManifest } from '../src/build/manifest.ts'; +import { type Diagnostic, DiagnosticError } from '../src/core/diagnostics.ts'; + +/** + * Composite-root rules ported from the superseded #569 (#555): the ones that + * hold whichever way the root is laid out, checked against this branch's one + * composite root. Assertions are on the emitted tree and the public API, + * never on planner internals. build-compose.test.ts owns the pinned layout; + * this file covers what it leaves open: every built-in host in ONE root, the + * install surface of a multi-host root, the Claude Code + Cursor `commands/` + * dialect collision, and `validate`/`inspect` parity with `build`. + */ + +const roots: string[] = []; +afterAll(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, content: string): Promise => { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); +}; + +const nativeHookDocument = (command: string): string => `${JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command, type: 'command' }] }] }, +})}\n`; + +interface FixtureOptions { + /** Frontmatter lines of one conventional command; omitted → the project declares no command. */ + readonly commandFrontmatter?: readonly string[]; + /** Extra frontmatter lines for the shared skill. */ + readonly skillFrontmatter?: readonly string[]; + readonly targets: readonly string[]; +} + +/** + * One project with a shared skill, a shared script, a shared MCP server, a + * generated hook, a native hook document for Claude Code and for Codex, a + * plugin logo, and optionally one conventional command. No runtime + * dependencies: every compiled surface is plain TypeScript. + */ +const writeProject = async (root: string, options: FixtureOptions): Promise => { + const command = options.commandFrontmatter === undefined + ? [] + : [writeProjectFile(root, 'src/commands/summarize.md', [ + ...(options.commandFrontmatter.length === 0 ? [] : ['---', ...options.commandFrontmatter, '---']), + 'Summarize the current diff.', + '', + ].join('\n'))]; + await Promise.all([ + writeProjectFile(root, 'package.json', `${JSON.stringify({ name: 'composite-fixture', type: 'module', version: '1.0.0' })}\n`), + writeProjectFile(root, 'agent-bundle.config.ts', [ + // A plain object: the fixture has no node_modules to resolve `agent-bundle/config` from. + 'export default {', + " claude: { nativeHooks: './native/claude.json' },", + " codex: { nativeHooks: './native/codex.json' },", + " hooks: { sessionStart: './src/hooks/session-start.ts' },", + " mcp: { servers: { fixture: { entry: './src/mcp/fixture.ts' } } },", + " plugin: { description: 'Composite root fixture.', logo: 'docs/media/logo.svg', name: 'composite-fixture', version: '1.0.0' },", + " scripts: { hello: './src/tools/hello.ts' },", + ` targets: ${JSON.stringify(options.targets)},`, + '};', + '', + ].join('\n')), + writeProjectFile(root, 'docs/media/logo.svg', '\n'), + writeProjectFile(root, 'native/claude.json', nativeHookDocument('echo claude-native')), + writeProjectFile(root, 'native/codex.json', nativeHookDocument('echo codex-native')), + writeProjectFile(root, 'src/hooks/session-start.ts', "export default () => ({ outcome: 'continue' as const, additionalContext: 'started' });\n"), + writeProjectFile(root, 'src/mcp/fixture.ts', "process.stderr.write('fixture server\\n');\n"), + writeProjectFile(root, 'src/tools/hello.ts', "console.log('hello');\n"), + writeProjectFile(root, 'src/skills/review/SKILL.md', [ + '---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '', + ].join('\n')), + ...command, + ]); +}; + +const temporaryProject = async (options: FixtureOptions): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-rules-')); + roots.push(root); + await writeProject(root, options); + return root; +}; + +interface BuiltRoot { + readonly manifest: ArtifactManifest; + readonly output: string; + readonly result: BuildProjectResult; +} + +const buildProject = async (root: string): Promise => { + const output = join(root, 'artifact'); + const result = await build({ output, root }); + expect(result.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); + return { manifest: parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')), output, result }; +}; + +/** The diagnostics `build` refused the root with; fails when the build went through. */ +const buildRefusal = async (root: string): Promise => { + const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + return (failure as DiagnosticError).diagnostics; +}; + +const readJson = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); + +const topLevel = async (root: string): Promise => (await readdir(root)).sort(); + +const errors = (diagnostics: readonly Diagnostic[]): readonly Diagnostic[] => + diagnostics.filter((entry) => entry.severity === 'error'); + +/** How many times `path` is listed in the artifact manifest's file table. */ +const listed = (manifest: ArtifactManifest, path: string): number => + manifest.files.filter((file) => file.path === path).length; + +const hostManifestPaths: Readonly> = Object.freeze({ + claude: '.claude-plugin/plugin.json', + codex: codexArtifactPaths.plugin, + cursor: cursorArtifactPaths.plugin, + portable: 'plugin.json', +}); + +describe('one root projecting every built-in host (#569 "lays every host manifest over one shared plugin root")', () => { + // Written in an arbitrary order on purpose: every composite output must + // come out in host-name order regardless (acceptance 5). + const selection = ['portable', 'cursor', 'codex', 'claude']; + let built: BuiltRoot; + beforeAll(async () => { + built = await buildProject(await temporaryProject({ targets: selection })); + }, 180_000); + + it('places each selected host\'s manifest at the root exactly once and never lists one path twice', async () => { + expect(built.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex', 'cursor', 'portable']); + for (const path of Object.values(hostManifestPaths)) { + await expect(readFile(join(built.output, path), 'utf8')).resolves.toContain('"composite-fixture"'); + expect(listed(built.manifest, path), path).toBe(1); + } + const paths = built.manifest.files.map((file) => file.path); + expect(new Set(paths).size).toBe(paths.length); + // Codex and Cursor read their hook and MCP documents through manifest + // pointers beside their manifests; Claude Code auto-loads hooks/hooks.json + // and reports a manifest pointer at it as a duplicate, so it names none. + expect(await readJson(join(built.output, hostManifestPaths.codex))).toMatchObject({ + hooks: `./${codexArtifactPaths.hooksManifest}`, + mcpServers: `./${codexArtifactPaths.mcp}`, + }); + expect(await readJson(join(built.output, hostManifestPaths.cursor))).toMatchObject({ + hooks: `./${cursorArtifactPaths.hooks}`, + mcpServers: `./${cursorArtifactPaths.mcp}`, + }); + expect(await readJson(join(built.output, hostManifestPaths.claude))).not.toHaveProperty('hooks'); + }); + + it('emits every shared surface once — skill, script, MCP entry, logo — and points each host document at the same file', async () => { + expect(listed(built.manifest, 'skills/review/SKILL.md')).toBe(1); + expect(listed(built.manifest, 'scripts/hello.mjs')).toBe(1); + expect(built.result.build.compiledEntries).toHaveLength(1); + expect(built.result.build.compiledMcpEntries).toHaveLength(1); + const mcpEntries = built.manifest.files.map((file) => file.path).filter((path) => path.startsWith('mcp/')); + expect(mcpEntries).toHaveLength(1); + const [entryName] = mcpEntries; + expect(entryName).toMatch(/^mcp\/mcp-fixture-[a-f\d]{8}\.mjs$/u); + + const serverArguments = async (document: string): Promise => + ((await readJson(join(built.output, document))) as { mcpServers: { fixture: { args: string[] } } }).mcpServers.fixture.args; + expect(await serverArguments('.mcp.json')).toEqual([`\${CLAUDE_PLUGIN_ROOT}/${entryName}`]); + expect(await serverArguments(codexArtifactPaths.mcp)).toEqual([`./${entryName}`]); + expect(await serverArguments(cursorArtifactPaths.mcp)).toEqual([`\${CURSOR_PLUGIN_ROOT}/${entryName}`]); + expect(await serverArguments('mcp.json')).toEqual([entryName]); + + // The logo is copied once; only the manifests whose host reads a logo + // name it (Cursor at the top level), never Claude Code's. + expect(built.manifest.files.map((file) => file.path).filter((path) => path.startsWith('assets/'))).toEqual(['assets/docs/media/logo.svg']); + expect(await readJson(join(built.output, hostManifestPaths.cursor))).toMatchObject({ logo: './assets/docs/media/logo.svg' }); + expect(await readJson(join(built.output, hostManifestPaths.claude))).not.toHaveProperty('logo'); + expect(await readJson(join(built.output, hostManifestPaths.codex))).not.toHaveProperty('logo'); + }); + + it('writes one INSTALL.md for the whole root with a section per selected host, and one installer', async () => { + expect(listed(built.manifest, 'INSTALL.md')).toBe(1); + expect(listed(built.manifest, 'install.mjs')).toBe(1); + const install = await readFile(join(built.output, 'INSTALL.md'), 'utf8'); + // One second-level heading per selected host, in the fixed host order — + // not the order `targets` was written in. + expect(install.split('\n').filter((line) => line.startsWith('## '))).toEqual([ + '## Claude Code', + '## Codex', + '## Cursor', + '## Portable Agent Plugin', + ]); + expect(install).toContain('claude plugin install composite-fixture@composite-fixture-marketplace --scope user'); + expect(install).toContain('codex plugin add composite-fixture@composite-fixture-marketplace'); + expect(install).toContain('node ./install.mjs'); + expect(await readFile(join(built.output, 'install.mjs'), 'utf8')).toContain("join(cursorRoot, 'plugins', 'local')"); + }); + + it('compiles one wrapper per hook host, indexes each once, and keeps every native hook document', async () => { + const wrappers = (await topLevel(join(built.output, 'hooks'))).filter((name) => name.endsWith('.mjs')); + // Portable hosts no hooks; the other three each get a host-suffixed wrapper of one stem. + const stems = new Set(wrappers.map((name) => name.replace(/\.(?:claude|codex|cursor)\.mjs$/u, ''))); + expect([...stems]).toHaveLength(1); + const [stem] = stems; + expect(wrappers).toEqual([`${stem}.claude.mjs`, `${stem}.codex.mjs`, `${stem}.cursor.mjs`]); + for (const wrapper of wrappers) expect(listed(built.manifest, `hooks/${wrapper}`)).toBe(1); + + const index = parseArtifactHookIndex(await readFile(join(built.output, 'agent-bundle.hooks.json'), 'utf8')); + expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + ['claude', `hooks/${stem}.claude.mjs`], + ['codex', `hooks/${stem}.codex.mjs`], + ['cursor', `hooks/${stem}.cursor.mjs`], + ]); + + type ClaudeFormat = { hooks: { SessionStart: { hooks: { command: string }[] }[] } }; + const commands = (document: ClaudeFormat): readonly string[] => + document.hooks.SessionStart.flatMap((group) => group.hooks.map((hook) => hook.command)); + // Generated groups first, each host's own native groups after. + expect(commands(await readJson(join(built.output, 'hooks', 'hooks.json')) as ClaudeFormat)).toEqual([ + `node "\${CLAUDE_PLUGIN_ROOT}/hooks/${stem}.claude.mjs"`, + 'echo claude-native', + ]); + expect(commands(await readJson(join(built.output, codexArtifactPaths.hooksManifest)) as ClaudeFormat)).toEqual([ + `node "\${PLUGIN_ROOT}/hooks/${stem}.codex.mjs"`, + 'echo codex-native', + ]); + const cursor = await readJson(join(built.output, cursorArtifactPaths.hooks)) as { hooks: Record }; + expect(cursor.hooks['sessionStart']).toEqual([{ command: `node "\${CURSOR_PLUGIN_ROOT}/hooks/${stem}.cursor.mjs"` }]); + }); +}); + +describe('the commands/ directory Claude Code and Cursor share (#569 "Claude-format commands", AB4104 there)', () => { + it('refuses a command both hosts select once their dialects differ (AB4103), and shares a frontmatter-free one', { timeout: 180_000 }, async () => { + // Claude Code lowers `description` into frontmatter; Cursor's commands + // surface is frontmatter-free and keeps the body only. Both read + // `commands/summarize.md`, so the root cannot hold the two documents. + const collision = await temporaryProject({ commandFrontmatter: ['description: Summarize the diff'], targets: ['claude', 'cursor'] }); + expect(await buildRefusal(collision)).toEqual([expect.objectContaining({ + code: 'AB4103', + generatedPath: 'commands/summarize.md', + message: expect.stringContaining('planned with different contents by the claude and cursor projections'), + severity: 'error', + })]); + + // With nothing to lower, both projections plan the same bytes: emitted + // once, and Cursor's manifest points at the directory Claude Code scans. + const shared = await buildProject(await temporaryProject({ commandFrontmatter: [], targets: ['claude', 'cursor'] })); + expect(await topLevel(join(shared.output, 'commands'))).toEqual(['summarize.md']); + expect(listed(shared.manifest, 'commands/summarize.md')).toBe(1); + await expect(readFile(join(shared.output, 'commands', 'summarize.md'), 'utf8')).resolves.toBe('Summarize the current diff.\n'); + expect(await readJson(join(shared.output, hostManifestPaths.cursor))).toMatchObject({ commands: './commands/' }); + }); +}); + +interface CompositionRefusal { + /** Hosts that, selected alone, make a valid root of the same project. */ + readonly alone: readonly string[]; + /** The field that locates the refusal (`generatedPath` for a collision, `sourcePath` for a scope leak). */ + readonly location: Readonly>; + readonly project: FixtureOptions; +} + +/** The two composition refusals build-compose.test.ts pins for `build`, with the projects that trigger them. */ +const compositionRefusals: readonly (readonly [string, CompositionRefusal])[] = [ + ['AB4103', { + alone: ['claude', 'codex'], + location: { generatedPath: 'skills/review/SKILL.md' }, + project: { skillFrontmatter: ['targets:', ' claude:', ' effort: high'], targets: ['claude', 'codex'] }, + }], + ['AB4105', { + alone: ['claude'], + location: { sourcePath: expect.stringMatching(/\/src\/commands\/summarize\.md$/u) }, + project: { commandFrontmatter: ['description: Summarize the diff', 'targets: ["claude"]'], targets: ['claude', 'cursor'] }, + }], +]; + +describe('validate and inspect judge the shared root exactly as build does (#569 "judges the one plugin root every selected target shares")', () => { + // An operator running `validate` or `inspect` before `build` must see the + // composition refusal there, not a ready project that the build then throws on. + it.each(compositionRefusals)('reports %s from validate and inspect, not only from build', { timeout: 180_000 }, async (code, refusal) => { + const root = await temporaryProject(refusal.project); + const expected = [expect.objectContaining({ code, severity: 'error', ...refusal.location })]; + expect(errors(await buildRefusal(root))).toEqual(expected); + + // Soft, so one report shows both surfaces' verdicts. + const validated = await validate({ root }); + expect.soft(errors(validated.diagnostics), 'validate').toEqual(expected); + + const inspected = await inspect({ root }); + expect.soft(inspected.state, 'inspect').toBe('invalid'); + expect.soft(errors(inspected.diagnostics), 'inspect').toEqual(expected); + + // Each host alone is a valid root over the same project. + for (const target of refusal.alone) { + expect(errors((await validate({ root, targets: [target] })).diagnostics), target).toEqual([]); + } + }); +}); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index c70990fe3..b81341141 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -9,7 +9,6 @@ import { cursorPluginValidator, isValidCursorPluginName, } from '../src/adapters/cursor.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import { readTargetMcpServers } from '../src/services/mcp-runtime.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; @@ -78,7 +77,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); @@ -87,10 +86,10 @@ it('registers cursor as a first-class target with pinned schema validation', () expect(registry.supports('cursor', 'marketplace')).toBe(true); expect(registry.hookContract('cursor')?.commandRoot).toBe('${CURSOR_PLUGIN_ROOT}'); expect(registry.artifactValidation('cursor').documents).toEqual([ + { path: '.cursor-plugin/hooks.json', required: false, schema: 'hooks' }, { path: '.cursor-plugin/marketplace.json', required: false, schema: 'marketplace' }, + { path: '.cursor-plugin/mcp.json', required: false, schema: 'mcp' }, { path: '.cursor-plugin/plugin.json', required: true, schema: 'plugin' }, - { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, - { path: 'mcp.json', required: false, schema: 'mcp' }, ]); expect(registry.artifactLayout('cursor').commands).toEqual({ allowedSuffixes: ['.md'], @@ -116,22 +115,12 @@ it('holds the 64-character plugin-name bound in both Cursor-producing planners', const named = (name: string): NormalizedPlugin => ({ ...model, metadata: { ...model.metadata, name }, - targets: [ - ...model.targets, - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: configPath } }, - ], }); expect(cursorAdapter.plan(named(overLong)).diagnostics.filter((entry) => entry.code === 'cursor.name')).toEqual([ { code: 'cursor.name', message: cursorPluginNameError(overLong), severity: 'error', target: 'cursor' }, ]); - expect(pluginAdapter.plan(named(overLong)).diagnostics.filter((entry) => entry.code === 'plugin.cursor.name')).toEqual([ - { code: 'plugin.cursor.name', message: cursorPluginNameError(overLong), severity: 'error', target: 'plugin' }, - ]); - - for (const plan of [cursorAdapter.plan(named(boundary)), pluginAdapter.plan(named(boundary))]) { - expect(plan.diagnostics.filter((entry) => entry.code.endsWith('cursor.name'))).toEqual([]); - } + expect(cursorAdapter.plan(named(boundary)).diagnostics.filter((entry) => entry.code.endsWith('cursor.name'))).toEqual([]); }); it('validates Cursor documents against the vendored real-host schemas', () => { @@ -176,13 +165,10 @@ const withCursorConfig = (value: unknown): NormalizedPlugin => ({ value, }, }, - targets: [ - ...plugin().targets, - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: configPath } }, - ], + targets: plugin().targets, }); -it('registers the cursor config extension and emits schema-admitted manifest metadata on both Cursor manifests', () => { +it('registers the cursor config extension and emits schema-admitted manifest metadata on the Cursor manifest', () => { const registry = createDefaultRegistry(); expect(registry.configExtensions().map((extension) => extension.key)).toContain('cursor'); @@ -215,18 +201,6 @@ it('registers the cursor config extension and emits schema-admitted manifest met expect(cursorPluginValidator(manifest)).toBe(true); const manifestEntry = plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json'); expect(manifestEntry?.sourceInputs).toContain(configPath); - - const bundle = pluginAdapter.plan(model); - expect(bundle.diagnostics).toEqual([]); - const bundleManifest = JSON.parse( - (bundle.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, - ) as Record; - expect(bundleManifest).toMatchObject({ author: { name: 'Example DevTools' }, minClientVersions: { cursor: '3.13.0' }, publisher: 'Example' }); - const claudeManifest = JSON.parse( - (bundle.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json') as { readonly content: string }).content, - ) as Record; - expect(claudeManifest).not.toHaveProperty('publisher'); - expect(claudeManifest).not.toHaveProperty('minClientVersions'); }); it('rejects cursor manifest metadata the pinned schema does not admit and emits no partial metadata', () => { @@ -253,8 +227,6 @@ it('rejects cursor manifest metadata the pinned schema does not admit and emits for (const field of ['author', 'homepage', 'keywords', 'license', 'minClientVersions', 'repository']) { expect(manifest).not.toHaveProperty(field); } - expect(pluginAdapter.plan(model).diagnostics.map((diagnostic) => diagnostic.code)).toContain('plugin.cursor.manifest.author.invalid'); - expect(cursorAdapter.plan(withCursorConfig({ minClientVersions: {} })).diagnostics.map((diagnostic) => diagnostic.code)) .toEqual(['cursor.manifest.minClientVersions.invalid']); expect(cursorAdapter.plan(withCursorConfig({})).diagnostics).toEqual([]); @@ -328,18 +300,18 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(plan.hookEntries).toEqual([]); const documents = writeContents(model); + // The install surface (INSTALL.md, install.mjs) is written once for the + // composite root by the build, not by each host's planner. expect(Object.keys(documents).sort()).toEqual([ + '.cursor-plugin/mcp.json', '.cursor-plugin/plugin.json', - 'INSTALL.md', - 'install.mjs', - 'mcp.json', ]); const manifest = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; expect(manifest).toEqual({ description: 'Review helpers for Cursor.', displayName: 'cursor-review', - mcpServers: './mcp.json', + mcpServers: './.cursor-plugin/mcp.json', name: 'cursor-review', skills: './skills/', variables: { @@ -357,7 +329,7 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit entry.relativePath === artifactPath || entry.relativePath.startsWith(`${artifactPath}/`))).toBe(true); } - const mcp = JSON.parse(documents['mcp.json']!) as { readonly mcpServers: Record> }; + const mcp = JSON.parse(documents['.cursor-plugin/mcp.json']!) as { readonly mcpServers: Record> }; expect(mcp.mcpServers['status']).toEqual({ args: ['--root', '${CURSOR_PLUGIN_ROOT}/tools/server.mjs'], command: 'node', @@ -474,7 +446,7 @@ it('rejects portable Agent Plugin tokens instead of emitting a hybrid Cursor art }; const plan = cursorAdapter.plan(candidate); expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain('cursor.mcp.token'); - expect(plan.entries.map((entry) => entry.relativePath)).not.toContain('mcp.json'); + expect(plan.entries.map((entry) => entry.relativePath)).not.toContain('.cursor-plugin/mcp.json'); const manifest = JSON.parse(writeContents(candidate)['.cursor-plugin/plugin.json']!) as Record; expect(manifest).not.toHaveProperty('mcpServers'); expect(manifest).not.toHaveProperty('variables'); @@ -498,7 +470,7 @@ it('rejects the plugin-data token and omits the failed server from the document' expect.objectContaining({ code: 'cursor.mcp.token', severity: 'error', target: 'cursor' }), ]); const documents = plan.entries.filter((entry) => entry.kind === 'write').map((entry) => entry.relativePath); - expect(documents).toEqual(['.cursor-plugin/plugin.json', 'INSTALL.md', 'install.mjs']); + expect(documents).toEqual(['.cursor-plugin/plugin.json']); const manifest = JSON.parse( (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, ) as Record; @@ -536,7 +508,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated const documents = Object.fromEntries(plan.entries .filter((entry): entry is Extract => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); - expect(JSON.parse(documents['hooks/hooks.json']!)).toEqual({ + expect(JSON.parse(documents['.cursor-plugin/hooks.json']!)).toEqual({ hooks: { postToolUse: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/record-write.mjs"', @@ -547,7 +519,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated }, version: 1, }); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json' }); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './.cursor-plugin/hooks.json' }); const wrappers = plan.hookEntries ?? []; expect(wrappers.map((entry) => entry.relativePath).sort()).toEqual([ @@ -614,7 +586,7 @@ it('lowers supported route-only families without exposing config hook names', () const documents = Object.fromEntries(plan.entries .filter((entry): entry is Extract => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); - expect(JSON.parse(documents['hooks/hooks.json']!)).toEqual({ + expect(JSON.parse(documents['.cursor-plugin/hooks.json']!)).toEqual({ hooks: { beforeSubmitPrompt: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-prompt-submit.mjs"', @@ -651,7 +623,7 @@ it('drops hooks scoped to other targets from the plan', () => { expect(plan.diagnostics).toEqual([]); expect(plan.hookEntries).toEqual([]); const paths = plan.entries.map((entry) => entry.relativePath); - expect(paths).not.toContain('hooks/hooks.json'); + expect(paths).not.toContain('.cursor-plugin/hooks.json'); expect(paths).toContain('.cursor-plugin/marketplace.json'); const manifest = JSON.parse( (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, @@ -661,9 +633,9 @@ it('drops hooks scoped to other targets from the plan', () => { it('reads the emitted shape-discriminated document back through the target MCP runtime', () => { const model = plugin(); - const document = JSON.parse(writeContents(model)['mcp.json']!) as unknown; + const document = JSON.parse(writeContents(model)['.cursor-plugin/mcp.json']!) as unknown; const runtime = cursorAdapter.mcpRuntime!; - expect(runtime.manifestPath).toBe('mcp.json'); + expect(runtime.manifestPath).toBe('.cursor-plugin/mcp.json'); const result = readTargetMcpServers(runtime, document); expect(result.status).toBe('found'); diff --git a/packages/agent-bundle/tests/dev-artifact-service.test.ts b/packages/agent-bundle/tests/dev-artifact-service.test.ts index 6fc19a6d1..1bd85e6b4 100644 --- a/packages/agent-bundle/tests/dev-artifact-service.test.ts +++ b/packages/agent-bundle/tests/dev-artifact-service.test.ts @@ -108,7 +108,7 @@ it('publishes one validated prepared project as an immutable epoch and removes i join(root, '.agent-bundle', 'epochs', 'epoch-one', 'agent-bundle.manifest.json'), ); expect(await store.readActiveEpoch()).toEqual(result.epoch); - await expect(readFile(join(root, '.agent-bundle', 'epochs', 'epoch-one', 'portable', 'plugin.json'), 'utf8')) + await expect(readFile(join(root, '.agent-bundle', 'epochs', 'epoch-one', 'plugin.json'), 'utf8')) .resolves.toContain('artifact-service-fixture'); await expect(readFile(join(root, '.agent-bundle', 'epochs', '.metadata', 'native-playground', 'epoch-one.json'), 'utf8')) .resolves.toBe('{"epochId":"epoch-one","selections":[]}\n'); @@ -212,14 +212,13 @@ it('allows only an exact epoch store marker as an extra staged artifact file', a const root = await mkdtemp(join(tmpdir(), 'agent-bundle-staged-artifact-validation-')); const marker = '.agent-bundle-epoch-stage.json'; try { - await mkdir(join(root, 'portable'), { recursive: true }); await writeFile( - join(root, 'portable', 'plugin.json'), + join(root, 'plugin.json'), '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"Valid staged plugin.","name":"valid","version":"1.0.0"}\n', ); await Promise.all([ - writeFile(join(root, 'portable', 'INSTALL.md'), '# Install valid\n'), - writeFile(join(root, 'portable', 'install.mjs'), 'export {};\n'), + writeFile(join(root, 'INSTALL.md'), '# Install valid\n'), + writeFile(join(root, 'install.mjs'), 'export {};\n'), ]); await writeFixtureManifest({ artifactRoot: root, targets: ['portable'] }); await writeFile(join(root, marker), '{"token":"8f2aa8b7-bdd2-4065-8cd3-5184c6bd9f74"}\n'); @@ -544,7 +543,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 c3bd0cb7d..4c14c4be8 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -69,24 +69,26 @@ const writeEpoch = async ( id: string, values: { readonly hook: string; readonly skill: string }, ): Promise => { + // The epoch root is the composite plugin root: Cursor's documents live in + // `.cursor-plugin/`, the shared component folders at the top level. const root = join(projectRoot, '.agent-bundle', 'epochs', id); - const target = join(root, 'cursor'); await Promise.all([ - mkdir(join(target, '.cursor-plugin'), { recursive: true }), - mkdir(join(target, 'hooks'), { recursive: true }), - mkdir(join(target, 'mcp'), { recursive: true }), - mkdir(join(target, 'skills', 'probe'), { recursive: true }), + mkdir(join(root, '.cursor-plugin'), { recursive: true }), + mkdir(join(root, 'hooks'), { recursive: true }), + mkdir(join(root, 'mcp'), { recursive: true }), + mkdir(join(root, 'skills', 'probe'), { recursive: true }), ]); await Promise.all([ writeFile(join(root, 'manifest.json'), '{}\n'), - writeFile(join(target, '.cursor-plugin', 'plugin.json'), '{"name":"dev-proof","version":"1.0.0"}\n'), - writeFile(join(target, 'hooks', 'hooks.json'), values.hook), - writeFile(join(target, 'mcp', 'probe-old.mjs'), 'export const old = true;\n'), + writeFile(join(root, '.cursor-plugin', 'plugin.json'), '{"name":"dev-proof","version":"1.0.0"}\n'), + writeFile(join(root, '.cursor-plugin', 'hooks.json'), values.hook), + writeFile(join(root, 'hooks', 'session-start.cursor.mjs'), values.hook), + writeFile(join(root, 'mcp', 'probe-old.mjs'), 'export const old = true;\n'), writeFile( - join(target, 'mcp.json'), + join(root, '.cursor-plugin', 'mcp.json'), '{"mcpServers":{"probe":{"args":["${CURSOR_PLUGIN_ROOT}/mcp/probe-old.mjs"],"command":"node","type":"stdio"}}}\n', ), - writeFile(join(target, 'skills', 'probe', 'SKILL.md'), values.skill), + writeFile(join(root, 'skills', 'probe', 'SKILL.md'), values.skill), ]); return root; }; @@ -187,7 +189,7 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire projectRoot, schemaVersion: 1, }); - const mcpBefore = await readFile(join(destination, 'mcp.json'), 'utf8'); + const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); expect(JSON.parse(mcpBefore)).toEqual({ mcpServers: { probe: await devProxyServerCommand(projectRoot, 'probe', 'cursor'), @@ -205,8 +207,8 @@ it('installs a marked Cursor dev variant and atomically re-points top-level dire expect(syncEvents.at(-1)).toMatchObject({ epochId: 'epoch-2', state: 'succeeded' }); expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toBe('second skill\n'); - expect(await readFile(join(destination, 'hooks', 'hooks.json'), 'utf8')).toBe('second hook\n'); - expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpBefore); + expect(await readFile(join(destination, 'hooks', 'session-start.cursor.mjs'), 'utf8')).toBe('second hook\n'); + expect(await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8')).toBe(mcpBefore); expect((await lstat(join(destination, 'skills'))).isSymbolicLink()).toBe(true); expect((await lstat(join(destination, 'hooks'))).isSymbolicLink()).toBe(true); @@ -331,7 +333,7 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f try { await coordinator.start(); await manager.settled(); - const mcpBefore = await readFile(join(destination, 'mcp.json'), 'utf8'); + const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); expect(mcpBefore).toContain(`"command":${JSON.stringify(process.execPath)}`); expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain( 'host-install proof fixture', @@ -359,7 +361,7 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f const hookModule = hookFiles.find((name) => name.endsWith('.mjs')); if (hookModule === undefined) throw new Error('Updated installed hooks contained no executable module.'); expect(await readFile(join(destination, 'hooks', hookModule), 'utf8')).toContain('updated hook'); - expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpBefore); + expect(await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8')).toBe(mcpBefore); const markerBeforeFailure = await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8'); await writeFile(join(projectRoot, 'src', 'hooks', 'session-start.ts'), 'export default () => ({;\n'); diff --git a/packages/agent-bundle/tests/dev-server.test.ts b/packages/agent-bundle/tests/dev-server.test.ts index f1ae3505c..b4a8053d4 100644 --- a/packages/agent-bundle/tests/dev-server.test.ts +++ b/packages/agent-bundle/tests/dev-server.test.ts @@ -1043,7 +1043,7 @@ it('accepts headerless browser same-origin fetch provenance with the exact token it('forwards structured artifact validation diagnostics through the foreground error boundary', async () => { const diagnostics = deepFreeze([{ code: 'AB4300', - generatedPath: 'claude/hooks/guard.mjs', + generatedPath: 'hooks/guard.mjs', message: 'Emitted hook wrapper is not executable.', severity: 'error' as const, }]); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 5f535749c..b452a6f7f 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { runCli } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; @@ -356,7 +357,7 @@ it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the // 2. The same pack installed by the emitted install.mjs: expanded, recorded, and verified — the Agent Plugins // contract is checked against the bundle's document, so the absolute paths and §9.1 keys in the copy are no error. const bundle = join(fixture.root, 'portable-bundle'); - const installerSource = createDefaultRegistry().get('portable').plan({ + const installerSource = composeProjections({ extensions: {}, hooks: [], mcpServers: [], @@ -370,8 +371,8 @@ it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the scripts: [], skills: [], targets: [{ id: 'target:portable', name: 'portable', provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' } }], - }).entries.find((entry): entry is TargetArtifactWrite => entry.kind === 'write' && entry.relativePath === 'install.mjs'); - if (installerSource === undefined) throw new Error('portable plan emitted no install.mjs'); + }, createDefaultRegistry()).entries.find((entry): entry is TargetArtifactWrite => entry.kind === 'write' && entry.relativePath === 'install.mjs'); + if (installerSource === undefined) throw new Error('portable composite root emitted no install.mjs'); await mkdir(join(bundle, 'mcp'), { recursive: true }); await writeFile(join(bundle, 'install.mjs'), installerSource.content); await writeFile(join(bundle, 'INSTALL.md'), '# Install expanded\n'); @@ -975,24 +976,25 @@ 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 (the pre-composite partition) is never probed. const artifactRoot = join(fixture.root, 'artifact'); - await mkdir(artifactRoot, { recursive: true }); - const bundle = join(artifactRoot, 'claude'); - await mkdir(bundle, { recursive: true }); - await writeFile(join(bundle, 'payload.txt'), 'payload\n'); - await writeJson(join(bundle, '.claude-plugin/plugin.json'), { + await mkdir(join(artifactRoot, 'claude'), { recursive: true }); + await writeJson(join(artifactRoot, 'claude', '.claude-plugin/plugin.json'), { name: 'nested-decoy', version: '0.0.0' }); + await writeFile(join(artifactRoot, 'payload.txt'), 'payload\n'); + await writeJson(join(artifactRoot, '.claude-plugin/plugin.json'), { author: { name: 'Doctor Fixture' }, description: 'Doctor fixture plugin.', name: 'doctor-fixture', version: '1.2.3', }); - await writeJson(join(bundle, '.claude-plugin/marketplace.json'), { + await writeJson(join(artifactRoot, '.claude-plugin/marketplace.json'), { name: 'doctor-fixture-marketplace', owner: { name: 'Doctor Fixture' }, plugins: [{ name: 'doctor-fixture', source: './' }], @@ -1012,10 +1014,10 @@ it('lists Claude plugins from the resolved host bundle root when --from names a }); expect(calls).toEqual(expect.arrayContaining([ - expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle }), + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: artifactRoot }), ])); 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..d68c846d6 100644 --- a/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts +++ b/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts @@ -38,8 +38,9 @@ type ArtifactClass = | 'mcp-framework-shell' | 'mcp-raw-stdio-server'; +// Compiled surfaces sit directly under the composite root: `/` (#555). const classify = (relativePath: string): ArtifactClass | undefined => { - const [, kind, file] = relativePath.split('/'); + const [kind, file] = relativePath.split('/'); if (kind === 'install.mjs' && file === undefined) return 'install-script'; if (file === undefined || !file.endsWith('.mjs')) return undefined; switch (kind) { diff --git a/packages/agent-bundle/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index 8f5e5638c..5b751f7cb 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@rstest/core'; -import { runtimeIgnoredRoot } from '../src/build/entries.ts'; +import type { NormalizedMcpServer } from '../src/core/types.ts'; +import { eventRuntimeHosting, runtimeIgnoredRoot, selectedServerHosts } from '../src/build/entries.ts'; describe('runtime ignored root', () => { it('anchors a source runtime to its package when the checkout is under dist', () => { @@ -23,3 +24,63 @@ describe('runtime ignored root', () => { .toBe('/var/cache/dist/project'); }); }); + +describe('selected server hosts', () => { + // The selected hosts whose MCP documents list the server — the hosts that + // can launch it. The entry bakes this set so a process one host can have + // spawned assumes that host for lineage when the client does not name itself. + const server = { targets: ['claude', 'cursor'] } as unknown as NormalizedMcpServer; + + it('keeps only the selected hosts the server targets, in selection order', () => { + expect(selectedServerHosts(server, ['claude', 'codex', 'cursor', 'portable'])).toEqual(['claude', 'cursor']); + expect(selectedServerHosts(server, ['cursor', 'claude'])).toEqual(['cursor', 'claude']); + }); + + it('yields no host when the server targets none of the selection', () => { + expect(selectedServerHosts(server, ['codex'])).toEqual([]); + }); +}); + +describe('event runtime hosting', () => { + // Each selected host reaches the composite root's shared event runtime + // through the first generated-route server its MCP document lists (#555); + // every hosting server accepts every host that reaches the runtime through + // a hosting server, because the endpoint is the artifact's alone (#592). + const generated = (id: string, targets: readonly string[]): NormalizedMcpServer => + ({ generatedRoutes: {}, id, source: `src/mcp/${id}.ts`, targets }) as unknown as NormalizedMcpServer; + const plain = (id: string, targets: readonly string[]): NormalizedMcpServer => + ({ id, source: `src/mcp/${id}.ts`, targets }) as unknown as NormalizedMcpServer; + + it('hosts the runtime in the first generated server every selected host lists', () => { + const hosting = eventRuntimeHosting([plain('bare', ['claude', 'codex']), generated('a', ['claude', 'codex'])], ['codex', 'claude']); + expect(hosting.allowedTargets).toEqual(['codex', 'claude']); + expect([...hosting.serverIds]).toEqual(['a']); + }); + + it('hosts the runtime in each server when the selected hosts list different generated servers', () => { + // Claude's document lists only `a`, Codex's only `b`: before the roots + // merged each per-host artifact hosted the runtime in its own server, and + // the composite root keeps both so neither host's wrappers go unanswered. + const hosting = eventRuntimeHosting([generated('a', ['claude']), generated('b', ['codex'])], ['claude', 'codex']); + expect(hosting.allowedTargets).toEqual(['claude', 'codex']); + expect([...hosting.serverIds].sort()).toEqual(['a', 'b']); + }); + + it('takes the first listed server for a host that lists several', () => { + const hosting = eventRuntimeHosting([generated('a', ['claude']), generated('b', ['claude', 'codex'])], ['claude', 'codex']); + expect(hosting.allowedTargets).toEqual(['claude', 'codex']); + expect([...hosting.serverIds].sort()).toEqual(['a', 'b']); + }); + + it('leaves a host that lists no generated server out of the allowed set', () => { + const hosting = eventRuntimeHosting([generated('a', ['claude'])], ['claude', 'cursor']); + expect(hosting.allowedTargets).toEqual(['claude']); + expect([...hosting.serverIds]).toEqual(['a']); + }); + + it('hosts nothing when no selected host lists a generated server', () => { + const hosting = eventRuntimeHosting([plain('bare', ['claude']), generated('a', ['codex'])], ['claude']); + expect(hosting.allowedTargets).toEqual([]); + expect(hosting.serverIds.size).toBe(0); + }); +}); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 52ffb27bf..8c351e2ff 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -368,7 +368,8 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { }, ], serverName: 'curator', - target: 'claude', + allowedTargets: ['claude', 'codex'], + hosts: ['claude'], workerFile: 'mcp-curator-flight.mjs', }); @@ -403,7 +404,16 @@ it('generates one final-only Flight MCP factory from filesystem routes', () => { // not re-templated here. expect(source).toContain('createEventRuntimeServer,'); expect(source).toContain('projectEventDocument,'); - expect(source).toContain('endpointId: `${EVENT_ARTIFACT_EPOCH}:${EVENT_TARGET}:'); + // The endpoint is the artifact's identity alone (epoch + root); the hosts + // that may deliver events ride separately as the allowed set (#592). + expect(source).toContain('endpointId: `${EVENT_ARTIFACT_EPOCH}:${dirname(dirname(resolve(process.argv[1])))}`'); + // The hosts whose wrappers the shared runtime accepts and the hosts that can + // have launched this entry are two sets: a Claude-only server in a + // Claude+Codex root hosts the runtime for both, yet only Claude spawns it. + expect(source).toContain('const EVENT_ALLOWED_TARGETS = Object.freeze(["claude","codex"]);'); + expect(source).toContain('const EVENT_HOSTS = Object.freeze(["claude"]);'); + expect(source).toContain(' hosts: EVENT_HOSTS,'); + expect(source).not.toContain('EVENT_TARGET '); expect(source).toContain('events,'); // Nothing else the shared runtime owns may be re-templated here. expect(source).not.toContain('server.register'); diff --git a/packages/agent-bundle/tests/epoch-store.test.ts b/packages/agent-bundle/tests/epoch-store.test.ts index 6fec10298..8476f7728 100644 --- a/packages/agent-bundle/tests/epoch-store.test.ts +++ b/packages/agent-bundle/tests/epoch-store.test.ts @@ -1069,7 +1069,7 @@ it('fails closed when active metadata points at a ghost epoch and leaves cleanup } }); -it('fails closed when active metadata differs from its epoch metadata or targets on disk', async () => { +it('fails closed when active metadata differs from its epoch metadata or the manifest leaves the disk', async () => { const root = await mkdtemp(join(tmpdir(), 'agent bundle inconsistent active epoch ')); try { @@ -1084,7 +1084,7 @@ it('fails closed when active metadata differs from its epoch metadata or targets await expect(store.readActiveEpoch()).rejects.toMatchObject({ code: 'EPOCH_METADATA_INVALID' }); await writeFile(activeMetadataPathFor(root), `${JSON.stringify({ epoch })}\n`); - await rm(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'codex'), { force: true, recursive: true }); + await rm(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'agent-bundle.manifest.json'), { force: true }); await expect(store.cleanup()).rejects.toMatchObject({ code: 'EPOCH_METADATA_INVALID' }); await expect(readFile(epochMetadataPathFor(root, 'epoch-1'), 'utf8')).resolves.toContain('epoch-1'); } finally { diff --git a/packages/agent-bundle/tests/eval-claude-harness.test.ts b/packages/agent-bundle/tests/eval-claude-harness.test.ts index ee059609a..06a987692 100644 --- a/packages/agent-bundle/tests/eval-claude-harness.test.ts +++ b/packages/agent-bundle/tests/eval-claude-harness.test.ts @@ -216,7 +216,7 @@ it('runs a signed-in trial with an explicit plugin directory, never --bare, and expect(execution?.args).toEqual([ '-p', '--plugin-dir', - join(context.artifact.root, 'claude'), + context.artifact.root, '--model', 'claude-sonnet-4-5', '--output-format', diff --git a/packages/agent-bundle/tests/eval-codex-harness.test.ts b/packages/agent-bundle/tests/eval-codex-harness.test.ts index d08412f42..bf3c27d40 100644 --- a/packages/agent-bundle/tests/eval-codex-harness.test.ts +++ b/packages/agent-bundle/tests/eval-codex-harness.test.ts @@ -55,7 +55,8 @@ const evalCase = (invocation: EvalCase['invocation'] = { mode: 'automatic' }): E const seedWorld = async (): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent bundle codex harness ')); const suiteDir = join(root, 'evals'); - const target = join(root, 'artifact', 'codex'); + // The composite artifact root is the Codex candidate; `codex` only selects the projection. + const target = join(root, 'artifact'); const normalCodexHome = join(root, 'normal-codex-home'); await mkdir(join(suiteDir, 'fixtures', 'repo'), { recursive: true }); await mkdir(join(target, '.agents', 'plugins'), { recursive: true }); diff --git a/packages/agent-bundle/tests/eval-codex-home.test.ts b/packages/agent-bundle/tests/eval-codex-home.test.ts index e9dc4c54c..52a4fd91e 100644 --- a/packages/agent-bundle/tests/eval-codex-home.test.ts +++ b/packages/agent-bundle/tests/eval-codex-home.test.ts @@ -39,9 +39,9 @@ const smokeCase = (): EvalCase => normalizeEvalCase({ const seedProject = async (): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent bundle codex home ')); await mkdir(join(root, 'evals', 'fixtures'), { recursive: true }); - await mkdir(join(root, 'artifact'), { recursive: true }); await cp(new URL('workspace/', fixtureRoot).pathname, join(root, 'evals', 'fixtures', 'workspace'), { recursive: true }); - await cp(new URL('candidate/', fixtureRoot).pathname, join(root, 'artifact', 'codex'), { recursive: true }); + // The composite artifact root is the Codex candidate itself. + await cp(new URL('candidate/', fixtureRoot).pathname, join(root, 'artifact'), { recursive: true }); return root; }; diff --git a/packages/agent-bundle/tests/eval-native-mount.test.ts b/packages/agent-bundle/tests/eval-native-mount.test.ts index 57b1a0318..7a2e9ddc2 100644 --- a/packages/agent-bundle/tests/eval-native-mount.test.ts +++ b/packages/agent-bundle/tests/eval-native-mount.test.ts @@ -251,15 +251,8 @@ it('runs a native Claude trial through the service with the pinned model and no const execution = world.requests[2]!; expect(execution.executable).toBe('claude'); expect(execution.args).not.toContain('--bare'); - const pluginDirectory = join( - project.root, - '.agent-bundle', - 'runs', - result.run.id, - 'artifacts', - 'target', - 'claude', - ); + // The mounted composite root is the Claude plugin directory itself. + const pluginDirectory = join(project.root, '.agent-bundle', 'runs', result.run.id, 'artifacts', 'target'); expect(execution.args.slice(0, 3)).toEqual(['-p', '--plugin-dir', pluginDirectory]); expect(execution.args).toContain('--model'); expect(execution.args[execution.args.indexOf('--model') + 1]).toBe(claudeModel); @@ -543,7 +536,7 @@ it('keeps native Claude plugin and fixture failures path-free after they are mou claudeRun: async (request) => { if (request.args[0] === '--version') return { exitCode: 0, stderr: '', stdout: '2.1.240 (Claude Code)\n' }; if (request.args[0] === 'auth') { - await rm(join(request.cwd, 'claude'), { force: true, recursive: true }); + await rm(join(request.cwd, '.claude-plugin'), { force: true, recursive: true }); return { exitCode: 0, stderr: '', diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index e10051fe5..c998cad53 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -25,7 +25,7 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { model: { metadata: { name: 'skills-starter' }, scripts: [], - targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], }, state: 'ready', }); @@ -37,13 +37,12 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { expect(projectVersionLabel(inspection.projectContext)).toContain('development fallback'); await build({ output, root }); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); - await expect(readFile(join(output, 'portable', 'skills', 'release-review', 'SKILL.md'), 'utf8')) + await expect(readFile(join(output, 'skills', 'release-review', 'SKILL.md'), 'utf8')) .resolves.toContain('# Release review'); - await expect(readFile(join(output, 'portable', 'skills', 'release-review', 'SKILL.md'), 'utf8')) + await expect(readFile(join(output, 'skills', 'release-review', 'SKILL.md'), 'utf8')) .resolves.toContain('## When to use'); await expect(readFile(join( output, - 'portable', 'skills', 'release-review', 'references', @@ -51,7 +50,6 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { ), 'utf8')).resolves.toContain('Confirm the release artifact'); await expect(readFile(join( output, - 'portable', 'skills', 'release-review', 'references', @@ -59,7 +57,6 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { ), 'utf8')).resolves.toContain('# Release readiness policy'); await expect(readFile(join( output, - 'portable', 'skills', 'release-review', 'assets', @@ -132,28 +129,28 @@ it('publishes the MCP App example service readiness across targets and returns d mcpApps: [{ name: 'status', targets: ['portable'] }], mcpServers: [{ name: 'status', targets: ['claude', 'codex', 'portable'] }], scripts: [{ name: 'check-service-fixture', targets: ['claude', 'codex', 'portable'] }], - skills: [{ name: 'service-readiness', targets: ['portable', 'codex', 'claude'] }], - targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], + skills: [{ name: 'service-readiness', targets: ['claude', 'codex', 'portable'] }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], }, state: 'ready', }); - for (const target of ['portable', 'codex', 'claude'] as const) { - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'SKILL.md'), 'utf8')) - .resolves.toContain('# Service readiness'); - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'references', 'status-policy.md'), 'utf8')) - .resolves.toContain('# Service status policy'); - await expect(readFile(join(output, target, 'skills', 'service-readiness', 'assets', 'readiness-report.md'), 'utf8')) - .resolves.toContain('# Service readiness report'); - await expect(readFile(join(output, target, 'scripts', 'check-service-fixture.mjs'), 'utf8')) - .resolves.toContain('Compiler fixture is healthy.'); - await expect(readFile(join(output, target, 'assets', 'evals', 'fixtures', 'status', 'result.json'), 'utf8')) - .resolves.toContain('"Compiler service is ready for release."'); - } + // The shared skill, script, and asset are emitted once into the composite + // root that all three selected hosts read (#555). + await expect(readFile(join(output, 'skills', 'service-readiness', 'SKILL.md'), 'utf8')) + .resolves.toContain('# Service readiness'); + await expect(readFile(join(output, 'skills', 'service-readiness', 'references', 'status-policy.md'), 'utf8')) + .resolves.toContain('# Service status policy'); + await expect(readFile(join(output, 'skills', 'service-readiness', 'assets', 'readiness-report.md'), 'utf8')) + .resolves.toContain('# Service readiness report'); + await expect(readFile(join(output, 'scripts', 'check-service-fixture.mjs'), 'utf8')) + .resolves.toContain('Compiler fixture is healthy.'); + await expect(readFile(join(output, 'assets', 'evals', 'fixtures', 'status', 'result.json'), 'utf8')) + .resolves.toContain('"Compiler service is ready for release."'); const fixtureCheck = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'check-service-fixture.mjs'), + join(output, 'scripts', 'check-service-fixture.mjs'), ], { cwd: unrelatedCwd }); expect(fixtureCheck.stdout).toBe('Compiler fixture is healthy.\n'); - const fixturePath = join(output, 'portable', 'assets', 'evals', 'fixtures', 'status', 'result.json'); + const fixturePath = join(output, 'assets', 'evals', 'fixtures', 'status', 'result.json'); const healthyFixture = await readFile(fixturePath, 'utf8'); await writeFile(fixturePath, JSON.stringify({ checks: [], @@ -163,7 +160,7 @@ it('publishes the MCP App example service readiness across targets and returns d })); try { const invalidFixtureCheck = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'check-service-fixture.mjs'), + join(output, 'scripts', 'check-service-fixture.mjs'), ], { cwd: unrelatedCwd }).then( () => { throw new Error('Expected an incomplete compiler fixture to fail.'); @@ -175,14 +172,15 @@ it('publishes the MCP App example service readiness across targets and returns d } finally { await writeFile(fixturePath, healthyFixture); } - const appHtml = await readFile(join(output, 'portable', 'mcp-apps', 'status.html'), 'utf8'); + const appHtml = await readFile(join(output, 'mcp-apps', 'status.html'), 'utf8'); expect(appHtml).toContain('aria-label="Service checks"'); expect(appHtml).toContain('mcp-app-example'); expect(appHtml).toContain('1.0.0'); expect(appHtml).not.toContain('mcp-app-status-panel'); expect(appHtml).not.toContain('agent-bundle/meta'); - expect(built.build.compiledMcpApps).toMatchObject([{ name: 'status', target: 'portable' }]); - expect(built.build.compiledMcpEntries.map(({ target }) => target).sort()).toEqual(['claude', 'codex', 'portable']); + // Compiled surfaces are attributed to the composite root's identity (#555). + expect(built.build.compiledMcpApps).toMatchObject([{ name: 'status', target: 'claude+codex+portable' }]); + expect(built.build.compiledMcpEntries.map(({ target }) => target)).toEqual(['claude+codex+portable']); await Promise.all(built.build.compiledMcpEntries.map(({ output: mcpOutput }) => expect(readFile(mcpOutput, 'utf8')).resolves.toContain('payments-api'), )); @@ -226,9 +224,9 @@ it('simulates the Hooks example and executes release checks', async () => { await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); const artifactCatalog = built.build.compiledEntries.map(({ name }) => name); expect(artifactCatalog).toEqual(expect.arrayContaining(['verify-release', 'detect-risk'])); - await expect(readFile(join(output, 'portable', 'assets', 'release', 'release-manifest.json'), 'utf8')) + await expect(readFile(join(output, 'assets', 'release', 'release-manifest.json'), 'utf8')) .resolves.toContain('"version": "2.4.0"'); - await expect(readFile(join(output, 'portable', 'assets', 'release', 'risk-register.json'), 'utf8')) + await expect(readFile(join(output, 'assets', 'release', 'risk-register.json'), 'utf8')) .resolves.toContain('"id": "REL-204"'); const hooks = await listHooks({ artifact: output, root }); expect(hooks).toHaveLength(2); @@ -248,11 +246,11 @@ it('simulates the Hooks example and executes release checks', async () => { }); expect(result).toMatchObject({ additionalContext: expect.stringContaining('release preparation') }); const verify = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'verify-release.mjs'), + join(output, 'scripts', 'verify-release.mjs'), ], { cwd: unrelatedCwd }); expect(verify.stdout).toContain('Release 2.4.0 is ready for packaging.'); const blocker = await execFile(process.execPath, [ - join(output, 'portable', 'scripts', 'detect-risk.mjs'), + join(output, 'scripts', 'detect-risk.mjs'), ], { cwd: unrelatedCwd }).then( () => { throw new Error('Expected detect-risk to block release packaging.'); @@ -306,7 +304,7 @@ it('serves the routed Audiobook Curator artifact through a real MCP client', { r await rm(join(root, 'src'), { force: true, recursive: true }); const server = compiled.model.mcpServers.find((candidate) => candidate.name === 'curator'); expect(server?.generatedRoutes).toHaveLength(18); - const entry = join(output, 'claude', server!.args![0]!); + const entry = join(output, server!.args![0]!); client = new Client({ name: 'audiobook-route-contract', version: '1.0.0' }); await client.connect(new StdioClientTransport({ args: [entry], command: process.execPath, stderr: 'pipe' })); diff --git a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts index fd8f15858..7a5c38b87 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts @@ -18,6 +18,6 @@ export default { mcpCommands: true, }, skills: ['src/skills/probe'], - // `plugin` is the unified bundle whose Cursor manifest names `hooks/hooks-cursor.json` (#438). - targets: ['claude', 'codex', 'cursor', 'plugin'], + // One composite root; the Cursor manifest names `.cursor-plugin/hooks.json` beside Claude's `hooks/hooks.json` (#438). + targets: ['claude', 'codex', 'cursor'], }; diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 2baf377e5..332ba938b 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -138,7 +138,7 @@ it('lists and calls a generated filesystem tool through final-only Flight', { re expect(generatedTypes).toContain('prompt:curator/curate'); const server = compiled.model.mcpServers[0]; expect(server).toMatchObject({ id: 'mcp:curator', name: 'curator' }); - const entry = join(output, 'portable', server!.args![0]!); + const entry = join(output, server!.args![0]!); const worker = entry.replace(/\.mjs$/u, '-flight.mjs'); const statelessSources = await Promise.all([entry, worker].map((path) => readFile(path, 'utf8'))); for (const source of statelessSources) { @@ -283,12 +283,12 @@ const connectGeneratedServer = async ( const compiled = await build({ output, root, targets: [target] }); const server = compiled.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); - const entry = join(output, target, server.args[0]); + const entry = join(output, server.args[0]); const connection = await connectGeneratedEntry(entry); return { client: connection.client, close: connection.close, - endpointId: `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(entry)))}`, + endpointId: `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(entry)))}`, }; }; @@ -379,7 +379,7 @@ it('augments a generated server from config and projects result _meta and text-o id: 'mcp:curator', }); expect(compiled.model.mcpApps?.map((app) => app.id)).toEqual(['mcp-app:curator:panel']); - const manifest = JSON.parse(await readFile(join(output, 'portable', 'mcp.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(output, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly curator: { readonly args: readonly string[]; readonly env: Readonly> } }; }; expect(manifest.mcpServers.curator.args[1]).toBe('--strict'); @@ -387,7 +387,7 @@ it('augments a generated server from config and projects result _meta and text-o const client = new Client({ name: 'generated-augment-test', version: '0.0.0' }); const transport = new StdioClientTransport({ - args: [join(output, 'portable', server!.args![0]!)], + args: [join(output, server!.args![0]!)], command: process.execPath, stderr: 'pipe', }); @@ -484,14 +484,14 @@ it('compiles appResourceUri() and imported-const references to the App route res description: 'Open the dashboard.', }); // The compiled App HTML came from the route-relative template. - const html = await readFile(join(output, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const html = await readFile(join(output, 'mcp-apps', 'dashboard.html'), 'utf8'); expect(html).toContain('route-relative-shell'); expect(html).toContain('Curator dashboard'); const server = compiled.model.mcpServers[0]!; const client = new Client({ name: 'generated-app-refs-test', version: '0.0.0' }); const transport = new StdioClientTransport({ - args: [join(output, 'portable', server.args![0]!)], + args: [join(output, server.args![0]!)], command: process.execPath, stderr: 'pipe', }); @@ -927,8 +927,8 @@ 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 endpointId = `${compiled.build.manifest.project.revision}:cursor:${dirname(dirname(resolve(entry)))}`; + const entry = join(output, server.args[0]); + const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(entry)))}`; const endpoint = eventRuntimeEndpoint(endpointId); const status = (): Promise => requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); @@ -1072,35 +1072,41 @@ it('renders one tool/after event route through two native thin clients', { retry expect(compiled.model.hooks.filter((hook) => hook.eventRoute !== undefined)).toHaveLength(2); expect(compiled.build.compiledHooks.filter((hook) => hook.id === 'hook:event-route:tool-after')).toHaveLength(2); - for (const target of ['claude', 'cursor'] as const) { - const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === target)!; - const hook = compiled.build.compiledHooks.find((entry) => entry.target === target && entry.event === 'afterTool')!; - await expect(readFile(mcp.output, 'utf8')).resolves.toContain('agent-bundle-event-'); - const client = new Client({ name: `generated-event-${target}`, version: '0.0.0' }); - const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); - await client.connect(transport); - try { - await expect(client.callTool({ arguments: {}, name: 'status' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ - content: [{ text: 'provider:tool', type: 'text' }], - structuredContent: { providerKind: 'tool', providersFrozen: true }, - }); - const exploded = await callGeneratedTool(client, 'explode'); - expectFailClosed(exploded, /throwing.*src[/\\]providers[/\\]throwing\.ts.*provider exploded/iu); + // One generated server serves the composite root; each host's thin client + // is its own suffixed wrapper in the shared hooks/ folder. + const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'claude+cursor')!; + await expect(readFile(mcp.output, 'utf8')).resolves.toContain('agent-bundle-event-'); + const client = new Client({ name: 'generated-event-composite', version: '0.0.0' }); + const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); + await client.connect(transport); + try { + await expect(client.callTool({ arguments: {}, name: 'status' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ + content: [{ text: 'provider:tool', type: 'text' }], + structuredContent: { providerKind: 'tool', providersFrozen: true }, + }); + const exploded = await callGeneratedTool(client, 'explode'); + expectFailClosed(exploded, /throwing.*src[/\\]providers[/\\]throwing\.ts.*provider exploded/iu); + + // The endpoint is the artifact's alone — epoch and root — however many + // projections the root carries (#592); the invoking host rides each request. + const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; + const expectedEndpoint = eventRuntimeEndpoint(endpointId); + await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); + const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); + const secondStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); + expect(firstStatus).toMatchObject({ + artifactEpoch: 'generated-events-fixture@1.0.0', + availability: 'available', + status: 'available', + }); + expect(secondStatus).toMatchObject({ + instanceId: firstStatus.status === 'available' ? firstStatus.instanceId : undefined, + status: 'available', + }); - const endpointId = `${compiled.build.manifest.project.revision}:${target}:${dirname(dirname(resolve(mcp.output)))}`; - const expectedEndpoint = eventRuntimeEndpoint(endpointId); - await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); - const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); - const secondStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); - expect(firstStatus).toMatchObject({ - artifactEpoch: 'generated-events-fixture@1.0.0', - availability: 'available', - status: 'available', - }); - expect(secondStatus).toMatchObject({ - instanceId: firstStatus.status === 'available' ? firstStatus.instanceId : undefined, - status: 'available', - }); + for (const target of ['claude', 'cursor'] as const) { + const hook = compiled.build.compiledHooks.find((entry) => entry.target === target && entry.event === 'afterTool')!; + expect(hook.output.endsWith(`.${target}.mjs`)).toBe(true); const native = target === 'cursor' ? { conversation_id: 'conversation-1', @@ -1131,24 +1137,22 @@ it('renders one tool/after event route through two native thin clients', { retry hookEventName: 'PostToolUse', }, }); - if (target === 'cursor') { - const workspaceOpen = compiled.build.compiledHooks.find((entry) => - entry.target === 'cursor' && entry.event === 'workspaceOpen'); - expect(workspaceOpen).toBeDefined(); - await expect(runHook(workspaceOpen!.output, { - cursor_version: '1.7.2', - hook_event_name: 'workspaceOpen', - user_email: null, - workspace_roots: [root, join(root, 'secondary')], - })).resolves.toBeUndefined(); - } - } finally { - await client.close(); } + const workspaceOpen = compiled.build.compiledHooks.find((entry) => + entry.target === 'cursor' && entry.event === 'workspaceOpen'); + expect(workspaceOpen).toBeDefined(); + await expect(runHook(workspaceOpen!.output, { + cursor_version: '1.7.2', + hook_event_name: 'workspaceOpen', + user_email: null, + workspace_roots: [root, join(root, 'secondary')], + })).resolves.toBeUndefined(); + } finally { + await client.close(); } }); -it('renders composite plugin events through each concrete host in one warm runtime', { retry: 2, timeout: 90_000 }, async () => { +it('renders composite root events through each selected host in one warm runtime', { retry: 2, timeout: 90_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-plugin-events-')); roots.push(root); await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); @@ -1166,7 +1170,7 @@ it('renders composite plugin events through each concrete host in one warm runti })), writeProjectFile(root, 'agent-bundle.config.ts', [ "import { defineConfig } from 'agent-bundle/config';", - "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture', version: '1.0.0' }, targets: ['plugin'] });", + "export default defineConfig({ plugin: { name: 'generated-plugin-events-fixture', version: '1.0.0' }, targets: ['claude', 'codex', 'cursor'] });", '', ].join('\n')), writeProjectFile(root, 'src/mcp/runtime/tools/status.tsx', [ @@ -1184,7 +1188,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 +1200,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 +1212,20 @@ it('renders composite plugin events through each concrete host in one warm runti ]); const output = join(root, 'artifact'); - const compiled = await build({ output, root, targets: ['plugin'] }); - const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'plugin')!; - const sharedAfter = compiled.build.compiledHooks.find((entry) => - entry.event === 'afterTool' && !entry.output.endsWith('.cursor.mjs'))!; - const cursorAfter = compiled.build.compiledHooks.find((entry) => - entry.event === 'afterTool' && entry.output.endsWith('.cursor.mjs'))!; - const sharedSession = compiled.build.compiledHooks.find((entry) => - entry.event === 'sessionStart' && !entry.output.endsWith('.cursor.mjs'))!; + const compiled = await build({ output, root, targets: ['cursor', 'codex', 'claude'] }); + // One generated server serves the whole composite root under its sorted identity. + const mcp = compiled.build.compiledMcpEntries.find((entry) => entry.target === 'claude+codex+cursor')!; + const hookFor = (event: string, host: string) => compiled.build.compiledHooks.find((entry) => + entry.event === event && entry.output.endsWith(`.${host}.mjs`))!; + const claudeAfter = hookFor('afterTool', 'claude'); + const codexAfter = hookFor('afterTool', 'codex'); + const cursorAfter = hookFor('afterTool', 'cursor'); + const codexSession = hookFor('sessionStart', 'codex'); const client = new Client({ name: 'generated-event-plugin', version: '0.0.0' }); const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); await client.connect(transport); try { - const endpointId = `${compiled.build.manifest.project.revision}:plugin:${dirname(dirname(resolve(mcp.output)))}`; + const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; await expect(requestEventRuntime({ artifactEpoch: compiled.build.manifest.project.revision, endpointId, @@ -1232,7 +1237,7 @@ it('renders composite plugin events through each concrete host in one warm runti timeoutMs: 10_000, })).rejects.toMatchObject({ code: 'runtime-failed' }); - const claude = await runHook(sharedAfter.output, { + const claude = await runHook(claudeAfter.output, { cwd: root, hook_event_name: 'PostToolUse', session_id: 'session-claude', @@ -1241,7 +1246,7 @@ it('renders composite plugin events through each concrete host in one warm runti tool_response: { ok: true }, tool_use_id: 'tool-claude', transcript_path: join(root, 'transcript.jsonl'), - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }); + }, { PLUGIN_ROOT: undefined }); const firstContext = (claude as { hookSpecificOutput: { additionalContext: string } }) .hookSpecificOutput.additionalContext; // The worker mounts the compiled route id as `operationId` and the @@ -1256,7 +1261,7 @@ it('renders composite plugin events through each concrete host in one warm runti }, }); - await expect(runHook(sharedAfter.output, { + await expect(runHook(codexAfter.output, { cwd: root, hook_event_name: 'PostToolUse', session_id: 'session-codex', @@ -1265,7 +1270,7 @@ it('renders composite plugin events through each concrete host in one warm runti tool_response: { ok: true }, tool_use_id: 'tool-codex', transcript_path: null, - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output })).resolves.toEqual({ + }, { PLUGIN_ROOT: output })).resolves.toEqual({ hookSpecificOutput: { additionalContext: `codex:event:tool/after|tool/after:2:${instanceId}`, hookEventName: 'PostToolUse', @@ -1281,17 +1286,17 @@ it('renders composite plugin events through each concrete host in one warm runti tool_name: 'Write', tool_output: '{"ok":true}', tool_use_id: 'tool-cursor', - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined })).resolves.toEqual({ + }, { PLUGIN_ROOT: undefined })).resolves.toEqual({ additional_context: `cursor:event:tool/after|tool/after:3:${instanceId}`, }); - await expect(runHook(sharedSession.output, { + await expect(runHook(codexSession.output, { cwd: root, hook_event_name: 'SessionStart', session_id: 'session-codex', source: 'startup', transcript_path: null, - }, { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output })).resolves.toEqual({ + }, { PLUGIN_ROOT: output })).resolves.toEqual({ hookSpecificOutput: { additionalContext: `codex:session/start:4:${instanceId}`, hookEventName: 'SessionStart', @@ -1478,7 +1483,7 @@ it('replays Claude and Codex subagent fixtures through standalone event-route wr } }); -it('dispatches composite plugin event routes through the invoking host contract', { timeout: 60_000 }, async () => { +it('dispatches shared event routes through the invoking host contract', { timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-subagent-events-')); roots.push(root); await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); @@ -1494,13 +1499,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 +1514,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 +1523,10 @@ it('dispatches composite plugin event routes through the invoking host contract' ]); const output = join(root, 'artifact'); - const compiled = await build({ output, root, targets: ['plugin'] }); - const start = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStart')!; - const stop = compiled.build.compiledHooks.find((hook) => hook.event === 'agentStop')!; + const compiled = await build({ output, root, targets: ['claude', 'codex'] }); + // Both hosts share hooks/, so each host's wrapper carries its suffix. + const hookFor = (event: string, host: string) => compiled.build.compiledHooks.find((hook) => + hook.event === event && hook.output.endsWith(`.${host}.mjs`))!; for (const target of ['claude', 'codex'] as const) { const input = JSON.parse(await readFile( @@ -1529,9 +1535,9 @@ it('dispatches composite plugin event routes through the invoking host contract' )) as Record; if (target === 'codex') input.transcript_path = null; const env = target === 'codex' - ? { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: output } - : { AGENT_BUNDLE_HOOK_HOST: undefined, PLUGIN_ROOT: undefined }; - await expect(runHook(start.output, input, env)).resolves.toEqual({ + ? { PLUGIN_ROOT: output } + : { PLUGIN_ROOT: undefined }; + await expect(runHook(hookFor('agentStart', target).output, input, env)).resolves.toEqual({ hookSpecificOutput: { additionalContext: `${target}:${String(input.agent_id)}`, hookEventName: 'SubagentStart', @@ -1543,8 +1549,7 @@ it('dispatches composite plugin event routes through the invoking host contract' new URL('./fixtures/events/claude-subagent-stop.json', import.meta.url), 'utf8', )) as Record; - await expect(runHook(stop.output, claudeStop, { - AGENT_BUNDLE_HOOK_HOST: undefined, + await expect(runHook(hookFor('agentStop', 'claude').output, claudeStop, { PLUGIN_ROOT: undefined, })).resolves.toEqual({ hookSpecificOutput: { @@ -1558,8 +1563,7 @@ it('dispatches composite plugin event routes through the invoking host contract' 'utf8', )) as Record; codexStop.transcript_path = null; - await expect(runHook(stop.output, codexStop, { - AGENT_BUNDLE_HOOK_HOST: undefined, + await expect(runHook(hookFor('agentStop', 'codex').output, codexStop, { PLUGIN_ROOT: output, })).rejects.toThrow(/not supported by the Codex SubagentStop output schema/u); }); diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index d12f11701..692a669fd 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -11,11 +11,11 @@ import type { TargetAdapter } from '../src/adapters/types.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; import { runNodeScript } from './support/run-node-script.ts'; -import { listArtifactFiles } from '../src/build/emit.ts'; import { normalizeProject } from '../src/config/normalize.ts'; -import { digest, sha256Hex } from '../src/core/digest.ts'; +import { sha256Hex } from '../src/core/digest.ts'; import type { CanonicalHookEvent, NormalizationTargetRegistry } from '../src/core/types.ts'; +import { projectionDigests } from '../src/dev/artifacts/projection-digest.ts'; import { EpochStore } from '../src/dev/epoch-store.ts'; import { HookPlaygroundService, @@ -170,12 +170,7 @@ const publishHookEpoch = async ( ); await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); - const targetDigests = Object.freeze(Object.fromEntries(await Promise.all( - ['claude', 'codex'].map(async (target) => [ - target, - digest(await listArtifactFiles(join(artifact, target))), - ]), - ))); + const targetDigests = await projectionDigests(artifact, ['claude', 'codex']); const store = epochStore; const staging = await store.createStagingEpoch({ epoch: epochFor(root, id, targetDigests), targets: ['codex', 'claude'] }); await Promise.all((await readdir(artifact)).map((entry) => cp(join(artifact, entry), join(staging.root, entry), { recursive: true }))); @@ -292,16 +287,14 @@ it('uses the injected adapter hook contract for custom manifests, mappings, matc }, })}\n`), writeFile(join(sourceArtifact, hook.path), 'export default undefined;\n'), + writeFile(join(sourceArtifact, 'agent-bundle.manifest.json'), '{}\n'), ]); const epochStore = new EpochStore({ projectRoot: root }); const staging = await epochStore.createStagingEpoch({ - epoch: epochFor(root, 'epoch-1', { synthetic: digest(await listArtifactFiles(sourceArtifact)) }), + epoch: epochFor(root, 'epoch-1', await projectionDigests(sourceArtifact, ['synthetic'])), targets: ['synthetic'], }); - await Promise.all([ - cp(sourceArtifact, join(staging.root, 'synthetic'), { recursive: true }), - writeFile(join(staging.root, 'agent-bundle.manifest.json'), '{}\n'), - ]); + await cp(sourceArtifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); const hookService = { @@ -417,7 +410,7 @@ it('runs fixture and inline canonical input through the epoch-bound wrapper and nativeProjection: 'deterministic', nativeSelector: 'PreToolUse', target: 'codex', - wrapperPath: `codex/hooks/${epochOne.hooks.beforeTool.name}.mjs`, + wrapperPath: `hooks/${epochOne.hooks.beforeTool.name}.codex.mjs`, }, nativeInput: { cwd: '/workspace', @@ -542,7 +535,7 @@ it('projects every emitted Codex and Claude event deterministically and exposes nativeProjection: 'deterministic', nativeSelector: trace.hostMapping.nativeEvent, target, - wrapperPath: `${target}/hooks/${epoch.hooks[event].name}.mjs`, + wrapperPath: `hooks/${epoch.hooks[event].name}.${target}.mjs`, }); await expect(runNativeHook( join(root, '.agent-bundle', 'epochs', 'epoch-1', trace.hostMapping.wrapperPath), @@ -583,7 +576,8 @@ it('isolates malicious relative writes from the referenced epoch and rejects coo await expect(readFile(manifestPath, 'utf8')).resolves.toBe(manifestBefore); await expect(access(join(epochRoot, 'simulation-only.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); - const wrapperPath = `codex/hooks/${epoch.hooks.beforeTool.name}.mjs`; + // Claude and Codex share the hooks/ folder, so the Codex wrapper is host-suffixed. + const wrapperPath = `hooks/${epoch.hooks.beforeTool.name}.codex.mjs`; const wrapper = join(epochRoot, wrapperPath); const tamperedWrapper = "process.stdout.write('');\n"; await writeFile(wrapper, tamperedWrapper); @@ -689,7 +683,7 @@ it('distinguishes an unsupported canonical event from an unsupported target', as event: 'futureEvent', id: 'hook:future', name: 'future', - path: 'codex/hooks/future.mjs', + path: 'hooks/future.codex.mjs', target: 'codex', }], simulate: async () => { diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 035e176eb..078efa9b9 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -8,6 +8,8 @@ import { pathToFileURL } from 'node:url'; import { expect, it, rs } from '@rstest/core'; import { rspack } from '@rslib/core'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; import { build } from './support/build.ts'; @@ -84,11 +86,11 @@ it('keeps the hook simulation cancellation constructor private to the executor', }); it('accepts only canonical frozen hook index metadata', () => { - const bytes = '{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"codex/hooks/start.mjs","target":"codex"}]}\n'; + const bytes = '{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"hooks/start.codex.mjs","target":"codex"}]}\n'; const index = parseArtifactHookIndex(bytes); expect(index).toEqual({ - hooks: [{ event: 'sessionStart', id: 'hook:start', name: 'start', path: 'codex/hooks/start.mjs', target: 'codex' }], + hooks: [{ event: 'sessionStart', id: 'hook:start', name: 'start', path: 'hooks/start.codex.mjs', target: 'codex' }], }); expect(index === undefined ? false : Object.isFrozen(index)).toBe(true); expect(index === undefined ? false : Object.isFrozen(index.hooks)).toBe(true); @@ -139,8 +141,8 @@ it('keeps the Claude and Codex native wrapper codecs byte-identical apart from i }; const stripCodecIdentifiers = (source: string): string => source - .replaceAll(/decode(?:Claude|Codex|Universal)Native/g, 'decodeNative') - .replaceAll(/encode(?:Claude|Codex|Universal)Native/g, 'encodeNative'); + .replaceAll(/decode(?:Claude|Codex)Native/g, 'decodeNative') + .replaceAll(/encode(?:Claude|Codex)Native/g, 'encodeNative'); const withoutTargetConstant = (source: string): string => source .split('\n') @@ -153,24 +155,9 @@ it('keeps the Claude and Codex native wrapper codecs byte-identical apart from i const codexSource = nativeHookWrapperSource(entry, 'Codex'); expect(normalize(claudeSource)).toBe(normalize(codexSource)); - - const universalSource = nativeHookWrapperSource(entry, 'Universal'); - - expect(universalSource).toContain('process.env.PLUGIN_ROOT'); - expect(universalSource).toContain('AGENT_BUNDLE_HOOK_HOST'); - - const hostDetectionLines = new Set([ - 'const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;', - 'const target = declaredHost === "claude" || declaredHost === "codex"', - ' ? declaredHost', - ' : process.env.PLUGIN_ROOT === undefined ? "claude" : "codex";', - ]); - const universalWithoutHostDetection = universalSource - .split('\n') - .filter((line) => !hostDetectionLines.has(line)) - .join('\n'); - - expect(stripCodecIdentifiers(universalWithoutHostDetection)).toBe(normalize(claudeSource)); + // Every wrapper bakes the host it was planned for; none sniffs it at runtime. + expect(claudeSource).toContain('const target = "claude";'); + expect(claudeSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); }); const runPublishedHook = async (wrapper: string, input: string) => runNodeScript({ args: [wrapper], input }); @@ -634,11 +621,14 @@ it('loads and deterministically merges target-native hook documents after genera const writes = Object.fromEntries(plan.entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content]] : [])); expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(writes['hooks/hooks.json']!)).toEqual({ + // Both hosts are selected, so the shared hook compiles one wrapper per + // host and Codex's document lives beside its manifest (#555). + const documentPath = target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'; + expect(JSON.parse(writes[documentPath]!)).toEqual({ description: target === 'codex' ? 'Codex escape hatch' : 'Claude escape hatch', hooks: { SessionStart: [ - { hooks: [{ command: `node "${generatedRoot}/hooks/session-start-session-start-7ab7e8a5.mjs"`, type: 'command' }] }, + { hooks: [{ command: `node "${generatedRoot}/hooks/session-start-session-start-7ab7e8a5.${target}.mjs"`, type: 'command' }] }, { hooks: [{ command: nativeCommand, type: 'command' }] }, ], UserPromptSubmit: [{ hooks: [{ command: `${nativeCommand} user-prompt`, type: 'command' }] }], @@ -710,7 +700,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a await cp(outputRoot, artifact, { recursive: true }); await rm(root, { force: true, recursive: true }); - await expect(importPublishedHook(join(artifact, 'codex', 'hooks', 'session-start-session-start-7ab7e8a5.mjs'))).resolves.toEqual({ + await expect(importPublishedHook(join(artifact, 'hooks', 'session-start-session-start-7ab7e8a5.codex.mjs'))).resolves.toEqual({ code: 0, stderr: '', stdout: '', @@ -728,7 +718,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a expect.objectContaining({ event: 'stop', target: 'codex' }), ]); expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.target === 'codex')).toMatchObject({ - path: 'codex/hooks/session-start-session-start-7ab7e8a5.mjs', + path: 'hooks/session-start-session-start-7ab7e8a5.codex.mjs', }); const epochMarker = join(artifact, '.agent-bundle-epoch-stage.json'); await writeFile(epochMarker, '{"token":"00000000-0000-4000-8000-000000000000"}\n'); @@ -773,7 +763,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a })).resolves.toBeUndefined(); } - await writeFile(join(artifact, 'codex', 'hooks', 'session-start-session-start-7ab7e8a5.mjs'), 'broken'); + await writeFile(join(artifact, 'hooks', 'session-start-session-start-7ab7e8a5.codex.mjs'), 'broken'); await expect(service.simulate({ artifact, hook: 'hook:session-start:session-start:7ab7e8a5', @@ -1028,13 +1018,15 @@ it('compiles each native hook through a virtual Rslib entry without sibling chun const hookIndex = await readFile(join(outputRoot, 'agent-bundle.hooks.json'), 'utf8'); expect(await readFile(join(repeatedOutputRoot, 'agent-bundle.hooks.json'), 'utf8')).toBe(hookIndex); + // One `hooks/` directory in the composite root: every shared hook compiles + // one wrapper per selected host, host-suffixed (#555). + const hooksRoot = join(outputRoot, 'hooks'); + expect((await readdir(hooksRoot)).filter((name) => name.endsWith('.mjs')).sort()).toEqual( + names.flatMap((name) => ['claude', 'codex'].map((target) => `${name}.${target}.mjs`)).sort(), + ); for (const target of ['codex', 'claude']) { - const hooksRoot = join(outputRoot, target, 'hooks'); - expect((await readdir(hooksRoot)).filter((name) => name.endsWith('.mjs')).sort()).toEqual( - names.map((name) => `${name}.mjs`), - ); for (const name of names) { - const wrapper = await readFile(join(hooksRoot, `${name}.mjs`), 'utf8'); + const wrapper = await readFile(join(hooksRoot, `${name}.${target}.mjs`), 'utf8'); expect(wrapper).toContain('compiled from local TypeScript'); expect(wrapper).not.toMatch(/from\s+['"](?:agent-bundle|@rstackjs\/|@rspack\/)[^'"]*['"]/); } @@ -1085,10 +1077,11 @@ it('applies the operator .env layer of the installed pack before a hook handler }; const context = (result: { readonly stdout: string }): string => (JSON.parse(result.stdout) as { hookSpecificOutput: { additionalContext: string } }).hookSpecificOutput.additionalContext; + const pluginRoot = outputRoot; for (const target of ['codex', 'claude']) { - const pluginRoot = join(outputRoot, target); - const wrapper = join(pluginRoot, 'hooks', 'session-start-session-start-7ab7e8a5.mjs'); + const wrapper = join(pluginRoot, 'hooks', `session-start-session-start-7ab7e8a5.${target}.mjs`); // No file: the wrapper is a no-op and the handler sees the host environment only. + await rm(join(pluginRoot, '.env'), { force: true }); const withoutFile = await runNativeHook(wrapper, event); expect(withoutFile).toMatchObject({ code: 0, stderr: '' }); expect(context(withoutFile)).toBe('unset=unset:unset'); @@ -1145,15 +1138,15 @@ it('runs the embedded Codex and Claude native codecs through their published wra await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); for (const target of ['codex', 'claude']) { - const hooksRoot = join(outputRoot, target, 'hooks'); - await expect(runNativeHook(join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), { + const hooksRoot = join(outputRoot, 'hooks'); + await expect(runNativeHook(join(hooksRoot, `session-start-session-start-7ab7e8a5.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '{"hookSpecificOutput":{"additionalContext":"session-1","hookEventName":"SessionStart"}}', }); - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { command: 'blocked' }, tool_name: 'Bash', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, @@ -1163,24 +1156,24 @@ it('runs the embedded Codex and Claude native codecs through their published wra // A continuing beforeTool handler writes no decision, so the host's own // permission prompt still applies (#461): nothing on stdout without a // rewrite, and a rewrite alone without permissionDecision. - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { command: 'ls' }, tool_name: 'Write', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { file_path: '/etc/passwd' }, tool_name: 'Edit', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"file_path":"/workspace/safe.ts"}}}', }); - await expect(runNativeHook(join(hooksRoot, 'after-tool-record-87785f02.mjs'), { + await expect(runNativeHook(join(hooksRoot, `after-tool-record-87785f02.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'PostToolUse', session_id: 'session-1', tool_input: {}, tool_response: { value: 'observed' }, tool_name: 'Write', tool_use_id: 'use-2', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '{"hookSpecificOutput":{"additionalContext":"[object Object]","hookEventName":"PostToolUse"}}', }); - await expect(runNativeHook(join(hooksRoot, 'stop-stop-bb2d7935.mjs'), { + await expect(runNativeHook(join(hooksRoot, `stop-stop-bb2d7935.${target}.mjs`), { cwd: '/workspace', hook_event_name: 'Stop', last_assistant_message: 'done', session_id: 'session-1', stop_hook_active: false, transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); } @@ -1188,7 +1181,7 @@ it('runs the embedded Codex and Claude native codecs through their published wra // The pinned rust-v0.147.0 post-tool-use input schema types tool_response // (and tool_input) as any JSON value, so scalar payloads reach the handler. for (const toolResponse of ['observed', 42, false, null]) { - await expect(runNativeHook(join(outputRoot, 'codex', 'hooks', 'after-tool-record-87785f02.mjs'), { + await expect(runNativeHook(join(outputRoot, 'hooks', 'after-tool-record-87785f02.codex.mjs'), { cwd: '/workspace', hook_event_name: 'PostToolUse', model: 'gpt-5-codex', permission_mode: 'default', session_id: 'session-1', tool_input: 'raw', tool_name: 'Write', tool_response: toolResponse, tool_use_id: 'use-2', transcript_path: null, turn_id: 'turn-1', })).resolves.toEqual({ code: 0, @@ -1312,7 +1305,8 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); for (const target of ['codex', 'claude'] as const) { - const manifest = JSON.parse(await readFile(join(outputRoot, target, 'hooks', 'hooks.json'), 'utf8')) as { + const documentPath = target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'; + const manifest = JSON.parse(await readFile(join(outputRoot, documentPath), 'utf8')) as { readonly hooks: Readonly>; }; expect(manifest.hooks.SubagentStart).toHaveLength(1); @@ -1327,7 +1321,7 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as 'utf8', )) as Record; const expectedTurn = target === 'codex' ? 'turn-codex-1' : 'undefined'; - await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', `subagent-start.${target}.mjs`), startInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1337,7 +1331,7 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as }, }), }); - await expect(runNativeHook(join(outputRoot, target, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', `subagent-stop.${target}.mjs`), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1403,7 +1397,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const document = JSON.parse(await readFile(join(outputRoot, 'cursor', 'hooks', 'hooks.json'), 'utf8')) as { + const document = JSON.parse(await readFile(join(outputRoot, cursorArtifactPaths.hooks), 'utf8')) as { readonly hooks: Readonly>; readonly version: number; }; @@ -1420,7 +1414,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso 'utf8', )) as Record; // https://cursor.com/docs/hooks#subagentstart: { permission, user_message }. - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1431,7 +1425,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso }), }); // https://cursor.com/docs/hooks#subagentstop: { followup_message }. - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ @@ -1439,17 +1433,17 @@ it('round-trips the documented Cursor subagent envelopes through published Curso }), }); // `/.env` is read before the handler module evaluates. - await writeFile(join(outputRoot, 'cursor', '.env'), 'CURSOR_OPERATOR=from-file\n'); - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + await writeFile(join(outputRoot, '.env'), 'CURSOR_OPERATOR=from-file\n'); + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ code: 0, stderr: '', stdout: JSON.stringify({ followup_message: `${String(stopInput.agent_transcript_path)}:false:${String(stopInput.summary)}:${String(stopInput.subagent_type)}:from-file`, }), }); - await rm(join(outputRoot, 'cursor', '.env')); + await rm(join(outputRoot, '.env')); // The Claude/Codex agent_id/agent_type spelling is not the Cursor envelope. - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), { + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), { agent_id: 'abc-123', agent_type: 'explore', conversation_id: 'conv-456', @@ -1463,12 +1457,12 @@ it('round-trips the documented Cursor subagent envelopes through published Curso // subagent envelope also carries parent_conversation_id; the parent id is // not a substitute for the session identifier. const { conversation_id: _conversationId, ...startWithoutConversation } = startInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutConversation)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), startWithoutConversation)).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: native session_id or conversation_id must be a string\n', stdout: '', }); - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), { ...stopInput, status: 'cancelled', })).resolves.toEqual({ @@ -1479,7 +1473,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso // followup_message is consumed only when status is "completed"; a denial // on an errored or aborted subagent fails instead of emitting ignored output. for (const status of ['error', 'aborted']) { - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { ...stopInput, status })).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), { ...stopInput, status })).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: Cursor subagentStop consumes followup_message only when status is "completed"; this subagent reported "${status}"\n`, stdout: '', @@ -1488,7 +1482,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso // Every documented field except git_branch is mandatory: a malformed // envelope must fail closed before the handler runs with undefined fields. const { git_branch: _gitBranch, ...startWithoutGitBranch } = startInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutGitBranch)) + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), startWithoutGitBranch)) .resolves.toMatchObject({ code: 0, stderr: '' }); for (const [field, message] of [ ['tool_call_id', 'native tool_call_id must be a string'], @@ -1497,7 +1491,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ['is_parallel_worker', 'native is_parallel_worker must be a boolean'], ] as const) { const { [field]: _omitted, ...missing } = startInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), missing)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-start.mjs'), missing)).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: ${message}\n`, stdout: '', @@ -1510,7 +1504,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ['agent_transcript_path', 'native agent_transcript_path must be a string or null'], ] as const) { const { [field]: _omitted, ...missing } = stopInput; - await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), missing)).resolves.toEqual({ + await expect(runNativeHook(join(outputRoot, 'hooks', 'subagent-stop.mjs'), missing)).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: ${message}\n`, stdout: '', @@ -1538,13 +1532,13 @@ it('rejects malformed event-specific native input before calling generated Codex await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); for (const target of ['codex', 'claude']) { - const hooksRoot = join(outputRoot, target, 'hooks'); - await expect(runNativeHook(join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), {})).resolves.toEqual({ + const hooksRoot = join(outputRoot, 'hooks'); + await expect(runNativeHook(join(hooksRoot, `session-start-session-start-7ab7e8a5.${target}.mjs`), {})).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: native session_id must be a string\n', stdout: '', }); - await expect(runNativeHook(join(hooksRoot, 'session-start-session-start-7ab7e8a5.mjs'), { + await expect(runNativeHook(join(hooksRoot, `session-start-session-start-7ab7e8a5.${target}.mjs`), { ...common, hook_event_name: 'SessionStart', })).resolves.toEqual({ code: 1, @@ -1555,7 +1549,7 @@ it('rejects malformed event-specific native input before calling generated Codex // Claude documents both as objects. const toolInputError = target === 'codex' ? 'tool_input is required' : 'tool_input must be an object'; const toolResponseError = 'tool_response is required'; - await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + await expect(runNativeHook(join(hooksRoot, `before-tool-check-command-1f5b5818.${target}.mjs`), { ...common, hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'use-1', ...(target === 'codex' ? {} : { tool_input: [] }), })).resolves.toEqual({ @@ -1563,14 +1557,14 @@ it('rejects malformed event-specific native input before calling generated Codex stderr: `Agent Bundle hook error: native PreToolUse ${toolInputError}\n`, stdout: '', }); - await expect(runNativeHook(join(hooksRoot, 'after-tool-record-87785f02.mjs'), { + await expect(runNativeHook(join(hooksRoot, `after-tool-record-87785f02.${target}.mjs`), { ...common, hook_event_name: 'PostToolUse', tool_input: {}, tool_name: 'Write', tool_use_id: 'use-2', })).resolves.toEqual({ code: 1, stderr: `Agent Bundle hook error: native PostToolUse ${toolResponseError}\n`, stdout: '', }); - await expect(runNativeHook(join(hooksRoot, 'stop-stop-bb2d7935.mjs'), { + await expect(runNativeHook(join(hooksRoot, `stop-stop-bb2d7935.${target}.mjs`), { ...common, hook_event_name: 'Stop', last_assistant_message: 'done', stop_hook_active: 'false', })).resolves.toEqual({ code: 1, @@ -1614,7 +1608,7 @@ it('rejects canonical reason combinations whose selected native hook cannot repr writeFile(join(sourceRoot, 'stop-deny.ts'), "export default () => ({ outcome: 'deny' as const, reason: '' });\n"), ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const hooksRoot = join(outputRoot, 'codex', 'hooks'); + const hooksRoot = join(outputRoot, 'hooks'); const assertions: readonly [string, Record, string][] = [ ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], ['before-allow-reason-00000002.mjs', { ...common, hook_event_name: 'PreToolUse', tool_input: {}, tool_name: 'Bash', tool_use_id: 'use-1' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], @@ -1663,17 +1657,17 @@ it('rejects malformed native hook input, exports, and handler results concisely' ]); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'valid-00000001.mjs'), '{not json')).resolves.toEqual({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'valid-00000001.mjs'), '{not json')).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: stdin must contain exactly one JSON value\n', stdout: '', }); - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'export-00000002.mjs'), '{}')).resolves.toEqual({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'export-00000002.mjs'), '{}')).resolves.toEqual({ code: 1, stderr: 'Agent Bundle hook error: default export must be a function\n', stdout: '', }); - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'result-00000003.mjs'), JSON.stringify({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'result-00000003.mjs'), JSON.stringify({ cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', }))).resolves.toEqual({ code: 1, @@ -1683,7 +1677,7 @@ it('rejects malformed native hook input, exports, and handler results concisely' // #492: a handler that throws is the same wire outcome as a malformed one — // the message on stderr, nothing on stdout, exit 1 (a non-blocking error on // every supported host, so the pending action proceeds). - await expect(runPublishedHook(join(outputRoot, 'codex', 'hooks', 'throws-00000004.mjs'), JSON.stringify({ + await expect(runPublishedHook(join(outputRoot, 'hooks', 'throws-00000004.mjs'), JSON.stringify({ cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', }))).resolves.toEqual({ code: 1, @@ -1764,23 +1758,23 @@ it('plans deterministic Codex and Claude hook configurations from the same model Object.fromEntries(entries.flatMap((entry) => entry.kind === 'write' ? [[entry.relativePath, entry.content]] : [])); expect(JSON.parse(writes(codex.entries)['.codex-plugin/plugin.json']!)).toMatchObject({ - hooks: './hooks/hooks.json', + hooks: `./${codexArtifactPaths.hooksManifest}`, }); // Claude Code loads hooks/hooks.json by convention and flags a manifest // pointer at that same file as a duplicate, so only Codex names it. expect(JSON.parse(writes(claude.entries)['.claude-plugin/plugin.json']!)).not.toHaveProperty('hooks'); - expect(JSON.parse(writes(codex.entries)['hooks/hooks.json']!)).toEqual({ + expect(JSON.parse(writes(codex.entries)[codexArtifactPaths.hooksManifest]!)).toEqual({ hooks: { PostToolUse: [{ - hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/after-tool-record-87785f02.mjs"', type: 'command' }], + hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/after-tool-record-87785f02.codex.mjs"', type: 'command' }], matcher: '^(?:apply_patch|Edit|Write)$', }], PreToolUse: [{ - hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/before-tool-check-command-1f5b5818.mjs"', timeout: 7, type: 'command' }], + hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/before-tool-check-command-1f5b5818.codex.mjs"', timeout: 7, type: 'command' }], matcher: '^Bash$', }], - SessionStart: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.mjs"', type: 'command' }] }], - Stop: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/stop-stop-bb2d7935.mjs"', type: 'command' }] }], + SessionStart: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/session-start-session-start-7ab7e8a5.codex.mjs"', type: 'command' }] }], + Stop: [{ hooks: [{ command: 'node "${PLUGIN_ROOT}/hooks/stop-stop-bb2d7935.codex.mjs"', type: 'command' }] }], }, }); expect(JSON.parse(writes(claude.entries)['hooks/hooks.json']!)).toMatchObject({ @@ -1790,10 +1784,10 @@ it('plans deterministic Codex and Claude hook configurations from the same model }, }); expect(Reflect.get(codex, 'hookEntries')).toMatchObject([ - { relativePath: 'hooks/session-start-session-start-7ab7e8a5.mjs' }, - { relativePath: 'hooks/before-tool-check-command-1f5b5818.mjs' }, - { relativePath: 'hooks/after-tool-record-87785f02.mjs' }, - { relativePath: 'hooks/stop-stop-bb2d7935.mjs' }, + { relativePath: 'hooks/session-start-session-start-7ab7e8a5.codex.mjs' }, + { relativePath: 'hooks/before-tool-check-command-1f5b5818.codex.mjs' }, + { relativePath: 'hooks/after-tool-record-87785f02.codex.mjs' }, + { relativePath: 'hooks/stop-stop-bb2d7935.codex.mjs' }, ]); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 1e8ab93a1..6edfe5213 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -6,9 +6,11 @@ import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { expect, it } from '@rstest/core'; -import { cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; +import { cursorArtifactPaths, cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; import { isValidClaudeDependencyRange } from '../src/adapters/claude.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import type { TargetArtifactEntry } from '../src/adapters/types.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { build } from './support/build.ts'; import { pathTokens, pluginRootEnvAnchor, type NormalizedPlugin } from '../src/core/types.ts'; @@ -77,6 +79,16 @@ const plugin = Object.freeze({ const planEntries = (model: NormalizedPlugin, target: 'codex' | 'claude') => createDefaultRegistry().get(target).plan(model).entries; +/** Where each host's projection writes its MCP document inside the composite root. */ +const mcpPath = (target: 'codex' | 'claude') => (target === 'codex' ? codexArtifactPaths.mcp : '.mcp.json'); +const hooksPath = (target: 'codex' | 'claude') => (target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'); + +/** The servers a projection's MCP document declares; `{}` when it writes none or only an empty shield document. */ +const emittedMcpServers = (entries: readonly TargetArtifactEntry[], target: 'codex' | 'claude'): Record => { + const entry = entries.find((candidate) => candidate.relativePath === mcpPath(target)); + return entry?.kind === 'write' ? (JSON.parse(entry.content) as { mcpServers: Record }).mcpServers : {}; +}; + const writeEntries = (model: NormalizedPlugin, target: 'codex' | 'claude') => { const entries = planEntries(model, target); return entries.filter((entry): entry is Extract => entry.kind === 'write'); @@ -306,9 +318,9 @@ const validateDocuments = async ( installFormats(validator); const paths = target === 'codex' ? { - marketplace: '.agents/plugins/marketplace.json', - mcp: '.mcp.json', - plugin: '.codex-plugin/plugin.json', + marketplace: codexArtifactPaths.marketplace, + mcp: codexArtifactPaths.mcp, + plugin: codexArtifactPaths.plugin, } : { marketplace: '.claude-plugin/marketplace.json', @@ -1196,19 +1208,21 @@ it('admits documented Codex component path and inline manifest forms', async () it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(plugin)).toBe(true); + // Both projections share one composite root (#555): Codex keeps its hook + // and MCP documents beside its manifest, and shields Claude's conventional + // `hooks/hooks.json` with an empty document of its own. The install surface + // is composed once over the selection, not planned per host. const codex = planEntries(plugin, 'codex'); const claude = planEntries(plugin, 'claude'); - const codexPluginEntries = codex.filter((entry) => entry.relativePath !== 'INSTALL.md'); - const claudePluginEntries = claude.filter((entry) => entry.relativePath !== 'INSTALL.md'); expect(codex.map((entry) => entry.relativePath)).toEqual([ - '.agents/plugins/marketplace.json', - '.codex-plugin/plugin.json', - '.mcp.json', - 'INSTALL.md', + codexArtifactPaths.marketplace, + codexArtifactPaths.hooksManifest, + codexArtifactPaths.mcp, + codexArtifactPaths.plugin, 'skills/review/SKILL.md', 'skills/review/assets/icon.bin', 'skills/review/references/guide.md', @@ -1217,32 +1231,36 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen '.claude-plugin/marketplace.json', '.claude-plugin/plugin.json', '.mcp.json', - 'INSTALL.md', 'skills/review/SKILL.md', 'skills/review/assets/icon.bin', 'skills/review/references/guide.md', ]); - expect(codexPluginEntries).toMatchObject([ + expect(codex).toMatchObject([ { content: '{"interface":{"displayName":"review-tools"},"name":"review-tools-marketplace","plugins":[{"category":"Productivity","name":"review-tools","policy":{"authentication":"ON_INSTALL","installation":"AVAILABLE"},"source":{"path":"./","source":"local"}}]}\n', kind: 'write', - relativePath: '.agents/plugins/marketplace.json', + relativePath: codexArtifactPaths.marketplace, }, { - content: '{"author":{"name":"review-tools"},"description":"Review code and explain findings.","interface":{"capabilities":["mcp","skills"],"category":"Productivity","defaultPrompt":["Help me use review-tools."],"developerName":"review-tools","displayName":"review-tools","longDescription":"Review code and explain findings.","shortDescription":"Review code and explain findings."},"mcpServers":"./.mcp.json","name":"review-tools","skills":"./skills/","version":"1.2.3"}\n', + content: '{"hooks":{}}\n', kind: 'write', - relativePath: '.codex-plugin/plugin.json', + relativePath: codexArtifactPaths.hooksManifest, }, { content: '{"mcpServers":{"http":{"headers":{"Authorization":"Bearer literal"},"type":"streamable-http","url":"https://mcp.example.test/stream"},"stdio":{"args":["--root","./tools/server.mjs"],"command":"node","cwd":"./","env":{"AGENT_BUNDLE_PLUGIN_ROOT":"./","CACHE_DIR":"cache"},"type":"stdio"}}}\n', kind: 'write', - relativePath: '.mcp.json', + relativePath: codexArtifactPaths.mcp, + }, + { + content: '{"author":{"name":"review-tools"},"description":"Review code and explain findings.","hooks":"./.codex-plugin/hooks.json","interface":{"capabilities":["mcp","hooks","skills"],"category":"Productivity","defaultPrompt":["Help me use review-tools."],"developerName":"review-tools","displayName":"review-tools","longDescription":"Review code and explain findings.","shortDescription":"Review code and explain findings."},"mcpServers":"./.codex-plugin/mcp.json","name":"review-tools","skills":"./skills/","version":"1.2.3"}\n', + kind: 'write', + relativePath: codexArtifactPaths.plugin, }, { bytes: 9, kind: 'copy', relativePath: 'skills/review/SKILL.md', source: '/workspace/src/skills/review/SKILL.md' }, { bytes: 3, kind: 'copy', relativePath: 'skills/review/assets/icon.bin', source: '/workspace/src/skills/review/assets/icon.bin' }, { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }, ]); - expect(claudePluginEntries).toMatchObject([ + expect(claude).toMatchObject([ { content: '{"description":"Review code and explain findings.","name":"review-tools-marketplace","owner":{"name":"review-tools"},"plugins":[{"description":"Review code and explain findings.","name":"review-tools","source":"./","version":"1.2.3"}]}\n', kind: 'write', @@ -1262,15 +1280,16 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen { bytes: 3, kind: 'copy', relativePath: 'skills/review/assets/icon.bin', source: '/workspace/src/skills/review/assets/icon.bin' }, { bytes: 8, kind: 'copy', relativePath: 'skills/review/references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }, ]); - expect(codexPluginEntries.map((entry) => entry.sourceInputs)).toEqual([ + expect(codex.map((entry) => entry.sourceInputs)).toEqual([ + ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts'], - ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], ['/workspace/agent-bundle.config.ts'], + ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], ['/workspace/src/skills/review/SKILL.md'], ['/workspace/src/skills/review/SKILL.md', '/workspace/src/skills/review/assets/icon.bin'], ['/workspace/src/skills/review/SKILL.md', '/workspace/src/skills/review/references/guide.md'], ]); - expect(claudePluginEntries.map((entry) => entry.sourceInputs)).toEqual([ + expect(claude.map((entry) => entry.sourceInputs)).toEqual([ ['/workspace/agent-bundle.config.ts'], ['/workspace/agent-bundle.config.ts', '/workspace/src/skills/review/SKILL.md'], ['/workspace/agent-bundle.config.ts'], @@ -1332,7 +1351,7 @@ it('diagnoses a plain Cursor workspaceOpen hook instead of lowering a session-sc code: 'cursor.hook.event.workspace-open', message: expect.stringContaining('cannot map canonical hook event "workspaceOpen"'), })); - expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); + expect(plan.entries.some((entry) => entry.relativePath === cursorArtifactPaths.hooks)).toBe(false); }); it('plans a Cursor workspace/open event route without enabling the plain hook vocabulary', () => { @@ -1358,7 +1377,7 @@ it('plans a Cursor workspace/open event route without enabling the plain hook vo }], }; const plan = createDefaultRegistry().get('cursor').plan(model); - const hooks = plan.entries.find((entry) => entry.relativePath === 'hooks/hooks.json'); + const hooks = plan.entries.find((entry) => entry.relativePath === cursorArtifactPaths.hooks); expect(plan.diagnostics).toEqual([]); expect(hooks?.kind).toBe('write'); @@ -2535,7 +2554,7 @@ it.each(['codex', 'claude'] as const)( env: Object.freeze({ [pluginRootEnvAnchor]: 'declared-root' }), })]), } satisfies NormalizedPlugin; - const entry = planEntries(overridden, target).find((candidate) => candidate.relativePath === '.mcp.json'); + const entry = planEntries(overridden, target).find((candidate) => candidate.relativePath === mcpPath(target)); const document = JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { mcpServers: Record }>; }; @@ -2558,7 +2577,7 @@ it('omits the Codex env anchor when a stdio server has no plugin-root cwd to res })]), } satisfies NormalizedPlugin; const read = (target: 'codex' | 'claude') => { - const entry = planEntries(unanchored, target).find((candidate) => candidate.relativePath === '.mcp.json'); + const entry = planEntries(unanchored, target).find((candidate) => candidate.relativePath === mcpPath(target)); return (JSON.parse(entry?.kind === 'write' ? entry.content : '{}') as { mcpServers: Record }>; }).mcpServers.external?.env; @@ -2590,7 +2609,7 @@ it.each(['codex', 'claude'] as const)( severity: 'error', sourcePath: '/workspace/agent-bundle.config.ts', }]); - expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(plan.entries, target)).toEqual({}); }, ); @@ -2619,7 +2638,7 @@ it.each(['codex', 'claude'] as const)( const adapter = createDefaultRegistry().get(target); const planned = alternatingServer(); const plan = adapter.plan({ ...plugin, mcpServers: [planned.server] }); - const mcp = plan.entries.find((entry) => entry.kind === 'write' && entry.relativePath === '.mcp.json'); + const mcp = plan.entries.find((entry) => entry.kind === 'write' && entry.relativePath === mcpPath(target)); const validated = alternatingServer(); expect(plan.diagnostics).toEqual([]); @@ -2663,8 +2682,11 @@ it('keeps Codex plugin and marketplace interface validator contracts separate', readonly interface: Record; }; + // The root is shared with Claude, whose conventional `hooks/hooks.json` + // Codex would otherwise discover, so Codex points at an empty hooks + // document of its own and declares the capability (#555). expect(pluginManifest.interface).toMatchObject({ - capabilities: ['mcp', 'skills'], + capabilities: ['mcp', 'hooks', 'skills'], defaultPrompt: ['Help me use review-tools.'], developerName: 'review-tools', }); @@ -2835,11 +2857,11 @@ it('records every selected component provenance for generated host documents', ( '/inputs/src/skills/review/SKILL.md', ]; expect(byPath[target === 'codex' ? '.codex-plugin/plugin.json' : '.claude-plugin/plugin.json']?.sourceInputs).toEqual(common); - expect(byPath['.mcp.json']?.sourceInputs).toEqual([ + expect(byPath[mcpPath(target)]?.sourceInputs).toEqual([ `/inputs/${target}.target.ts`, '/inputs/mcp.config.ts', ]); - expect(byPath['hooks/hooks.json']?.sourceInputs).toEqual([ + expect(byPath[hooksPath(target)]?.sourceInputs).toEqual([ `/inputs/${target}.target.ts`, '/inputs/hook.config.ts', ]); @@ -2945,7 +2967,7 @@ it('requires an explicit plugin-root cwd before Codex can map leading root token 'codex.mcp.token.plugin-root.cwd.required.args[0]', 'codex.mcp.token.plugin-root.cwd.required.env.TOOL', ]); - expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(plan.entries, 'codex')).toEqual({}); }); it('rejects Codex plugin-root paths that escape the explicit relative cwd', () => { @@ -2965,7 +2987,7 @@ it('rejects Codex plugin-root paths that escape the explicit relative cwd', () = expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ 'codex.mcp.token.plugin-root.escape.command', ]); - expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(plan.entries, 'codex')).toEqual({}); }); it('rejects Claude path tokens in environment keys while expanding values in a valid server', () => { @@ -3020,7 +3042,7 @@ it('reports malformed remote MCP URLs through independently validated host schem expect(codex.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['codex.schema.mcp']); expect(claude.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['claude.schema.mcp']); - expect(codex.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + expect(emittedMcpServers(codex.entries, 'codex')).toEqual({}); expect(claude.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); }); @@ -3033,9 +3055,8 @@ it('filters host components and builds portable, Codex, and Claude target roots' } satisfies NormalizedPlugin; const filteredPlan = createDefaultRegistry().get('codex').plan(filtered); expect(filteredPlan.entries.map((entry) => entry.relativePath)).toEqual([ - '.agents/plugins/marketplace.json', - '.codex-plugin/plugin.json', - 'INSTALL.md', + codexArtifactPaths.marketplace, + codexArtifactPaths.plugin, ]); const root = await mkdtemp(join(tmpdir(), 'agent-bundle-host-adapter-')); @@ -3076,18 +3097,20 @@ it('filters host components and builds portable, Codex, and Claude target roots' try { await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - await expect(readFile(join(outputRoot, 'portable', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); - await expect(readFile(join(outputRoot, 'codex', '.codex-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); - await expect(readFile(join(outputRoot, 'claude', '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); + // One composite root: every selected host's manifest sits at the root in its own place (#555). + await expect(readFile(join(outputRoot, 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); + await expect(readFile(join(outputRoot, codexArtifactPaths.plugin), 'utf8')).resolves.toContain('review-tools'); + await expect(readFile(join(outputRoot, '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); const manifest = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly files: readonly { readonly path: string }[]; readonly targets: readonly { readonly name: string }[]; }; expect(manifest.targets.map(({ name }) => name)).toEqual(['claude', 'codex', 'portable']); expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ - 'portable/plugin.json', - 'codex/.codex-plugin/plugin.json', - 'claude/.claude-plugin/plugin.json', + 'plugin.json', + codexArtifactPaths.plugin, + '.claude-plugin/plugin.json', + 'skills/review/SKILL.md', ])); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index e57a463ad..2a4b3326a 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -65,7 +65,7 @@ beforeAll(async () => { " version: '1.0.0',", ' },', ' routes: { mcpCommands: true },', - " targets: ['claude', 'codex', 'cursor', 'plugin'],", + " targets: ['claude', 'codex', 'cursor'],", '};', '', ].join('\n')); @@ -236,16 +236,11 @@ it('accepts an installed artifact whose manifest declares no resource components await writeFile(artifactManifestPath, `${stableJson({ ...artifactManifest, files: artifactManifest.files.filter((file) => - !/^[^/]+\/(?:assets|commands|skills)\//u.test(file.path)), + !/^(?:assets|commands|skills)\//u.test(file.path)), })}\n`); const clonedFixture: BuiltHostInstallFixture = Object.freeze({ artifactRoot, - bundles: Object.freeze({ - claude: join(artifactRoot, 'claude'), - codex: join(artifactRoot, 'codex'), - cursor: join(artifactRoot, 'cursor'), - plugin: join(artifactRoot, 'plugin'), - }), + bundles: Object.freeze({ claude: artifactRoot, codex: artifactRoot, cursor: artifactRoot }), cli: builtFixture().cli, root: cloneRoot, }); @@ -431,9 +426,9 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' }, pluginRootVariable: { locations: [ - 'hooks/hooks.json#/hooks/sessionStart/0/command', - 'mcp.json#/mcpServers/probe/args/0', - 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + '.cursor-plugin/hooks.json#/hooks/sessionStart/0/command', + '.cursor-plugin/mcp.json#/mcpServers/probe/args/0', + '.cursor-plugin/mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ], resolvedAtInstall: false, sessionEvidence: 'unavailable: Cursor exposes no non-interactive plugin-loading session surface', @@ -443,7 +438,7 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' skill: '.cursor/plugins/local/host-install-proof/skills/probe/SKILL.md', status: 'passed', unifiedBundle: { - hooksDocument: 'hooks/hooks-cursor.json', + hooksDocument: '.cursor-plugin/hooks.json', hooksRegistration: 'registered', install: 'installed', staticFindings: { AB6027: 0, AB7320: 0 }, diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 88b4ee0cf..0e8bfa800 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', @@ -126,7 +126,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({ @@ -161,7 +161,7 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t }, }, 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..6a256e6c1 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -8,6 +8,7 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { installReceiptFile, @@ -42,14 +43,16 @@ const modelFor = (target: string): NormalizedPlugin => ({ }], }); +// The install surface is written once for the composite root, so the +// production path is the composed plan, not one adapter's. const writesFor = (target: string): ReadonlyMap => { - const plan = createDefaultRegistry().get(target).plan(modelFor(target)); + const plan = composeProjections(modelFor(target), createDefaultRegistry()); return new Map(plan.entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); }; -it.each(['claude', 'codex', 'cursor', 'portable', 'plugin'])( +it.each(['claude', 'codex', 'cursor', 'portable'])( 'emits a concrete INSTALL.md for the %s target', (target) => { const install = writesFor(target).get('INSTALL.md'); @@ -117,7 +120,7 @@ it('emits the exact host uninstall commands the framework CLI itself runs, with }); it('emits a standalone safe-copy installer only for Cursor-compatible fallback profiles', () => { - for (const target of ['cursor', 'portable', 'plugin']) { + for (const target of ['cursor', 'portable']) { const writes = writesFor(target); expect(writes.get('INSTALL.md')).toContain('node ./install.mjs'); expect(writes.get('install.mjs')).toContain("join(cursorRoot, 'plugins', 'local')"); @@ -391,14 +394,6 @@ it('emitted install.mjs expands Agent Plugins placeholders for the Cursor copy o } }, 60_000); -it('documents every real host path from the composite profile', () => { - const install = writesFor('plugin').get('INSTALL.md'); - - expect(install).toContain('claude plugin install install-fixture@install-fixture-marketplace --scope user'); - expect(install).toContain('codex plugin add install-fixture@install-fixture-marketplace'); - expect(install).toContain('node ./install.mjs'); -}); - it('documents the same-version reinstall recipe per host, including Claude\'s version-gated update', () => { const claude = writesFor('claude').get('INSTALL.md') ?? ''; expect(claude).toContain('Reinstall after a same-version rebuild'); @@ -412,7 +407,7 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve expect(codex).toContain('codex plugin remove install-fixture@install-fixture-marketplace'); expect(codex).toContain('--replace'); - for (const target of ['cursor', 'portable', 'plugin']) { + for (const target of ['cursor', 'portable']) { const install = writesFor(target).get('INSTALL.md') ?? ''; expect(install).toContain(installReceiptFile); expect(install).toContain('--replace'); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index fbb29b065..002335bc8 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -105,11 +105,12 @@ const makeFifo = async (path: string): Promise => { const createHostBundle = async ( host: 'claude' | 'codex' | 'cursor', - options: { readonly artifactRoot?: boolean } = {}, + /** `nestedUnder` writes the bundle one directory below `from`, the pre-#555 `/` layout nothing probes any more. */ + options: { readonly nestedUnder?: string } = {}, ): Promise<{ readonly bundleRoot: string; readonly cleanupRoot: string; readonly from: string }> => { const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-install-')); - const from = options.artifactRoot === true ? cleanupRoot : join(cleanupRoot, 'bundle'); - const bundleRoot = options.artifactRoot === true ? join(cleanupRoot, host) : from; + const from = join(cleanupRoot, 'bundle'); + const bundleRoot = options.nestedUnder === undefined ? from : join(from, options.nestedUnder); await mkdir(bundleRoot, { recursive: true }); await writeFile(join(bundleRoot, 'payload.txt'), 'payload\n'); @@ -567,24 +568,34 @@ it('honours --replace for Codex through remove + add and fails closed without a } }); -it('accepts an artifact root containing the requested host target', async () => { - const fixture = await createHostBundle('claude', { artifactRoot: true }); - const { calls, runner } = recordingRunner(); - try { - const result = await installBundle({ - ...isolated(fixture), - commandRunner: runner, - from: fixture.from, - host: 'claude', - scope: 'user', - }); - - expect(result.bundleRoot).toBe(fixture.bundleRoot); - expect(calls[0]).toMatchObject({ cwd: fixture.bundleRoot }); - } finally { - await rm(fixture.cleanupRoot, { force: true, recursive: true }); - } -}); +it.each(['claude', 'codex', 'cursor'] as const)( + 'refuses --from that names a directory above the %s plugin root instead of probing into it (#555)', + async (host) => { + // The bundle sits under `/`, the pre-composite partition: every + // host reads the one root it is given, so nothing nested is probed, no host + // CLI runs, and the refusal names the root that lacks the manifest. + const fixture = await createHostBundle(host, { nestedUnder: host }); + const { calls, runner } = recordingRunner(); + try { + await expect(installBundle({ + ...isolated(fixture), + commandRunner: runner, + from: fixture.from, + host, + scope: 'user', + })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: `No ${host} bundle manifest was found in ${JSON.stringify(fixture.from)}.`, + target: host, + })], + }); + expect(calls).toEqual([]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } + }, +); it('fails with a typed diagnostic when the public host CLI is missing', async () => { const fixture = await createHostBundle('codex'); diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index d3fcffe79..3d4a03ab1 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -21,7 +21,7 @@ afterEach(async () => { const fixture = async (options: { readonly author?: string; readonly bin?: false | readonly string[]; - readonly target: 'cursor' | 'plugin' | 'portable'; + readonly target: 'cursor' | 'portable' | readonly ('claude' | 'codex' | 'cursor')[]; }): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-installer-entry-')); roots.push(root); @@ -43,7 +43,7 @@ const fixture = async (options: { ...(options.author === undefined ? [] : [` cursor: { author: { name: ${JSON.stringify(options.author)} } },`]), " lib: './src/index.ts',", " plugin: { name: 'installer-fixture' },", - ` targets: [${JSON.stringify(options.target)}],`, + ` targets: ${JSON.stringify(typeof options.target === 'string' ? [options.target] : options.target)},`, '};', '', ].join('\n')), @@ -239,7 +239,7 @@ it('uses the plugin name when free and skips portable-only artifacts', async () await mkdir(join(portableHome, '.cursor'), { recursive: true }); const portableMarketplace = await run( process.execPath, - [join(portableRoot, 'host-packs', 'portable', 'install.mjs'), '--mode', 'marketplace'], + [join(portableRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: portableHome } }, ); expect(portableMarketplace.code).toBe(1); @@ -249,22 +249,22 @@ it('uses the plugin name when free and skips portable-only artifacts', async () // A bundle carrying nested Git metadata would be committed as an empty gitlink; the emitted installer refuses it. const cursorHome = join(cursorRoot, 'home'); await mkdir(join(cursorHome, '.cursor'), { recursive: true }); - await mkdir(join(cursorRoot, 'host-packs', 'cursor', 'vendor', '.git'), { recursive: true }); + await mkdir(join(cursorRoot, 'host-packs', 'vendor', '.git'), { recursive: true }); const nestedGit = await run( process.execPath, - [join(cursorRoot, 'host-packs', 'cursor', 'install.mjs'), '--mode', 'marketplace'], + [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, ); expect(nestedGit.code).toBe(1); expect(nestedGit.stderr).toContain('refuses bundle-internal Git metadata at "vendor/.git"'); await expect(stat(join(cursorHome, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - await rm(join(cursorRoot, 'host-packs', 'cursor', 'vendor'), { recursive: true }); + await rm(join(cursorRoot, 'host-packs', 'vendor'), { recursive: true }); // The emitted install.mjs and `agent-bundle install cursor --mode marketplace` derive owner/description from the // same emitted manifest (authored cursor.author here), so staging with one and rerunning the other is idempotent. const stagedByScript = await run( process.execPath, - [join(cursorRoot, 'host-packs', 'cursor', 'install.mjs'), '--mode', 'marketplace'], + [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, ); expect(stagedByScript).toMatchObject({ code: 0, stderr: '' }); @@ -274,14 +274,14 @@ it('uses the plugin name when free and skips portable-only artifacts', async () )); expect(stagedManifest.owner).toEqual({ name: 'Fixture Owner' }); const rerunByCli = await installBundle({ - from: join(cursorRoot, 'host-packs', 'cursor'), + from: join(cursorRoot, 'host-packs'), home: cursorHome, host: 'cursor', mode: 'marketplace', }); expect(rerunByCli).toMatchObject({ mode: 'marketplace', state: 'already-installed' }); - const pluginRoot = await fixture({ bin: false, target: 'plugin' }); + const pluginRoot = await fixture({ bin: false, target: ['claude', 'codex', 'cursor'] }); const plugin = await build({ output: 'host-packs', packageOutputs: true, root: pluginRoot }); const pluginInstaller = join(pluginRoot, 'dist', 'bin', 'installer-fixture.js'); expect(plugin.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); diff --git a/packages/agent-bundle/tests/integration-matrix.test.ts b/packages/agent-bundle/tests/integration-matrix.test.ts index 487361e89..fe053f92a 100644 --- a/packages/agent-bundle/tests/integration-matrix.test.ts +++ b/packages/agent-bundle/tests/integration-matrix.test.ts @@ -9,6 +9,7 @@ import { expect, it } from '@rstest/core'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { build, inspect, invokeMcp, listHooks, listMcp, simulateHook, validate } from '../src/api.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; @@ -46,14 +47,14 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { { mode: 'copy', name: 'python' }, { mode: 'copy', name: 'shell' }, ], - targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], + targets: [{ name: 'claude' }, { name: 'codex' }, { name: 'portable' }], }); await build({ output, root }); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); - const generatedShell = join(output, 'portable', 'scripts', 'shell.sh'); - const generatedPython = join(output, 'portable', 'scripts', 'python.py'); + const generatedShell = join(output, 'scripts', 'shell.sh'); + const generatedPython = join(output, 'scripts', 'python.py'); const sourceShell = join(root, 'src', 'shell.sh'); const sourcePython = join(root, 'src', 'python.py'); await expect(execFile(generatedShell, [], { cwd: root })).resolves.toMatchObject({ stdout: 'shell fixture\n' }); @@ -61,28 +62,30 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { expect((await stat(generatedShell)).mode & 0o777).toBe((await stat(sourceShell)).mode & 0o777); expect((await stat(generatedPython)).mode & 0o777).toBe((await stat(sourcePython)).mode & 0o777); - const bundled = await import(pathToFileURL(join(output, 'portable', 'scripts', 'bundle.mjs')).href); + const bundled = await import(pathToFileURL(join(output, 'scripts', 'bundle.mjs')).href); expect(bundled.bundleMessage).toBe('bundled fixture'); - await expect(readFile(join(output, 'portable', 'scripts', 'bundle.mjs'), 'utf8')).resolves.not.toMatch( + await expect(readFile(join(output, 'scripts', 'bundle.mjs'), 'utf8')).resolves.not.toMatch( /from\s+['"]agent-bundle(?:\/[^'"]*)?['"]/, ); - await expect(readFile(join(output, 'portable', 'skills', 'review', 'references', 'guide.txt'), 'utf8')).resolves.toBe( + await expect(readFile(join(output, 'skills', 'review', 'references', 'guide.txt'), 'utf8')).resolves.toBe( 'fixture reference\n', ); - await expect(readFile(join(output, 'portable', 'skills', 'review', 'assets', 'binary.bin'))).resolves.toEqual( + await expect(readFile(join(output, 'skills', 'review', 'assets', 'binary.bin'))).resolves.toEqual( await readFile(join(root, 'src', 'skills', 'review', 'assets', 'binary.bin')), ); const [portableMcp, codexMcp, claudeMcp, codexHooks, claudeHooks, codexMarketplace, claudeMarketplace] = await Promise.all([ - readFile(join(output, 'portable', 'mcp.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'codex', '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'claude', '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'codex', 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'claude', 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'codex', '.agents', 'plugins', 'marketplace.json'), 'utf8').then((value) => JSON.parse(value)), - readFile(join(output, 'claude', '.claude-plugin', 'marketplace.json'), 'utf8').then((value) => JSON.parse(value)), + // One composite root (#555): Claude and the portable format keep their + // conventional documents; Codex keeps its own beside its manifest. + readFile(join(output, 'mcp.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, codexArtifactPaths.mcp), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.mcp.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, codexArtifactPaths.hooksManifest), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, 'hooks', 'hooks.json'), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, codexArtifactPaths.marketplace), 'utf8').then((value) => JSON.parse(value)), + readFile(join(output, '.claude-plugin', 'marketplace.json'), 'utf8').then((value) => JSON.parse(value)), ]); expect(portableMcp.mcpServers['remote-http']).toEqual({ headers: { 'X-Fixture': 'integration' }, @@ -119,7 +122,7 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { } const localMcpPath = portableMcp.mcpServers.local.args[0] as string; - await expect(readFile(join(output, 'portable', localMcpPath), 'utf8')).resolves.toContain('ordinary local import'); + await expect(readFile(join(output, localMcpPath), 'utf8')).resolves.toContain('ordinary local import'); const localTools = await listMcp({ artifact: output, root, server: 'local', target: 'portable' }); expect(localTools.tools).toMatchObject([{ _meta: { ui: { resourceUri: 'ui://integration-fixture/dashboard.html' } }, @@ -141,7 +144,7 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { const client = new Client({ name: 'integration-matrix', version: '1.0.0' }); await client.connect(new StdioClientTransport({ - args: [join(output, 'portable', localMcpPath)], + args: [join(output, localMcpPath)], command: process.execPath, stderr: 'pipe', })); @@ -195,13 +198,13 @@ it('builds the checked-in portable skills-only fixture', async () => { model: { scripts: [], targets: [{ name: 'portable' }] }, }); await build({ output, root }); - await expect(readFile(join(output, 'portable', 'skills', 'portable-skill', 'SKILL.md'), 'utf8')).resolves.toBe( + await expect(readFile(join(output, 'skills', 'portable-skill', 'SKILL.md'), 'utf8')).resolves.toBe( '---\nname: portable-skill\ndescription: A portable skills-only fixture.\n---\n# Portable skill\n\nRead [the guide](references/guide.txt) before using the asset.\n', ); - await expect(readFile(join(output, 'portable', 'skills', 'portable-skill', 'references', 'guide.txt'), 'utf8')).resolves.toBe( + await expect(readFile(join(output, 'skills', 'portable-skill', 'references', 'guide.txt'), 'utf8')).resolves.toBe( 'portable guide\n', ); - await expect(readFile(join(output, 'portable', 'skills', 'portable-skill', 'assets', 'binary.bin'))).resolves.toEqual( + await expect(readFile(join(output, 'skills', 'portable-skill', 'assets', 'binary.bin'))).resolves.toEqual( await readFile(join(root, 'src', 'skills', 'portable-skill', 'assets', 'binary.bin')), ); } finally { diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts index 2b9d376a9..b6bbb68b4 100644 --- a/packages/agent-bundle/tests/layout-build.test.ts +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -167,7 +167,7 @@ it('composes the root and server layouts around every rendered surface of one bu // keeps the route's value as structuredContent. const server = result.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); - const session = await connectServer(root, join(output, 'portable', server.args[0])); + const session = await connectServer(root, join(output, server.args[0])); try { const lookup = await session.client.callTool({ arguments: { message: 'wired' }, name: 'lookup' }, { signal: AbortSignal.timeout(20_000) }); expect(lookup).toMatchObject({ @@ -224,7 +224,7 @@ it('composes the root and server layouts around every rendered surface of one bu stdout: '', }); - // The artifact-hosted executable (`/bin/.mjs`) composes the + // The artifact-hosted executable (`bin/.mjs` in the artifact root) composes the // same chains as the package-built one, and its worker lists the layouts // among its source inputs. const hostedBin = result.build.compiledCliBins.find((bin) => bin.target === 'portable'); @@ -232,14 +232,14 @@ it('composes the root and server layouts around every rendered surface of one bu join(root, 'src/layout.tsx'), join(root, 'src/mcp/harness/layout.tsx'), ])); - const hostedBinPath = join(output, 'portable', 'bin', 'layout-fixture.mjs'); + const hostedBinPath = join(output, 'bin', 'layout-fixture.mjs'); const hostedReport = await execFile(process.execPath, [hostedBinPath, 'report', '/library']); expect(hostedReport.stdout).toBe(piped.stdout); const hostedProjected = await execFile(process.execPath, [hostedBinPath, 'harness', 'lookup', '--input', '{"message":"projected"}']); expect(hostedProjected.stdout).toBe(projected.stdout); // A rendered script takes the root layout. - const scriptPath = join(output, 'portable', 'scripts', 'summarize.mjs'); + const scriptPath = join(output, 'scripts', 'summarize.mjs'); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n\n> shell: script summarize\n'); const scriptJson = await execFile(process.execPath, [scriptPath, 'alpha', '--json']); @@ -259,7 +259,7 @@ it('ships byte-identical surfaces when no layout exists and refuses an invalid l expect(piped.stdout).toBe('Found **2** books under /library.\n'); const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"plain"}']); expect(projected.stdout).toBe('Lookup: plain\n'); - const scriptMarkdown = await execFile(process.execPath, [join(output, 'portable', 'scripts', 'summarize.mjs'), 'alpha']); + const scriptMarkdown = await execFile(process.execPath, [join(output, 'scripts', 'summarize.mjs'), 'alpha']); expect(scriptMarkdown.stdout).toBe('Summarized 1 arguments.\n'); // An invalid layout module is a compile-time error (AB4830), never a runtime surprise. diff --git a/packages/agent-bundle/tests/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-apps-compile.test.ts b/packages/agent-bundle/tests/mcp-apps-compile.test.ts index 8df2928fd..77c1714be 100644 --- a/packages/agent-bundle/tests/mcp-apps-compile.test.ts +++ b/packages/agent-bundle/tests/mcp-apps-compile.test.ts @@ -62,8 +62,8 @@ const compile = async ( apps: readonly NormalizedMcpApp[], options: { readonly mode?: McpAppCompileMode; readonly tools?: AgentBundleToolsConfig } = {}, ) => { - const outDir = join(root, 'dist', 'portable'); - const result = await compileMcpApps(apps, { cwd: root, meta, outDir, target: 'portable', ...options }); + const outDir = join(root, 'dist'); + const result = await compileMcpApps(apps, { cwd: root, meta, outDir, selected: ['portable'], target: 'portable', ...options }); return { outDir, result }; }; diff --git a/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts index 6713f7f72..45b047e29 100644 --- a/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-dev-server.test.ts @@ -59,7 +59,7 @@ it('runs an authenticated initialize and tools/list probe against a real built s port: 0, root: project.root, }); - await access(join(project.root, 'dist', 'claude', '.mcp.json')); + await access(join(project.root, 'dist', '.mcp.json')); const unauthenticated = await fetch(`${server.url}/api/discovery/probes`, { body: JSON.stringify({ host: 'claude', serverName: 'timeline' }), diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts index 9d358bf6b..b54b5aadd 100644 --- a/packages/agent-bundle/tests/mcp-server-runtime.test.ts +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -159,6 +159,89 @@ describe('generated server lineage correlation', () => { await server.close(); } }); + + // The fallback host for a client that does not name itself is a projection, + // never the artifact's own identity (#592). The binding's `hosts` is the + // selected hosts whose MCP documents list this server — the hosts that can + // have spawned it — distinct from `allowedTargets`, the hosts whose hook + // wrappers the runtime it hosts accepts: a Claude-only server hosting the + // runtime of a Claude+Codex root accepts both hosts' wrappers yet assumes + // Claude, and a server several hosts list has no single host to assume and + // leaves the axis to the client's name. + const lineageFallbackFor = async ( + hosts: readonly string[], + allowedTargets: readonly string[] = hosts, + ): Promise => { + const queries: LineageToolCallQuery[] = []; + const lineage: AgentLineageRegistry = { + observe: async () => unavailable('id-not-resolvable'), + resolveToolCall: async (query) => { + queries.push(query); + return unavailable('id-not-resolvable'); + }, + snapshot: () => ({ nodes: {}, openCalls: [], pendingChildren: [], pendingSpawns: [], seenStarts: [] }), + }; + const { host } = stubs(); + const server = await createGeneratedRouteMcpServer({ + artifactEpoch: 'epoch', + events: { + allowedTargets, + artifactEpoch: 'epoch', + createCanonicalEventProps: (() => { + throw new Error('not invoked'); + }) as never, + createEventRuntimeServer: (async () => ({ + close: async () => undefined, + onRoleChange: () => () => undefined, + role: () => 'owner', + })) as never, + endpointId: `lineage-fallback:${hosts.join('+')}:${allowedTargets.join('+')}`, + hosts, + projectEventDocument: (() => { + throw new Error('not invoked'); + }) as never, + }, + host, + lineage, + plugin: { name: 'lineage-fallback', version: '0.0.0' }, + routes: { + 'mcp/fallback/tools/probe': { + config: {}, + id: 'mcp/fallback/tools/probe', + kind: 'tool', + module: { + default: () => undefined, + inputSchema: z.object({}).strict(), + resultSchema: z.object({ ok: z.boolean() }).strict(), + }, + name: 'probe', + }, + }, + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // A client whose name maps to no host, so only the fallback can supply one. + const client = new Client({ name: 'anonymous-mcp-client', version: '1.0.0' }); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + await client.callTool({ arguments: {}, name: 'probe' }, { signal: AbortSignal.timeout(5_000) }); + return queries.map((query) => query.host); + } finally { + await client.close(); + await server.close(); + } + }; + + it('assumes the one host whose MCP document lists the server when the client does not name itself', async () => { + await expect(lineageFallbackFor(['claude'])).resolves.toEqual(['claude']); + }); + + it('assumes the one launching host however many hosts the runtime it hosts accepts', async () => { + await expect(lineageFallbackFor(['claude'], ['claude', 'codex'])).resolves.toEqual(['claude']); + }); + + it('assumes no host when several selected hosts list the server', async () => { + await expect(lineageFallbackFor(['claude', 'codex'])).resolves.toEqual([undefined]); + }); }); describe('generated server render completion', () => { @@ -301,10 +384,10 @@ describe('generated server teardown', () => { role: () => 'owner', })) as never, endpointId: 'teardown-test', + hosts: ['claude'], projectEventDocument: (() => { throw new Error('not invoked'); }) as never, - target: 'claude', }, host, notices, @@ -409,10 +492,10 @@ describe('generated server standby diagnostics', () => { }; }) as never, endpointId: 'standby-diagnostics-test', + hosts: ['claude'], projectEventDocument: (() => { throw new Error('not invoked'); }) as never, - target: 'claude', }, host, notices, diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 9e29a80d3..5c734d55d 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -163,11 +163,7 @@ const publishFixtureEpoch = async ( epoch: epochFor(root, id, undefined, targets), targets, }); - await Promise.all([ - cp(join(artifact, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(artifact, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - ...targets.map((target) => cp(join(artifact, target), join(staging.root, target), { recursive: true })), - ]); + await cp(artifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); const epochRoot = join(root, '.agent-bundle', 'epochs', id); await expect(validateArtifact({ @@ -203,11 +199,7 @@ const publishRemoteEpoch = async (root: string, id: string): Promise const store = new EpochStore({ projectRoot: root }); const staging = await store.createStagingEpoch({ epoch: epochFor(root, id), targets: ['portable'] }); - await Promise.all([ - cp(join(artifact, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(artifact, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - cp(join(artifact, 'portable'), join(staging.root, 'portable'), { recursive: true }), - ]); + await cp(artifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); return store; }; @@ -223,11 +215,7 @@ const publishEpochCopy = async ( epoch: epochFor(root, epochId, createdAt), targets: ['portable'], }); - await Promise.all([ - cp(join(sourceRoot, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(sourceRoot, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - cp(join(sourceRoot, 'portable'), join(staging.root, 'portable'), { recursive: true }), - ]); + await cp(sourceRoot, staging.root, { recursive: true }); await staging.publish(async () => undefined); }; @@ -265,7 +253,7 @@ it('keeps one generated server and plugin-data directory bound to the selected e readonly pid: number; readonly root: string; }; - expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable')); + expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1')); expect(firstState.inherited).toBe('resolved-on-open'); await expect(access(firstState.data)).resolves.toBeUndefined(); expect(session.events().some((event) => event.type === 'stderr' && event.text === 'fixture stderr\n')).toBe(true); @@ -428,7 +416,7 @@ it('uses the configured project root as the default workspace from a decoy cwd', readonly cwd: string; readonly workspace: string; }; - const targetRoot = join(root, '.agent-bundle', 'epochs', 'epoch-workspace', 'claude'); + const targetRoot = join(root, '.agent-bundle', 'epochs', 'epoch-workspace'); expect(first).toMatchObject({ cwd: targetRoot, workspace: root }); await session.restart(); @@ -464,11 +452,11 @@ it('pins the selected epoch until the persistent session closes', async () => { ); } await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).resolves.toBeUndefined(); + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).resolves.toBeUndefined(); await session.close(); await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).rejects.toMatchObject({ + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT', }); await service.close(); @@ -537,9 +525,9 @@ it('executes only the acquired epoch reference root when service and store roots const result = await session.callTool({ arguments: {}, name: 'inspect' }); const state = JSON.parse(textFrom(result)) as { readonly root: string }; - expect(state.root).toBe(join(storeRoot, '.agent-bundle', 'epochs', 'epoch-1', 'portable')); + expect(state.root).toBe(join(storeRoot, '.agent-bundle', 'epochs', 'epoch-1')); await session.close(); - await expect(access(join(serviceRoot, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).resolves.toBeUndefined(); + await expect(access(join(serviceRoot, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).resolves.toBeUndefined(); await service.close(); } finally { await Promise.all([ @@ -608,7 +596,7 @@ it('closes an in-flight open instead of returning an untracked epoch-pinning ses ); } await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).rejects.toMatchObject({ + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT', }); } finally { @@ -678,7 +666,7 @@ it('retains a rejected cleanup from an opening drained during service close', as ); } await epochStore.cleanup(); - await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'portable', 'mcp.json'))).rejects.toMatchObject({ + await expect(access(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'mcp.json'))).rejects.toMatchObject({ code: 'ENOENT', }); } finally { diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 51aad4534..b5a0388ca 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -10,6 +10,7 @@ import { expect, it } from '@rstest/core'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; @@ -535,11 +536,13 @@ it('bundles each local MCP entry once and maps every target manifest to that art registry: createDefaultRegistry(), }); expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); + // One composite root compiles the entry once; the compiled surface is + // attributed to the selection as a whole (#555). expect(result.compiledMcpEntries).toEqual([ { id: 'mcp:local server', name: 'mcp-local-server-f45eb99f', - output: join(outputRoot, 'portable', 'mcp', outputName), + output: join(outputRoot, 'mcp', outputName), outputKind: 'bundle', source: join(root, 'src', 'local server.ts'), sourceInputs: [ @@ -547,51 +550,24 @@ it('bundles each local MCP entry once and maps every target manifest to that art join(root, 'src', 'local server.ts'), join(root, 'src', 'message.ts'), ], - target: 'portable', - }, - { - id: 'mcp:local server', - name: 'mcp-local-server-f45eb99f', - output: join(outputRoot, 'codex', 'mcp', outputName), - outputKind: 'bundle', - source: join(root, 'src', 'local server.ts'), - sourceInputs: [ - join(root, 'agent-bundle.config.ts'), - join(root, 'src', 'local server.ts'), - join(root, 'src', 'message.ts'), - ], - target: 'codex', - }, - { - id: 'mcp:local server', - name: 'mcp-local-server-f45eb99f', - output: join(outputRoot, 'claude', 'mcp', outputName), - outputKind: 'bundle', - source: join(root, 'src', 'local server.ts'), - sourceInputs: [ - join(root, 'agent-bundle.config.ts'), - join(root, 'src', 'local server.ts'), - join(root, 'src', 'message.ts'), - ], - target: 'claude', + target: 'claude+codex+portable', }, ]); - const bundles = await Promise.all(['portable', 'codex', 'claude'].map(async (target) => { - const mcpRoot = join(outputRoot, target, 'mcp'); - expect(await readdir(mcpRoot)).toEqual([outputName]); - const bundle = await readFile(join(mcpRoot, outputName), 'utf8'); - expect(bundle).toContain('bundled'); - expect(bundle).not.toContain('./message.ts'); - expect(bundle).not.toContain('agent-bundle'); - return bundle; - })); - expect(new Set(bundles).size).toBe(1); + const mcpRoot = join(outputRoot, 'mcp'); + expect(await readdir(mcpRoot)).toEqual([outputName]); + const bundle = await readFile(join(mcpRoot, outputName), 'utf8'); + expect(bundle).toContain('bundled'); + expect(bundle).not.toContain('./message.ts'); + expect(bundle).not.toContain('agent-bundle'); + // Every selected host's document points at that one bundle in its own + // dialect: the portable and Claude documents sit at their conventional + // plugin-root paths, Codex's beside its manifest. const [portable, codex, claude] = await Promise.all([ - readFile(join(outputRoot, 'portable', 'mcp.json'), 'utf8'), - readFile(join(outputRoot, 'codex', '.mcp.json'), 'utf8'), - readFile(join(outputRoot, 'claude', '.mcp.json'), 'utf8'), + readFile(join(outputRoot, 'mcp.json'), 'utf8'), + readFile(join(outputRoot, codexArtifactPaths.mcp), 'utf8'), + readFile(join(outputRoot, '.mcp.json'), 'utf8'), ]); expect(JSON.parse(portable)).toMatchObject({ mcpServers: { @@ -633,9 +609,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art projectRoot: root, registry: createDefaultRegistry(), }); - expect(await readFile(join(secondOutput, 'portable', 'mcp', outputName), 'utf8')).toBe( - bundles[0], - ); + expect(await readFile(join(secondOutput, 'mcp', outputName), 'utf8')).toBe(bundle); const collisionRegistry = new TargetRegistry().register({ capabilities: supportedCapabilities('mcp'), @@ -659,14 +633,16 @@ it('bundles each local MCP entry once and maps every target manifest to that art registry: collisionRegistry, })).rejects.toThrow('Duplicate planned artifact destination'); - await rm(join(secondOutput, 'portable', 'mcp', outputName)); + await rm(join(secondOutput, 'mcp', outputName)); expect(await validateArtifact({ artifactRoot: secondOutput })).toMatchObject([ { code: 'AB6004' }, - { code: 'AB6014', generatedPath: 'portable/mcp', target: 'portable' }, - { code: 'AB6007', generatedPath: 'portable/mcp.json' }, + { code: 'AB6014', generatedPath: 'mcp' }, + { code: 'AB6007', generatedPath: '.mcp.json' }, + { code: 'AB6007', generatedPath: codexArtifactPaths.mcp }, + { code: 'AB6007', generatedPath: 'mcp.json' }, ]); - const previousBundle = bundles[0]!; + const previousBundle = bundle; await writeFile(join(root, 'src', 'local server.ts'), 'export const = ;\n'); await expect(build({ model, @@ -674,9 +650,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art projectRoot: root, registry: createDefaultRegistry(), })).rejects.toThrow(); - expect(await readFile(join(outputRoot, 'portable', 'mcp', outputName), 'utf8')).toBe( - previousBundle, - ); + expect(await readFile(join(outputRoot, 'mcp', outputName), 'utf8')).toBe(previousBundle); } finally { await rm(root, { force: true, recursive: true }); } @@ -726,7 +700,7 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply // `/.env` is one directory above `mcp/`; it fills the gap and // an exported variable still wins. - const pluginRoot = join(outputRoot, 'portable'); + const pluginRoot = outputRoot; const probe = async (env: Readonly>): Promise => { const run = await runNodeScript({ args: [entry!.output], env }); expect(run).toMatchObject({ code: 0, stderr: '' }); @@ -788,7 +762,7 @@ it('lets the operator .env beat a manifest env default the host passed through, const outputRoot = join(root, 'artifact'); const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); const [entry] = result.compiledMcpEntries; - const pluginRoot = join(outputRoot, 'claude'); + const pluginRoot = outputRoot; // The host reads the manifest, expands its plugin-root token, and merges // the `env` block into the child environment beneath its own exports — @@ -988,37 +962,34 @@ it('builds one deterministic self-contained MCP App view and injects it through join(root, 'views', 'dashboard.ts'), join(root, 'views', 'shell.html'), ]; - expect(compiled).toEqual(expect.arrayContaining(['claude', 'codex', 'portable'].map((target) => expect.objectContaining({ + // One composite root compiles the app once for the whole selection (#555). + expect(compiled).toEqual([expect.objectContaining({ _meta: { ui: { prefersBorder: true } }, id: 'mcp-app:fixture:dashboard', mimeType: 'text/html;profile=mcp-app', name: 'dashboard', - output: join(outputRoot, target, 'mcp-apps', 'dashboard.html'), + output: join(outputRoot, 'mcp-apps', 'dashboard.html'), resourceUri: 'ui://agent-bundle/dashboard.html', serverIds: ['mcp:fixture'], source: join(root, 'views', 'dashboard.ts'), sourceInputs, - target, - })))); - expect(compiled).toHaveLength(3); + target: 'claude+codex+portable', + })]); expect(compiled.every((entry) => Object.isFrozen(entry.sourceInputs))).toBe(true); - const html = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const html = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); expect(html).toContain('dashboard-ready'); expect(html).toContain(']+(?:src|href)=/iu); - expect(await readdir(join(outputRoot, 'portable', 'mcp-apps'))).toEqual(['dashboard.html']); - for (const target of ['claude', 'codex']) { - expect(await readdir(join(outputRoot, target, 'mcp-apps'))).toEqual(['dashboard.html']); - } - const serverBundle = await readFile(join(outputRoot, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); + expect(await readdir(join(outputRoot, 'mcp-apps'))).toEqual(['dashboard.html']); + const serverBundle = await readFile(join(outputRoot, 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); expect(serverBundle).toContain('ui://agent-bundle/dashboard.html'); expect(serverBundle).toContain('text/html;profile=mcp-app'); expect(serverBundle).toContain('prefersBorder'); expect(result.outputProvenance).toContainEqual({ kind: 'bundle', - path: 'portable/mcp-apps/dashboard.html', + path: 'mcp-apps/dashboard.html', sourceInputs: [ 'agent-bundle.config.ts', 'views/dashboard.css', @@ -1029,7 +1000,7 @@ it('builds one deterministic self-contained MCP App view and injects it through expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); expect(result.outputProvenance).toContainEqual({ kind: 'bundle', - path: 'portable/mcp/mcp-fixture-f16d05ec.mjs', + path: 'mcp/mcp-fixture-f16d05ec.mjs', sourceInputs: [ 'agent-bundle.config.ts', 'src/server.ts', @@ -1105,13 +1076,13 @@ it('injects one release identity into both the Node bundle and the browser MCP A const outputRoot = join(root, 'dist'); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const serverBundle = await readFile(join(outputRoot, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); + const serverBundle = await readFile(join(outputRoot, 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); for (const injected of ['meta-fixture', '4.5.6', '@scope/meta-fixture']) { expect(serverBundle).toContain(injected); } expect(serverBundle).not.toContain('agent-bundle/meta'); - const html = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const html = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); for (const injected of ['meta-fixture', '4.5.6']) { expect(html).toContain(injected); } @@ -1172,13 +1143,13 @@ it('compiles one shared MCP App once and serves it from every identically declar resourceUri: 'ui://agent-bundle/widget.html', serverIds: ['mcp:library', 'mcp:public'], })]); - expect(await readdir(join(outputRoot, 'portable', 'mcp-apps'))).toEqual(['widget.html']); + expect(await readdir(join(outputRoot, 'mcp-apps'))).toEqual(['widget.html']); - const bundleNames = await readdir(join(outputRoot, 'portable', 'mcp')); + const bundleNames = await readdir(join(outputRoot, 'mcp')); for (const serverName of ['library', 'public']) { const bundleName = bundleNames.find((entry) => entry.startsWith(`mcp-${serverName}-`)); expect(bundleName).toBeDefined(); - const bundle = await readFile(join(outputRoot, 'portable', 'mcp', bundleName!), 'utf8'); + const bundle = await readFile(join(outputRoot, 'mcp', bundleName!), 'utf8'); expect(bundle).toContain('ui://agent-bundle/widget.html'); expect(bundle).toContain('widget-ready'); expect(bundle).toContain('prefersBorder'); @@ -1373,7 +1344,7 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and expect(closes).toBe(1); await expect(access(http[0]!.headers!['X-Data']!)).rejects.toMatchObject({ code: 'ENOENT' }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"tampered"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"tampered"}\n'); await expect(service.list({ artifact, server: 'http', target: 'claude' })).rejects.toThrow(); expect(closes).toBe(1); } finally { @@ -1552,7 +1523,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro const outputRoot = join(root, 'dist'); const artifact = join(consumer, 'installed-plugin'); await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const expectedHtml = await readFile(join(outputRoot, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + const expectedHtml = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); await cp(outputRoot, artifact, { recursive: true }); await rm(join(root, 'src'), { force: true, recursive: true }); await rm(join(root, 'views'), { force: true, recursive: true }); @@ -1560,7 +1531,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro const client = new Client({ name: 'app-resource-consumer', version: '1.0.0' }); await client.connect(new StdioClientTransport({ - args: [join(artifact, 'portable', 'mcp', 'mcp-fixture-f16d05ec.mjs')], + args: [join(artifact, 'mcp', 'mcp-fixture-f16d05ec.mjs')], command: process.execPath, stderr: 'pipe', })); @@ -1737,7 +1708,7 @@ it('lists tools from a validated copied artifact without reading project source' readonly data: string; readonly root: string; }; - expect(firstSession.root).toBe(join(artifact, 'portable')); + expect(firstSession.root).toBe(artifact); await expect(access(firstSession.data)).rejects.toMatchObject({ code: 'ENOENT' }); const nextInvocation = await new api.McpService!().invoke({ diff --git a/packages/agent-bundle/tests/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index d3de973c5..26ceb063f 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -241,9 +241,9 @@ it('retains an exact epoch catalog across service restart after fixture source c const epochRoot = join(root, '.agent-bundle', 'epochs', 'epoch-retained'); const suiteDir = join(root, 'evals'); const fixtureFile = join(suiteDir, 'fixture', 'input.txt'); - await mkdir(join(epochRoot, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(epochRoot, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(epochRoot, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(epochRoot, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(fixtureFile, 'first fixture bytes\n'); const reference = epoch('epoch-retained', epochRoot); let discoveryCalls = 0; @@ -866,9 +866,9 @@ it('refuses fixture bytes changed after cataloging without recomputing the serve const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); const fixtureFile = join(suiteDir, 'fixture', 'input.txt'); - await mkdir(join(artifact, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(artifact, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(fixtureFile, 'catalog baseline\n'); const reference = epoch('epoch-native-stale-fixture', artifact); const commands: string[] = []; @@ -988,9 +988,9 @@ it('projects only awaited normalized Claude completion evidence and removes its try { const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); - await mkdir(join(artifact, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(artifact, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'); await writeFile(join(suiteDir, 'grader.mjs'), 'export default () => ({ detail: "sk-proj-1234567890abcdef /private/native/grader", outcome: "pass" });\n'); const reference = epoch('epoch-native-run', artifact); @@ -1090,9 +1090,9 @@ it('bounds normalized native evidence before it reaches durable Playground event try { const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); - await mkdir(join(artifact, 'claude', '.claude-plugin'), { recursive: true }); + await mkdir(join(artifact, '.claude-plugin'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); - await writeFile(join(artifact, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); + await writeFile(join(artifact, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'); await writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'); const response = 'bounded response '.repeat(32_768); const stream = [ @@ -1157,10 +1157,10 @@ it('redacts hostile normalized Codex MCP labels without changing observed eviden const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); - await mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }); + await mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); await mkdir(normalCodexHome, { recursive: true }); - await writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ + await writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }], })); @@ -1226,10 +1226,10 @@ it('awaits a cancelled Codex child, preserves its harness failure, and removes a const artifact = join(root, 'artifact'); const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); - await mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }); + await mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }); await mkdir(join(suiteDir, 'fixture'), { recursive: true }); await mkdir(normalCodexHome, { recursive: true }); - await writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ + await writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }], })); @@ -1314,13 +1314,13 @@ it('eagerly captures every epoch catalog before a later build can replace author const epochARoot = join(root, '.agent-bundle', 'epochs', 'epoch-eager-a'); const epochBRoot = join(root, '.agent-bundle', 'epochs', 'epoch-eager-b'); await Promise.all([ - mkdir(join(epochARoot, 'claude', '.claude-plugin'), { recursive: true }), - mkdir(join(epochBRoot, 'claude', '.claude-plugin'), { recursive: true }), + mkdir(join(epochARoot, '.claude-plugin'), { recursive: true }), + mkdir(join(epochBRoot, '.claude-plugin'), { recursive: true }), mkdir(join(suiteDir, 'fixture'), { recursive: true }), ]); await Promise.all([ - writeFile(join(epochARoot, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), - writeFile(join(epochBRoot, 'claude', '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), + writeFile(join(epochARoot, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), + writeFile(join(epochBRoot, '.claude-plugin', 'plugin.json'), '{"name":"review"}\n'), writeFile(fixtureFile, 'epoch A fixture bytes\n'), ]); const referenceA = epoch('epoch-eager-a', epochARoot); @@ -2317,12 +2317,12 @@ it('does not deadlock when a direct native Codex abort listener awaits a reentra const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); await Promise.all([ - mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }), + mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }), mkdir(join(suiteDir, 'fixture'), { recursive: true }), mkdir(normalCodexHome, { recursive: true }), ]); await Promise.all([ - writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), + writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'), writeFile(join(normalCodexHome, 'auth.json'), '{"opaque":"session"}\n'), ]); @@ -2390,12 +2390,12 @@ it('does not deadlock when caller cancellation reaches a native Codex close list const suiteDir = join(root, 'evals'); const normalCodexHome = join(root, 'normal-codex-home'); await Promise.all([ - mkdir(join(artifact, 'codex', '.agents', 'plugins'), { recursive: true }), + mkdir(join(artifact, '.agents', 'plugins'), { recursive: true }), mkdir(join(suiteDir, 'fixture'), { recursive: true }), mkdir(normalCodexHome, { recursive: true }), ]); await Promise.all([ - writeFile(join(artifact, 'codex', '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), + writeFile(join(artifact, '.agents', 'plugins', 'marketplace.json'), JSON.stringify({ name: 'native-marketplace', plugins: [{ name: 'native-review', source: { path: './', source: 'local' } }] })), writeFile(join(suiteDir, 'fixture', 'input.txt'), 'baseline only\n'), writeFile(join(normalCodexHome, 'auth.json'), '{"opaque":"session"}\n'), ]); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 2b3f4da9f..87bf1eb13 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -189,7 +189,7 @@ it('enumerates lsp components with unambiguous ids for any server name (#100)', }, }, plugin: { name: 'claude-lsp-fixture', version: '1.0.0' }, - targets: ['claude', 'cursor', 'plugin'], + targets: ['claude', 'cursor'], }), { skills: [] }, createDefaultRegistry()); // Separator and escape characters are escaped so the (key, name) tuple is @@ -203,7 +203,7 @@ it('enumerates lsp components with unambiguous ids for any server name (#100)', expect(model.lspServers).toHaveLength(4); // Only adapters that lower the `claude` extension are targeted. for (const server of model.lspServers ?? []) { - expect(server).toMatchObject({ declaredBy: 'claude', targets: ['claude', 'plugin'] }); + expect(server).toMatchObject({ declaredBy: 'claude', targets: ['claude'] }); } expect(Object.isFrozen(model.lspServers)).toBe(true); }); @@ -294,12 +294,6 @@ it('enumerates claude.bin relative to the config file into immutable executable expect(Object.isFrozen(model.hostBins?.[0])).toBe(true); expect(Object.isFrozen(model.hostBins?.[0]?.files)).toBe(true); expect(Object.isFrozen(model.hostBins?.[0]?.files[0])).toBe(true); - - const pluginModel = await normalizeProject({ - ...loaded, - config: { ...loaded.config, targets: ['plugin'] }, - }, { skills: [] }, createDefaultRegistry()); - expect(pluginModel.hostBins?.[0]?.target).toBe('plugin'); } finally { await rm(root, { force: true, recursive: true }); } @@ -364,13 +358,6 @@ it('enumerates Claude workflows and output styles relative to the config file in expect(Object.isFrozen(model.hostWorkflows?.[0]?.files[0])).toBe(true); expect(Object.isFrozen(model.hostOutputStyles)).toBe(true); expect(Object.isFrozen(model.hostOutputStyles?.[0]?.files[0])).toBe(true); - - const pluginModel = await normalizeProject({ - ...loaded, - config: { ...loaded.config, targets: ['plugin'] }, - }, { skills: [] }, createDefaultRegistry()); - expect(pluginModel.hostWorkflows?.[0]?.target).toBe('plugin'); - expect(pluginModel.hostOutputStyles?.[0]?.target).toBe('plugin'); } finally { await rm(root, { force: true, recursive: true }); } @@ -982,12 +969,31 @@ it('reports unknown targets, duplicate IDs, and portable output collisions', asy expect(diagnostics.find(({ code }) => code === 'AB4100')).toMatchObject({ target: 'future-host', }); + // One collision per selected host, reported against the composite root's + // path (#555); the selection is sorted by host name. expect(diagnostics.filter(({ code }) => code === 'AB4102')).toMatchObject([ - { generatedPath: 'portable/skills/duplicate/SKILL.md' }, - { generatedPath: 'future-host/skills/duplicate/SKILL.md' }, + { generatedPath: 'skills/duplicate/SKILL.md', target: 'future-host' }, + { generatedPath: 'skills/duplicate/SKILL.md', target: 'portable' }, ]); }); +it('rejects the retired plugin target as unknown (#555 acceptance 3)', async () => { + // The composite root is the only output shape; `plugin` is not a target + // that selects anything inside it, so it fails like any other unknown name. + const model = await normalizeProject(loadedProject({ + plugin: { name: 'review-tools', version: '1.0.0' }, + targets: ['plugin'], + }), { skills: [] }, registry); + + expect(model.targets.map((target) => target.name)).toEqual(['plugin']); + expect(validateModel(model, registry)).toMatchObject([{ + code: 'AB4100', + message: 'Unknown target "plugin".', + severity: 'error', + target: 'plugin', + }]); +}); + it('normalizes discovered assets with stable IDs, provenance, and all selected targets', async () => { const discovered = { assets: [ @@ -1010,7 +1016,7 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t provenance: { kind: 'conventional', sourcePath: '/workspace/project/agent-bundle.config.ts' }, relativePath: 'logo.svg', source: '/workspace/project/assets/logo.svg', - targets: ['portable', 'claude'], + targets: ['claude', 'portable'], }, { bytes: 3, @@ -1019,7 +1025,7 @@ it('normalizes discovered assets with stable IDs, provenance, and all selected t provenance: { kind: 'conventional', sourcePath: '/workspace/project/agent-bundle.config.ts' }, relativePath: 'branding/logo.png', source: '/workspace/project/branding/logo.png', - targets: ['portable', 'claude'], + targets: ['claude', 'portable'], }, ]); @@ -1055,7 +1061,7 @@ it('reports duplicate asset destinations as duplicate IDs and output collisions' expect(diagnostics.map(({ code }) => code)).toEqual(['AB4101', 'AB4102']); expect(diagnostics[1]).toMatchObject({ - generatedPath: 'portable/assets/logo.svg', + generatedPath: 'assets/logo.svg', sourcePath: '/workspace/project/branding/logo.svg', }); }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 828f5ce6d..f6c12a24a 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -151,8 +151,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); @@ -433,7 +433,7 @@ describe('mcp run', () => { expect(launches).toHaveLength(1); expect(launches[0]!.command).toBe('node'); expect(launches[0]!.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); - expect(launches[0]!.cwd).toBe(join(artifact, 'portable')); + expect(launches[0]!.cwd).toBe(artifact); await expect(stat(join(launches[0]!.cwd, launches[0]!.args[0]!))).resolves.toMatchObject({}); }, 120_000); @@ -485,7 +485,7 @@ describe('mcp run', () => { expect(bare.env.SHARED).toBe('dotenv'); // args/cwd stay artifact-rooted: args[0] is the content-hashed bundle. expect(bare.args[0]).toMatch(/mcp-echoer-[a-f\d]{8}\.mjs$/u); - expect(bare.cwd).toBe(join(artifact, 'portable')); + expect(bare.cwd).toBe(artifact); // Loading never leaks .env values into the runner's own environment. expect(process.env.FROM_DOTENV).toBeUndefined(); @@ -521,9 +521,9 @@ describe('mcp run', () => { expect(disabled.env.AGENT_BUNDLE_ENV_FILE).toBe('none'); // pluginRoot restores the byte-faithful artifact-rooted rehearsal. - const rehearsal = await captureLaunch({ ...base, pluginRoot: join(artifact, 'portable') }); - expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(join(artifact, 'portable')); - expect(rehearsal.env.STATE_DIR).toBe(join(artifact, 'portable', '.runtime')); + const rehearsal = await captureLaunch({ ...base, pluginRoot: artifact }); + expect(rehearsal.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(artifact); + expect(rehearsal.env.STATE_DIR).toBe(join(artifact, '.runtime')); // Codex has no token interpolation — its anchor is a `./` path, so the // target's own relative rule must re-anchor it durably too. @@ -577,7 +577,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 7a3abaf0c..5f706c32c 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -268,10 +268,10 @@ it('uses only an installed tarball after source deletion', async () => { agentBundleImport, ); } - const localServer = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { + const localServer = JSON.parse(await readFile(join(artifact, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly local: { readonly args: readonly [string, ...string[]] } }; }; - const localServerBundle = join(artifact, 'portable', localServer.mcpServers.local.args[0]); + const localServerBundle = join(artifact, localServer.mcpServers.local.args[0]); await Promise.all([ rm(join(projectRoot, 'agent-bundle.config.ts')), @@ -315,23 +315,23 @@ it('uses only an installed tarball after source deletion', async () => { .sort(); expect(validationDocument.diagnostics.map((diagnostic) => diagnostic.code).sort()).toEqual(hostDiagnosticCodes); - const bundlePath = join(artifact, 'portable', 'scripts', 'bundle.mjs'); + const bundlePath = join(artifact, 'scripts', 'bundle.mjs'); await expect(execFile(process.execPath, [ '--input-type=module', '--eval', "const module = await import(process.argv[1]); console.log(module.bundleMessage);", pathToFileURL(bundlePath).href, ], { cwd: projectRoot, env: installedEnvironment() })).resolves.toMatchObject({ stdout: 'bundled fixture\n' }); - await expect(execFile(join(artifact, 'portable', 'scripts', 'shell.sh'), [], { + await expect(execFile(join(artifact, 'scripts', 'shell.sh'), [], { cwd: projectRoot, env: installedEnvironment(), })).resolves.toMatchObject({ stdout: 'shell fixture\n' }); - await expect(execFile('python3', [join(artifact, 'portable', 'scripts', 'python.py')], { + await expect(execFile('python3', [join(artifact, 'scripts', 'python.py')], { cwd: projectRoot, env: installedEnvironment(), })).resolves.toMatchObject({ stdout: 'python fixture\n' }); - expect((await stat(join(artifact, 'portable', 'scripts', 'shell.sh'))).mode & 0o777).toBe(sourceShellMode); - expect((await stat(join(artifact, 'portable', 'scripts', 'python.py'))).mode & 0o777).toBe(sourcePythonMode); + expect((await stat(join(artifact, 'scripts', 'shell.sh'))).mode & 0o777).toBe(sourceShellMode); + expect((await stat(join(artifact, 'scripts', 'python.py'))).mode & 0o777).toBe(sourcePythonMode); const { stdout: hooks } = await runInstalled(cli, projectRoot, [ 'hooks', 'list', '--json', '--root', projectRoot, '--artifact', artifact, '--target', 'codex', @@ -429,16 +429,17 @@ it('uses only an installed tarball after source deletion', async () => { await runInstalled(frameworkCli, frameworkRoot, ['build', '--root', frameworkRoot, '--output', frameworkArtifact]); // Self-containment on the outputs a consumer builds from the installed - // tarball: every host tree of the artifact and the package build's dist/ - // import nothing but Node builtins. A violation names the importer and - // the specifier that survived bundling. + // tarball: the composite root and the package build's dist/ import + // nothing but Node builtins. A violation names the importer and the + // specifier that survived bundling. The MCP entry is compiled once for + // the three selected hosts (#555), not once per host. const [artifactModules, packageModules] = await Promise.all([ emittedModuleReport(frameworkArtifact), emittedModuleReport(join(frameworkRoot, 'dist')), ]); expect(artifactModules.violations).toEqual([]); expect(packageModules.violations).toEqual([]); - expect(artifactModules.modules.filter((module) => /^(?:claude|codex|portable)\/mcp\/[^/]+\.mjs$/u.test(module.path))).toHaveLength(3); + expect(artifactModules.modules.filter((module) => /^mcp\/[^/]+\.mjs$/u.test(module.path))).toHaveLength(1); expect(packageModules.modules.map((module) => module.path)).toEqual(expect.arrayContaining([ 'bin/framework-build-fixture-install.js', 'bin/framework-build-fixture.js', @@ -458,10 +459,10 @@ it('uses only an installed tarball after source deletion', async () => { expect(packedLib.packedAnswer.value).toBe(42); await expect(readFile(join(frameworkRoot, 'dist', 'index.d.ts'), 'utf8')).resolves.toContain('PackedAnswer'); - const greeterManifest = JSON.parse(await readFile(join(frameworkArtifact, 'portable', 'mcp.json'), 'utf8')) as { + const greeterManifest = JSON.parse(await readFile(join(frameworkArtifact, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly greeter: { readonly args: readonly [string, ...string[]] } }; }; - const greeterEntry = join(frameworkArtifact, 'portable', greeterManifest.mcpServers.greeter.args[0]); + const greeterEntry = join(frameworkArtifact, greeterManifest.mcpServers.greeter.args[0]); const greeterBundle = await readFile(greeterEntry, 'utf8'); expect(greeterBundle).not.toMatch(agentBundleImport); expect(greeterBundle).toContain('stdio heartbeat'); diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index 3c7d44c2e..de6bc9ed6 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -104,10 +104,9 @@ beforeAll(async () => { const installedBin = join(consumer, 'node_modules', '.bin', pluginName); await Promise.all([ access(installedBin), - access(join(installedArtifactRoot, 'claude')), - access(join(installedArtifactRoot, 'codex')), - access(join(installedArtifactRoot, 'cursor')), - access(join(installedArtifactRoot, 'plugin')), + access(join(installedArtifactRoot, '.claude-plugin', 'plugin.json')), + access(join(installedArtifactRoot, '.codex-plugin', 'plugin.json')), + access(join(installedArtifactRoot, '.cursor-plugin', 'plugin.json')), ]); await rm(projectRoot, { force: true, recursive: true }); @@ -118,10 +117,9 @@ beforeAll(async () => { packedFixture = Object.freeze({ artifactRoot: installedArtifactRoot, bundles: Object.freeze({ - claude: join(installedArtifactRoot, 'claude'), - codex: join(installedArtifactRoot, 'codex'), - cursor: join(installedArtifactRoot, 'cursor'), - plugin: join(installedArtifactRoot, 'plugin'), + claude: installedArtifactRoot, + codex: installedArtifactRoot, + cursor: installedArtifactRoot, }), cli: sourceFixture.cli, root: cleanupRoot, @@ -255,9 +253,9 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, }, pluginRootVariable: { locations: [ - 'hooks/hooks.json#/hooks/sessionStart/0/command', - 'mcp.json#/mcpServers/probe/args/0', - 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + '.cursor-plugin/hooks.json#/hooks/sessionStart/0/command', + '.cursor-plugin/mcp.json#/mcpServers/probe/args/0', + '.cursor-plugin/mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ], resolvedAtInstall: false, sessionEvidence: 'unavailable: Cursor exposes no non-interactive plugin-loading session surface', @@ -267,7 +265,7 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, skill: '.cursor/plugins/local/host-install-proof/skills/probe/SKILL.md', status: 'passed', unifiedBundle: { - hooksDocument: 'hooks/hooks-cursor.json', + hooksDocument: '.cursor-plugin/hooks.json', hooksRegistration: 'registered', install: 'installed', staticFindings: { AB6027: 0, AB7320: 0 }, diff --git a/packages/agent-bundle/tests/packed-serve-app-command.test.ts b/packages/agent-bundle/tests/packed-serve-app-command.test.ts index e1f5dbf8e..757ffec3f 100644 --- a/packages/agent-bundle/tests/packed-serve-app-command.test.ts +++ b/packages/agent-bundle/tests/packed-serve-app-command.test.ts @@ -19,8 +19,8 @@ import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } * One tarball set (the run-level shared pack), one scratch consumer copied * from `fixtures/serve-app-command`, one `agent-bundle build` with the * installed CLI, then the generated bins run as separate operating-system - * processes: the package build's `dist/bin/.js` and the portable - * pack's `bin/.mjs`. The proof covers the ready-line relay to stderr + * processes: the package build's `dist/bin/.js` and the composite + * root's `bin/.mjs`. The proof covers the ready-line relay to stderr * (stdout stays the JSON result), the served page, teardown of the * `serve-app` child and its packed MCP server, the request `signal` reaching * the child on Ctrl-C, and every `ServeAppCommandError` code a checkout can @@ -160,7 +160,7 @@ beforeAll(async () => { 'react-dom@19.2.8', 'zod@4.4.3', ], { cwd: project, env: installedEnvironment() }); - // The installed CLI builds both surfaces at once: the portable artifact the + // The installed CLI builds both surfaces at once: the composite root the // route serves from (`artifact/`) and the package build whose generated bin // carries the route (`dist/bin/`). const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); @@ -169,7 +169,7 @@ beforeAll(async () => { env: installedEnvironment(), }); packageBin = join(project, 'dist', 'bin', `${pluginName}.js`); - artifactBin = join(project, 'artifact', 'portable', 'bin', `${pluginName}.mjs`); + artifactBin = join(project, 'artifact', 'bin', `${pluginName}.mjs`); }, 300_000); afterAll(async () => { diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 7a0042ae6..87ffa2f3e 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -94,7 +94,7 @@ it('serves compiled routes and durable state across packed process restarts', as env: installedEnvironment(), }); - const pluginRoot = join(artifact, 'claude'); + const pluginRoot = artifact; const manifest = JSON.parse(await readFile(join(pluginRoot, '.mcp.json'), 'utf8')) as McpJson; const serverConfig = manifest.mcpServers['harness']!; // Claude Code expands ${CLAUDE_PLUGIN_ROOT} to the installed plugin root @@ -129,7 +129,7 @@ it('serves compiled routes and durable state across packed process restarts', as await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8'), ) as { readonly project: { readonly revision: string } }; const eventRuntimeEndpointId = - `${artifactManifest.project.revision}:claude:${dirname(dirname(resolve(entry)))}`; + `${artifactManifest.project.revision}:${dirname(dirname(resolve(entry)))}`; const deletedSource = await removeProjectSource({ projectRoot: project }); // The artifact-hosted routed CLI and the `main`-envelope script probe diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts deleted file mode 100644 index 1713d0022..000000000 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ /dev/null @@ -1,863 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { expect, it } from '@rstest/core'; - -import { createDefaultRegistry } from '../src/adapters/registry.ts'; -import { runNodeScript } from './support/run-node-script.ts'; -import { build } from './support/build.ts'; -import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; - -const configPath = '/workspace/agent-bundle.config.ts'; - -const bundleModel = Object.freeze({ - extensions: Object.freeze({}), - hooks: Object.freeze([ - Object.freeze({ - event: 'sessionStart' as const, - id: 'hook:session-start', - name: 'session-start', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - source: '/workspace/src/hooks/session-start.ts', - targets: Object.freeze(['plugin']), - tools: Object.freeze([]), - }), - Object.freeze({ - event: 'afterTool' as const, - id: 'hook:record-write', - name: 'record-write', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - source: '/workspace/src/hooks/record-write.ts', - targets: Object.freeze(['plugin']), - tools: Object.freeze(['file.write' as const]), - }), - ]), - marketplace: true as const, - mcpServers: Object.freeze([ - Object.freeze({ - args: Object.freeze([`${pathTokens.pluginRoot}/mcp/server.mjs`]), - command: 'node', - cwd: pathTokens.pluginRoot, - id: 'mcp:status', - name: 'status', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - targets: Object.freeze(['plugin']), - transport: 'stdio' as const, - }), - ]), - metadata: Object.freeze({ - description: 'One bundle for every supported host.', - id: 'plugin:bundle-example', - name: 'bundle-example', - provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }), - version: '2.0.0', - }), - runtime: Object.freeze({ node: '22.12.0' }), - scripts: Object.freeze([]), - skills: Object.freeze([ - Object.freeze({ - body: '# Review\n', - description: 'Review code and explain findings.', - dir: '/workspace/src/skills/review', - frontmatter: Object.freeze({ description: 'Review code and explain findings.', name: 'review' }), - id: 'skill:review', - name: 'review', - provenance: Object.freeze({ kind: 'conventional' as const, sourcePath: '/workspace/src/skills/review/SKILL.md' }), - resources: Object.freeze([ - Object.freeze({ bytes: 9, relativePath: 'SKILL.md', source: '/workspace/src/skills/review/SKILL.md' }), - Object.freeze({ bytes: 8, relativePath: 'references/guide.md', source: '/workspace/src/skills/review/references/guide.md' }), - ]), - source: '/workspace/src/skills/review/SKILL.md', - targets: Object.freeze(['plugin']), - }), - ]), - targets: Object.freeze([ - Object.freeze({ id: 'target:plugin', name: 'plugin', provenance: Object.freeze({ kind: 'config' as const, sourcePath: configPath }) }), - ]), -} satisfies NormalizedPlugin); - -const planBundle = (model: NormalizedPlugin) => createDefaultRegistry().get('plugin').plan(model); - -const writeContents = (model: NormalizedPlugin): Record => Object.fromEntries( - planBundle(model).entries - .filter((entry): entry is Extract => entry.kind === 'write') - .map((entry) => [entry.relativePath, entry.content]), -); - -it('lays both host manifests over one shared bundle root', () => { - const plan = planBundle(bundleModel); - expect(plan.diagnostics).toEqual([]); - const documents = writeContents(bundleModel); - - const claudePlugin = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - expect(claudePlugin).toMatchObject({ name: 'bundle-example', version: '2.0.0' }); - // Claude Code auto-loads hooks/hooks.json and reports a manifest pointer at - // the same file as a duplicate hooks file, so the manifest never names it. - expect(claudePlugin).not.toHaveProperty('hooks'); - - const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - expect(codexPlugin).toMatchObject({ - mcpServers: './.codex-plugin/mcp.json', - name: 'bundle-example', - skills: './skills/', - }); - - const claudeMcp = JSON.parse(documents['.mcp.json']!) as { - readonly mcpServers: Record }>; - }; - expect(claudeMcp.mcpServers['status']!.args[0]).toBe('${CLAUDE_PLUGIN_ROOT}/mcp/server.mjs'); - expect(claudeMcp.mcpServers['status']).not.toHaveProperty('cwd'); - expect(claudeMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }); - - const codexMcp = JSON.parse(documents['.codex-plugin/mcp.json']!) as { - readonly mcpServers: Record }>; - }; - expect(codexMcp.mcpServers['status']!.args[0]).toBe('./mcp/server.mjs'); - expect(codexMcp.mcpServers['status']!.cwd).toBe('./'); - expect(codexMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: './' }); - - const hooks = documents['hooks/hooks.json']!; - expect(hooks).toContain('${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs'); - expect(hooks).toContain('${CLAUDE_PLUGIN_ROOT}/hooks/record-write.mjs'); - expect(hooks).toContain('apply_patch|Edit|Write'); - expect(codexPlugin).not.toHaveProperty('hooks'); - - expect(documents['.claude-plugin/marketplace.json']).toContain('bundle-example-marketplace'); - expect(documents['.agents/plugins/marketplace.json']).toContain('bundle-example-marketplace'); - expect(JSON.parse(documents['.cursor-plugin/marketplace.json']!)).toEqual({ - name: 'bundle-example-marketplace', - owner: { name: 'bundle-example' }, - plugins: [{ - description: 'One bundle for every supported host.', - name: 'bundle-example', - source: './', - }], - }); - expect(documents['AGENTS.md']).toContain('multi-host agent plugin bundle'); - expect(documents['AGENTS.md']).toContain('Claude Code'); - expect(documents['AGENTS.md']).toContain('Codex'); - expect(documents['AGENTS.md']).toContain('Cursor'); - expect(documents['AGENTS.md']).toContain('See `INSTALL.md` for exact Claude Code, Codex, and Cursor commands'); - expect(documents['AGENTS.md']).toContain('`node ./install.mjs`'); - expect(documents['INSTALL.md']).toContain('claude plugin install bundle-example@bundle-example-marketplace --scope user'); - expect(documents['INSTALL.md']).toContain('codex plugin add bundle-example@bundle-example-marketplace'); - expect(documents['install.mjs']).toContain("join(cursorRoot, 'plugins', 'local')"); - expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); - - const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; - expect(cursorPlugin).toMatchObject({ - hooks: './hooks/hooks-cursor.json', - mcpServers: './mcp.json', - name: 'bundle-example', - skills: './skills/', - version: '2.0.0', - }); - const cursorMcp = JSON.parse(documents['mcp.json']!) as { - readonly mcpServers: Record }>; - }; - expect(cursorMcp.mcpServers['status']!.args[0]).toBe('${CURSOR_PLUGIN_ROOT}/mcp/server.mjs'); - expect(cursorMcp.mcpServers['status']).not.toHaveProperty('type'); - expect(cursorMcp.mcpServers['status']!.env).toEqual({ AGENT_BUNDLE_PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}' }); - expect(JSON.parse(documents['hooks/hooks-cursor.json']!)).toEqual({ - hooks: { - postToolUse: [{ - command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/record-write.cursor.mjs"', - matcher: '^Write$', - }], - sessionStart: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/session-start.cursor.mjs"' }], - }, - version: 1, - }); -}); - -it('keeps the Claude marketplace overlay host-specific in the unified bundle', () => { - const model: NormalizedPlugin = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config', sourcePath: configPath }, - target: 'claude', - value: { - marketplace: { - allowCrossMarketplaceDependenciesOn: ['acme-shared'], - owner: { email: 'plugins@example.test' }, - plugin: { - relevance: { signals: { hosts: ['api.example.test'] } }, - source: { - package: '@acme/bundle-example', - source: 'npm', - version: '^1.0.0', - }, - strict: true, - }, - renames: { 'legacy-bundle-example': 'bundle-example' }, - }, - }, - }, - }, - }; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/marketplace.json']!)).toMatchObject({ - allowCrossMarketplaceDependenciesOn: ['acme-shared'], - owner: { email: 'plugins@example.test', name: 'bundle-example' }, - plugins: [{ - name: 'bundle-example', - relevance: { signals: { hosts: ['api.example.test'] } }, - source: { - package: '@acme/bundle-example', - source: 'npm', - version: '^1.0.0', - }, - strict: true, - }], - renames: { 'legacy-bundle-example': 'bundle-example' }, - }); - expect(JSON.parse(documents['.cursor-plugin/marketplace.json']!)).not.toHaveProperty( - 'allowCrossMarketplaceDependenciesOn', - ); - expect(JSON.parse(documents['.agents/plugins/marketplace.json']!)).not.toHaveProperty( - 'allowCrossMarketplaceDependenciesOn', - ); -}); - -it('emits Cursor logo and omits it from Claude and Codex manifests', () => { - const model: NormalizedPlugin = { - ...bundleModel, - metadata: { - ...bundleModel.metadata, - logo: { - bytes: 64, - path: 'assets/docs/media/logo.svg', - source: '/workspace/docs/media/logo.svg', - }, - }, - }; - const plan = planBundle(model); - expect(plan.diagnostics).toEqual([]); - const documents = writeContents(model); - const claudePlugin = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - const codexPlugin = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; - expect(claudePlugin).not.toHaveProperty('logo'); - expect(codexPlugin).not.toHaveProperty('logo'); - expect(cursorPlugin.logo).toBe('./assets/docs/media/logo.svg'); - expect(plan.entries).toContainEqual(expect.objectContaining({ - kind: 'copy', - relativePath: 'assets/docs/media/logo.svg', - source: '/workspace/docs/media/logo.svg', - })); -}); - -it('bundles subagent hooks at Codex default hooks/hooks.json location', () => { - const model: NormalizedPlugin = { - ...bundleModel, - hooks: [ - { - ...bundleModel.hooks[0]!, - event: 'agentStart', - id: 'hook:agent-start', - name: 'agent-start', - source: '/workspace/src/hooks/agent-start.ts', - }, - { - ...bundleModel.hooks[0]!, - event: 'agentStop', - id: 'hook:agent-stop', - name: 'agent-stop', - source: '/workspace/src/hooks/agent-stop.ts', - }, - ], - }; - const documents = writeContents(model); - const codexManifest = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const hooks = JSON.parse(documents['hooks/hooks.json']!) as { - readonly hooks: Readonly>; - }; - - // Codex discovers this plugin-root path by convention when the manifest - // omits `hooks`; both documented plugin-bundled forms are compliant. - expect(codexManifest).not.toHaveProperty('hooks'); - expect(hooks.hooks.SubagentStart).toHaveLength(1); - expect(hooks.hooks.SubagentStop).toHaveLength(1); -}); - -it('emits Claude-only LSP configuration at the shared composite root', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - lspServers: { - typescript: { - command: 'typescript-language-server', - extensionToLanguage: { '.ts': 'typescript' }, - }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.lsp.json']!)).toEqual({ - typescript: { - command: 'typescript-language-server', - extensionToLanguage: { '.ts': 'typescript' }, - }, - }); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('lspServers'); - expect(documents['AGENTS.md']).toContain('## Language servers'); - expect(documents['AGENTS.md']).toContain('must install the language server binary separately'); - expect(documents['AGENTS.md']).toContain('/plugin'); - expect(documents['AGENTS.md']).toContain('claude --debug'); -}); - -it('emits Claude userConfig from the unified plugin target only into the Claude manifest', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: '/workspace/claude.config.ts' }, - target: 'claude', - value: { - userConfig: { - workspace: { - description: 'Workspace directory.', - required: true, - title: 'Workspace', - type: 'directory', - }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).toMatchObject({ - userConfig: { - workspace: { - description: 'Workspace directory.', - required: true, - title: 'Workspace', - type: 'directory', - }, - }, - }); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('userConfig'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('userConfig'); - expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) - .toContain('/workspace/claude.config.ts'); -}); - -it('emits Claude manifest metadata from the unified target only into the Claude manifest', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: '/workspace/claude-metadata.config.ts' }, - target: 'claude', - value: { - defaultEnabled: false, - displayName: 'Bundle Example', - metadata: { catalog: 'internal' }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).toMatchObject({ - defaultEnabled: false, - displayName: 'Bundle Example', - metadata: { catalog: 'internal' }, - }); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('defaultEnabled'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('displayName'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('metadata'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('defaultEnabled'); - // Cursor owns an independent generic displayName and must not inherit the - // Claude extension's human-readable value. - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toHaveProperty('displayName', 'bundle-example'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('metadata'); - expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) - .toContain('/workspace/claude-metadata.config.ts'); -}); - -it('emits Claude channels from the unified target only into the Claude manifest', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: '/workspace/channels.config.ts' }, - target: 'claude', - value: { - channels: [{ - server: 'status', - userConfig: { - webhook_secret: { - description: 'Webhook signing secret.', - sensitive: true, - title: 'Webhook secret', - type: 'string', - }, - }, - }], - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - const claudeManifest = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - - expect(plan.diagnostics).toEqual([]); - expect(claudeManifest.channels).toEqual([{ - server: 'status', - userConfig: { - webhook_secret: { - description: 'Webhook signing secret.', - sensitive: true, - title: 'Webhook secret', - type: 'string', - }, - }, - }]); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('channels'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('channels'); - expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) - .toContain('/workspace/channels.config.ts'); -}); - -it('emits the Claude bin directory from the unified plugin target', () => { - const model: NormalizedPlugin = { - ...bundleModel, - hostBins: [{ - files: [{ - bytes: 37, - executable: true, - relativePath: 'review-tool', - source: '/workspace/tools/review-tool', - }], - provenance: { kind: 'config', sourcePath: configPath }, - source: '/workspace/tools', - target: 'plugin', - }], - }; - const plan = planBundle(model); - - expect(plan.diagnostics).toEqual([]); - expect(plan.entries.filter((entry) => entry.relativePath.startsWith('bin/'))).toEqual([{ - bytes: 37, - kind: 'copy', - prebuilt: true, - relativePath: 'bin/review-tool', - source: '/workspace/tools/review-tool', - sourceInputs: [configPath, '/workspace/tools/review-tool'], - }]); - expect(writeContents(model)['AGENTS.md']).toContain('`bin/`'); -}); - -it('emits Claude workflows and output styles from the unified plugin target', () => { - const model: NormalizedPlugin = { - ...bundleModel, - hostOutputStyles: [{ - files: [{ - bytes: 72, - executable: false, - relativePath: 'terse.md', - source: '/workspace/styles/terse.md', - }], - provenance: { kind: 'config', sourcePath: configPath }, - source: '/workspace/styles', - target: 'plugin', - }], - hostWorkflows: [{ - files: [{ - bytes: 48, - executable: false, - relativePath: 'release-audit.js', - source: '/workspace/workflows/release-audit.js', - }], - provenance: { kind: 'config', sourcePath: configPath }, - source: '/workspace/workflows', - target: 'plugin', - }], - }; - const plan = planBundle(model); - - expect(plan.diagnostics).toEqual([]); - expect(plan.entries.filter((entry) => - entry.relativePath.startsWith('workflows/') || entry.relativePath.startsWith('output-styles/'))).toEqual([ - { - bytes: 72, - kind: 'copy', - prebuilt: true, - relativePath: 'output-styles/terse.md', - source: '/workspace/styles/terse.md', - sourceInputs: [configPath, '/workspace/styles/terse.md'], - }, - { - bytes: 48, - kind: 'copy', - prebuilt: true, - relativePath: 'workflows/release-audit.js', - source: '/workspace/workflows/release-audit.js', - sourceInputs: [configPath, '/workspace/workflows/release-audit.js'], - }, - ]); - expect(writeContents(model)['AGENTS.md']).toContain('`workflows/`'); - expect(writeContents(model)['AGENTS.md']).toContain('`output-styles/`'); -}); - -it('emits Claude-only plugin default settings at the shared composite root', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - settings: { - subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['settings.json']!)).toEqual({ - subagentStatusLine: { command: 'node scripts/rows.mjs', type: 'command' }, - }); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('settings'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('settings'); - expect(documents['AGENTS.md']).toContain('- `settings.json` — Claude Code default configuration'); -}); - -it('emits Claude-only experimental themes and monitors at the shared composite root', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - monitors: [{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', - description: 'Watch the review queue.', - name: 'review-queue', - when: 'on-skill-invoke:review', - }], - themes: { - dracula: { - base: 'dark', - overrides: { claude: '#bd93f9', error: '#ff5555' }, - }, - }, - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([expect.objectContaining({ - code: 'claude.monitors.availability', - severity: 'warning', - target: 'claude', - })]); - expect(JSON.parse(documents['themes/dracula.json']!)).toEqual({ - base: 'dark', - name: 'dracula', - overrides: { claude: '#bd93f9', error: '#ff5555' }, - }); - expect(JSON.parse(documents['monitors/monitors.json']!)).toEqual([{ - command: 'node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs', - description: 'Watch the review queue.', - name: 'review-queue', - when: 'on-skill-invoke:review', - }]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('experimental'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('experimental'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('experimental'); -}); - -it('emits Claude-only dependencies from the unified plugin target', () => { - const model = { - ...bundleModel, - extensions: { - claude: { - id: 'extension:claude', - key: 'claude', - provenance: { kind: 'config' as const, sourcePath: configPath }, - target: 'claude', - value: { - dependencies: [ - { marketplace: 'acme-shared', name: 'audit-logger' }, - { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, - ], - }, - }, - }, - } satisfies NormalizedPlugin; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!).dependencies).toEqual([ - { marketplace: 'acme-shared', name: 'audit-logger' }, - { marketplace: 'acme-shared', name: 'policy-kit', version: '^2.0' }, - ]); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('dependencies'); -}); - -it('emits each shared surface exactly once with no duplicate artifact paths', () => { - const plan = planBundle(bundleModel); - const paths = plan.entries.map((entry) => entry.relativePath); - expect(paths.filter((path) => path === 'skills/review/SKILL.md')).toHaveLength(1); - expect(paths.filter((path) => path === 'skills/review/references/guide.md')).toHaveLength(1); - expect(new Set(paths).size).toBe(paths.length); - - const hookEntries = plan.hookEntries ?? []; - expect(hookEntries.map((entry) => entry.relativePath).sort()).toEqual([ - 'hooks/record-write.cursor.mjs', - 'hooks/record-write.mjs', - 'hooks/session-start.cursor.mjs', - 'hooks/session-start.mjs', - ]); - expect(new Set(hookEntries.map((entry) => entry.target))).toEqual(new Set(['plugin'])); -}); - -it('emits Cursor-only rules once at the shared root and documents the honest host boundary', () => { - const markdown = '---\ndescription: Keep changes focused\n---\nStay focused.'; - const model: NormalizedPlugin = { - ...bundleModel, - rules: [{ - body: 'Stay focused.', - emittedMarkdown: markdown, - frontmatter: { description: 'Keep changes focused' }, - id: 'rule:focused', - markdown, - name: 'focused', - provenance: { kind: 'conventional', sourcePath: '/workspace/src/rules/focused.mdc' }, - source: '/workspace/src/rules/focused.mdc', - targets: ['plugin'], - }], - }; - const plan = planBundle(model); - const documents = writeContents(model); - const paths = plan.entries.map((entry) => entry.relativePath); - - expect(plan.diagnostics).toEqual([]); - expect(paths.filter((path) => path === 'rules/focused.mdc')).toHaveLength(1); - expect(documents['rules/focused.mdc']).toBe(markdown); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ rules: './rules/' }); - expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('rules'); - expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('rules'); - expect(documents['AGENTS.md']).toContain( - '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', - ); - - const ruleFree = planBundle(bundleModel); - expect(ruleFree.entries.some((entry) => entry.relativePath.startsWith('rules/'))).toBe(false); - expect(writeContents(bundleModel)['AGENTS.md']).not.toContain('`rules/`'); - expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('rules'); -}); - -it('emits Claude-format commands without pointing Cursor at the shared directory', () => { - const model: NormalizedPlugin = { - ...bundleModel, - commands: [{ - body: 'Review the staged diff.\n', - frontmatter: { - argumentHint: '[path]', - description: 'Review changes', - }, - id: 'command:review', - markdown: '---\ndescription: Review changes\nargumentHint: "[path]"\n---\nReview the staged diff.\n', - name: 'review', - provenance: { kind: 'conventional', sourcePath: '/workspace/src/commands/review.md' }, - source: '/workspace/src/commands/review.md', - targets: ['plugin'], - }], - }; - const plan = planBundle(model); - const documents = writeContents(model); - - expect(plan.diagnostics).toEqual([]); - expect(documents['commands/review.md']).toBe([ - '---', - 'argument-hint: "[path]"', - 'description: Review changes', - '---', - 'Review the staged diff.', - '', - ].join('\n')); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); - expect(documents['AGENTS.md']).toContain( - '- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.', - ); - - const commandFree = planBundle(bundleModel); - expect(commandFree.entries.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); - expect(writeContents(bundleModel)['AGENTS.md']).not.toContain('`commands/`'); - expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); -}); - -it('bakes runtime host detection into the universal wrapper source', () => { - const plan = planBundle(bundleModel); - const wrapper = (plan.hookEntries ?? []).find((entry) => entry.relativePath === 'hooks/session-start.mjs'); - expect(wrapper?.virtualSource).toContain('process.env.PLUGIN_ROOT === undefined ? "claude" : "codex"'); - expect(wrapper?.virtualSource).toContain('AGENT_BUNDLE_HOOK_HOST'); -}); - -it('reports a bundle-target conflict instead of silently overwriting an entry', () => { - // Two skills whose resources collide at the same artifact path with different sources. - const model: NormalizedPlugin = { - ...bundleModel, - marketplace: undefined, - mcpServers: [], - skills: [ - bundleModel.skills[0]!, - { - ...bundleModel.skills[0]!, - id: 'skill:review-shadow', - provenance: { kind: 'explicit', sourcePath: '/workspace/other/review/SKILL.md' }, - resources: [{ bytes: 5, relativePath: 'SKILL.md', source: '/workspace/other/review/SKILL.md' }], - source: '/workspace/other/review/SKILL.md', - }, - ], - }; - const plan = planBundle(model); - expect(plan.diagnostics).toEqual([expect.objectContaining({ code: 'plugin.artifact.conflict', severity: 'error' })]); -}); - -it('builds the unified bundle root on disk with a compiled universal hook wrapper', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-plugin-bundle-')); - const outputRoot = join(root, 'dist'); - const skillRoot = join(root, 'src', 'skills', 'review'); - const skillMarkdown = '---\nname: review\ndescription: Review code and explain findings.\n---\n\n# Review\n'; - await mkdir(join(skillRoot, 'references'), { recursive: true }); - await Promise.all([ - writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), - writeFile(join(skillRoot, 'SKILL.md'), skillMarkdown), - writeFile(join(skillRoot, 'references', 'guide.md'), '# Guide\n'), - ]); - const hookSource = join(root, 'src', 'hooks', 'session-start.ts'); - await mkdir(join(root, 'src', 'hooks'), { recursive: true }); - await writeFile(hookSource, "export default (event: unknown, context: { target: string }) => ({ additionalContext: `host:${context.target}`, outcome: 'continue' as const });\n"); - const model: NormalizedPlugin = { - ...bundleModel, - hooks: [{ - ...bundleModel.hooks[0]!, - provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, - source: hookSource, - }], - mcpServers: [], - metadata: { - ...bundleModel.metadata, - provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') }, - }, - skills: [{ - ...bundleModel.skills[0]!, - dir: skillRoot, - provenance: { kind: 'conventional', sourcePath: join(skillRoot, 'SKILL.md') }, - resources: [ - { bytes: Buffer.byteLength(skillMarkdown), relativePath: 'SKILL.md', source: join(skillRoot, 'SKILL.md') }, - { bytes: 8, relativePath: 'references/guide.md', source: join(skillRoot, 'references', 'guide.md') }, - ], - source: join(skillRoot, 'SKILL.md'), - }], - targets: [ - { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') } }, - ], - }; - - try { - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - const bundleRoot = join(outputRoot, 'plugin'); - const claudePlugin = JSON.parse(await readFile(join(bundleRoot, '.claude-plugin', 'plugin.json'), 'utf8')) as Record; - expect(claudePlugin).toMatchObject({ name: 'bundle-example' }); - expect(claudePlugin).not.toHaveProperty('hooks'); - await expect(readFile(join(bundleRoot, '.codex-plugin', 'plugin.json'), 'utf8')).resolves.toContain('./skills/'); - await expect(readFile(join(bundleRoot, 'AGENTS.md'), 'utf8')).resolves.toContain('multi-host agent plugin bundle'); - await expect(readFile(join(bundleRoot, 'skills', 'review', 'SKILL.md'), 'utf8')).resolves.toBe(skillMarkdown); - await expect(readFile(join(bundleRoot, 'hooks', 'hooks.json'), 'utf8')).resolves.toContain('${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs'); - const wrapper = join(bundleRoot, 'hooks', 'session-start.mjs'); - const nativeInput = JSON.stringify({ - cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', - }); - // Codex documents exporting PLUGIN_ROOT into hook processes; Claude does not. - expect(process.env['PLUGIN_ROOT']).toBeUndefined(); - await expect(runNodeScript({ args: [wrapper], env: { PLUGIN_ROOT: '/plugin' }, input: nativeInput })).resolves.toMatchObject({ - code: 0, - stdout: expect.stringContaining('host:codex'), - }); - await expect(runNodeScript({ args: [wrapper], input: nativeInput })).resolves.toMatchObject({ - code: 0, - stdout: expect.stringContaining('host:claude'), - }); - await expect(runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_HOOK_HOST: 'codex' }, input: nativeInput })).resolves.toMatchObject({ - code: 0, - stdout: expect.stringContaining('host:codex'), - }); - // The Cursor wrapper speaks Cursor's own envelope: session ids over - // conversation fields in, snake_case additional_context out. - const cursorWrapper = join(bundleRoot, 'hooks', 'session-start.cursor.mjs'); - const cursorInput = JSON.stringify({ - composer_mode: 'agent', conversation_id: 'conv-1', cursor_version: '2.4.1', hook_event_name: 'sessionStart', - is_background_agent: false, session_id: 'conv-1', transcript_path: null, workspace_roots: ['/workspace'], - }); - const cursorRun = await runNodeScript({ args: [cursorWrapper], input: cursorInput }); - expect(cursorRun.code).toBe(0); - expect(JSON.parse(cursorRun.stdout) as Record).toEqual({ additional_context: 'host:cursor' }); - const manifest = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8')) as { - readonly files: readonly { readonly path: string }[]; - readonly targets: readonly { readonly name: string }[]; - }; - expect(manifest.targets.map(({ name }) => name)).toEqual(['plugin']); - expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ - 'plugin/.claude-plugin/plugin.json', - 'plugin/.codex-plugin/plugin.json', - 'plugin/.cursor-plugin/marketplace.json', - 'plugin/.cursor-plugin/plugin.json', - 'plugin/AGENTS.md', - 'plugin/hooks/hooks-cursor.json', - 'plugin/hooks/session-start.cursor.mjs', - 'plugin/skills/review/SKILL.md', - ])); - } finally { - await rm(root, { force: true, recursive: true }); - } -}, 60_000); diff --git a/packages/agent-bundle/tests/plugin-logo.test.ts b/packages/agent-bundle/tests/plugin-logo.test.ts index dea68878a..dbac38888 100644 --- a/packages/agent-bundle/tests/plugin-logo.test.ts +++ b/packages/agent-bundle/tests/plugin-logo.test.ts @@ -6,7 +6,6 @@ import { afterAll, expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { cursorAdapter, cursorPluginValidator } from '../src/adapters/cursor.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import { normalizeProject, validateSource } from '../src/config/index.ts'; import type { LoadedConfig } from '../src/config/load.ts'; import type { AgentBundleConfig, NormalizedPlugin } from '../src/core/types.ts'; @@ -42,7 +41,7 @@ const loadedProject = async ( }; }; -const logoModel = (target: 'cursor' | 'plugin'): NormalizedPlugin => ({ +const logoModel = (target: 'cursor'): NormalizedPlugin => ({ extensions: {}, hooks: [], metadata: { @@ -164,25 +163,3 @@ it('emits Cursor plugin.json logo and copies the image into the artifact', () => source: '/workspace/docs/media/logo.svg', })); }); - -it('omits logo from Claude and Codex manifests while still emitting it for Cursor', () => { - const model = logoModel('plugin'); - const plan = pluginAdapter.plan(model); - expect(plan.diagnostics).toEqual([]); - const documents = Object.fromEntries( - plan.entries - .filter((entry): entry is Extract => entry.kind === 'write') - .map((entry) => [entry.relativePath, entry.content]), - ); - const claude = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; - const codex = JSON.parse(documents['.codex-plugin/plugin.json']!) as Record; - const cursor = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; - expect(claude).not.toHaveProperty('logo'); - expect(codex).not.toHaveProperty('logo'); - expect(cursor.logo).toBe('./assets/docs/media/logo.svg'); - expect(plan.entries).toContainEqual(expect.objectContaining({ - kind: 'copy', - relativePath: 'assets/docs/media/logo.svg', - source: '/workspace/docs/media/logo.svg', - })); -}); diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index c7cbc8e42..efd753202 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -85,7 +85,7 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () const plan = adapter.plan(plugin()); expect(registry.defaultTargetNames()).toEqual(['portable']); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(plan.diagnostics).toEqual([]); const pluginEntries = plan.entries.filter((entry) => entry.relativePath !== 'INSTALL.md' && entry.relativePath !== 'install.mjs'); @@ -632,7 +632,7 @@ it('rejects duplicate adapters without exposing mutable registry snapshots', () expect(() => registry.register(portableAdapter)).toThrow('already registered'); expect(() => names.push('other')).toThrow(); expect(() => defaults.push('other')).toThrow(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(registry.get('portable').capabilities)).toBe(true); expect(new TargetRegistry().has('portable')).toBe(false); diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index ebba15aa2..cd16da82c 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -81,31 +81,29 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu expect(result.model.hooks.map((hook) => hook.provenance.kind)).toEqual(['prebuilt', 'prebuilt']); expect(result.model.payloads?.map((payload) => payload.name)).toEqual(['app', 'runtime']); - // Payload bytes land verbatim at their stable relative paths per target. - for (const target of ['claude', 'codex', 'portable']) { - expect(await readFile(join(root, 'out', target, 'runtime', 'chunks', '417.js'), 'utf8')) - .toBe('module.exports = require("./418.js");\n'); - expect(await readFile(join(root, 'out', target, 'app', 'index.html'), 'utf8')) - .toBe('widget\n'); - } + // Payload bytes land verbatim, once, at their stable paths in the composite root. + expect(await readFile(join(root, 'out', 'runtime', 'chunks', '417.js'), 'utf8')) + .toBe('module.exports = require("./418.js");\n'); + expect(await readFile(join(root, 'out', 'app', 'index.html'), 'utf8')) + .toBe('widget\n'); // Adapter lowering: the same token expansion as compiled entries. const claudeMcp = await readJson<{ mcpServers: Record }> }>( - join(root, 'out', 'claude', '.mcp.json'), + join(root, 'out', '.mcp.json'), ); expect(claudeMcp.mcpServers['timeline']).toMatchObject({ args: ['${CLAUDE_PLUGIN_ROOT}/runtime/mcp/server.js'], command: 'node', env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' }, }); - const codexMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'codex', '.mcp.json')); + const codexMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', '.codex-plugin', 'mcp.json')); expect(codexMcp.mcpServers['timeline']).toMatchObject({ args: ['./runtime/mcp/server.js'], command: 'node', cwd: './', env: { AGENT_BUNDLE_PLUGIN_ROOT: './' }, }); - const portableMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'portable', 'mcp.json')); + const portableMcp = await readJson<{ mcpServers: Record }>(join(root, 'out', 'mcp.json')); expect(portableMcp.mcpServers['timeline']).toMatchObject({ args: ['${PLUGIN_ROOT}/runtime/mcp/server.js'], command: 'node', @@ -115,7 +113,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu // Prebuilt hooks emit native commands at the payload path with their // declared arguments; nothing is compiled or indexed for them. const claudeHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string; timeout: number }[]; matcher: string }[] } }>( - join(root, 'out', 'claude', 'hooks', 'hooks.json'), + join(root, 'out', 'hooks', 'hooks.json'), ); expect(claudeHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:Write|Edit)$' }); expect(claudeHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ @@ -123,7 +121,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu timeout: 30, }); const codexHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string }[]; matcher: string }[] } }>( - join(root, 'out', 'codex', 'hooks', 'hooks.json'), + join(root, 'out', '.codex-plugin', 'hooks.json'), ); expect(codexHooks.hooks.PostToolUse[0]).toMatchObject({ matcher: '^(?:apply_patch|Edit|Write)$' }); expect(codexHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ @@ -135,7 +133,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu // Manifest provenance: payload files carry the prebuilt kind and their // own bytes as source inputs; the revision hashes the payload files. const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); - const chunk = manifest.files.find((file) => file.path === 'claude/runtime/chunks/417.js'); + const chunk = manifest.files.find((file) => file.path === 'runtime/chunks/417.js'); expect(chunk).toMatchObject({ kind: 'prebuilt', sourceInputs: ['agent-bundle.config.ts', 'built/runtime/chunks/417.js'] }); expect(manifest.project.sourceInputs.some((input) => input.path === 'built/runtime/mcp/server.js')).toBe(true); @@ -163,7 +161,7 @@ it('validates an argument-less prebuilt hook without demanding a wrapper index e const result = await build({ output: join(root, 'out'), root }); expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); const claudeHooks = await readJson<{ hooks: { PostToolUse: { hooks: { command: string }[] }[] } }>( - join(root, 'out', 'claude', 'hooks', 'hooks.json'), + join(root, 'out', 'hooks', 'hooks.json'), ); expect(claudeHooks.hooks.PostToolUse[0]?.hooks[0]).toMatchObject({ command: 'node "${CLAUDE_PLUGIN_ROOT}/runtime/hook.js"', diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 818d5b022..57a7ec8c7 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' })); }); @@ -912,7 +912,8 @@ it('accepts a dependency that only a prebuilt payload module imports: prepack pa const reported = [...packed.build.diagnostics, ...packed.diagnostics]; expect(withCode(reported, 'AB6005')).toHaveLength(0); expect(withCode(reported, 'AB7014')).toHaveLength(0); - expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/cursor/runtime/mcp/server.js'); + // The payload is copied once into the composite root (#555): no `/` partition under `distPath`. + expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/runtime/mcp/server.js'); }, 180_000); it('fails prepack with AB6005, never AB7014, when only a compiled dist bundle imports a declared dependency', async () => { diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index ab7688c9b..253a5b8b2 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -282,9 +282,9 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-consumer-')); try { const artifact = join(consumerRoot, 'artifact'); - await mkdir(join(artifact, 'portable', 'mcp'), { recursive: true }); + await mkdir(join(artifact, 'mcp'), { recursive: true }); await writeFile( - join(artifact, 'portable', 'mcp', 'server.mjs'), + join(artifact, 'mcp', 'server.mjs'), [ "let buffer = '';", 'const send = (id, result) => process.stdout.write(`${JSON.stringify({ jsonrpc: \'2.0\', id, result })}\\n`);', @@ -305,11 +305,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: { @@ -325,8 +325,8 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { })}\n`, ); await Promise.all([ - writeFile(join(artifact, 'portable', 'INSTALL.md'), '# Install packed-fixture\n'), - writeFile(join(artifact, 'portable', 'install.mjs'), '#!/usr/bin/env node\n'), + writeFile(join(artifact, 'INSTALL.md'), '# Install packed-fixture\n'), + writeFile(join(artifact, 'install.mjs'), '#!/usr/bin/env node\n'), ]); await writeFixtureManifest({ artifactRoot: artifact, targets: ['portable'] }); await expect(readFile(join(artifact, 'agent-bundle.hooks.json'), 'utf8')).resolves.toBe( diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index fef73f80e..fc964d463 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -147,8 +147,6 @@ it('projects subagent-stop continuation only through supported host contracts', }); expect(() => projectEventDocument(feedback.document, 'agent/stop', 'codex', 'SubagentStop')) .toThrow(/not supported by the Codex SubagentStop output schema/u); - expect(() => projectEventDocument(feedback.document, 'agent/stop', 'plugin', 'SubagentStop')) - .toThrow(/must resolve the invoking host/u); }); it('projects the Cursor subagent lifecycle through its documented permission and followup_message channels only', async () => { diff --git a/packages/agent-bundle/tests/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..8040ffc9b 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -13,6 +13,7 @@ import portableMcpSchema from '../../src/adapters/schemas/portable/mcp.schema.js import portablePluginSchema from '../../src/adapters/schemas/portable/plugin.schema.json' with { type: 'json' }; import { codexArtifactPaths, codexInterfaceFields, codexPluginDocumentValidator } from '../../src/adapters/codex.ts'; import { + cursorArtifactPaths, cursorHooksValidator, cursorMcpValidator, cursorPluginValidator, @@ -68,7 +69,7 @@ const portablePluginSchemaIdentifier = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; const skillSidecarPath = join('skills', 'probe', 'agents', 'openai.yaml'); const codexManifestPath = codexArtifactPaths.plugin; -const validateCodexPluginManifest = codexPluginDocumentValidator(codexArtifactPaths.mcp); +const validateCodexPluginManifest = codexPluginDocumentValidator; const portableSchemaValidator = createAdapterValidator(); /** @@ -149,8 +150,11 @@ export interface BuiltFixtureProject { } export interface BuiltHostInstallFixture extends BuiltFixtureProject { - /** Per-host bundles plus the unified `plugin` bundle (one root, three host manifests). */ - readonly bundles: Readonly>; + /** + * The composite root is the bundle for every selected host: one directory, + * three host manifests. Keyed by host so install flows read as before. + */ + readonly bundles: Readonly>; } export interface BuiltHostInstallTokenFixture extends BuiltFixtureProject { @@ -296,12 +300,12 @@ export interface CursorHostInstallReport { readonly skill: string; readonly status: 'passed'; /** - * The unified `plugin` bundle installed as a Cursor local plugin in its own isolated home: Doctor's - * static validation must follow the manifest to `hooks/hooks-cursor.json` and report zero + * The composite root installed as a Cursor local plugin in its own isolated home: Doctor's + * static validation must follow the manifest to `.cursor-plugin/hooks.json` and report zero * AB7320/AB6027 findings even though a Claude-format `hooks/hooks.json` sits beside it (#438). */ readonly unifiedBundle: { - readonly hooksDocument: 'hooks/hooks-cursor.json'; + readonly hooksDocument: '.cursor-plugin/hooks.json'; readonly hooksRegistration: 'registered'; readonly install: 'installed'; readonly staticFindings: { readonly AB6027: 0; readonly AB7320: 0 }; @@ -582,7 +586,8 @@ const proveSameVersionRebuild = async (options: { */ const buildFixtureProject = async (options: { readonly buildCommand?: 'build' | 'prepack'; - readonly bundleNames: readonly string[]; + /** Root-relative host documents that prove each selected projection landed. */ + readonly expectedPaths: readonly string[]; readonly environment: Readonly; readonly fixture: string; readonly prepareProject?: (projectRoot: string) => Promise; @@ -607,7 +612,7 @@ const buildFixtureProject = async (options: { timeout: 180_000, }); assertProof(result.exitCode === 0, `${options.fixture} fixture build failed: ${commandDetail(result)}`); - await Promise.all(options.bundleNames.map((name) => access(join(artifactRoot, name)))); + await Promise.all(options.expectedPaths.map((path) => access(join(artifactRoot, path)))); return Object.freeze({ artifactRoot, cli, root }); } catch (error) { await rm(root, { force: true, recursive: true }); @@ -622,18 +627,17 @@ export const buildHostInstallFixture = async (options: { }): Promise => { const built = await buildFixtureProject({ ...(options.buildCommand === undefined ? {} : { buildCommand: options.buildCommand }), - bundleNames: ['claude', 'codex', 'cursor', 'plugin'], environment: options.environment, + expectedPaths: ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json'], fixture: 'host-install', ...(options.prepareProject === undefined ? {} : { prepareProject: options.prepareProject }), }); return Object.freeze({ ...built, bundles: Object.freeze({ - claude: join(built.artifactRoot, 'claude'), - codex: join(built.artifactRoot, 'codex'), - cursor: join(built.artifactRoot, 'cursor'), - plugin: join(built.artifactRoot, 'plugin'), + claude: built.artifactRoot, + codex: built.artifactRoot, + cursor: built.artifactRoot, }), }); }; @@ -648,11 +652,11 @@ export const buildHostInstallTokenFixture = async (options: { readonly environment: Readonly; }): Promise => { const built = await buildFixtureProject({ - bundleNames: ['claude'], environment: options.environment, + expectedPaths: ['.claude-plugin/plugin.json'], fixture: 'host-install-tokens', }); - const claudeBundle = join(built.artifactRoot, 'claude'); + const claudeBundle = built.artifactRoot; return Object.freeze({ ...built, claudeBundle, @@ -664,13 +668,13 @@ export const buildPortableHostInstallFixture = async (options: { readonly environment: Readonly; }): Promise => { const built = await buildFixtureProject({ - bundleNames: ['portable'], environment: options.environment, + expectedPaths: ['agent-bundle.manifest.json'], fixture: 'host-install-portable', }); return Object.freeze({ ...built, - portableBundle: join(built.artifactRoot, 'portable'), + portableBundle: built.artifactRoot, }); }; @@ -746,7 +750,7 @@ export const runDevHostInstallProof = async ( const destination = host === 'cursor' ? join(home, '.cursor', 'plugins', 'local', plugin) : join(marketplaceRoot, 'plugins', 'cache', marketplace, plugin, version); - const mcpPath = host === 'cursor' ? 'mcp.json' : '.mcp.json'; + const mcpPath = hostMcpDocument(host); try { manager.start(); const first = identity('epoch-1', fixture.artifactRoot); @@ -774,11 +778,11 @@ export const runDevHostInstallProof = async ( `${host} development proxy did not report AB8025: ${commandDetail(spawned)}`, ); const skillBefore = await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8'); - const hookName = (await readdir(join(epoch2Root, host, 'hooks'))).find((name) => name.endsWith('.mjs')); + const hookName = (await readdir(join(epoch2Root, 'hooks'))).find((name) => name.endsWith(`.${host}.mjs`)); assertProof(hookName !== undefined, `${host} proof epoch contained no generated hook module.`); await Promise.all([ - writeFile(join(epoch2Root, host, 'skills', 'probe', 'SKILL.md'), `${skillBefore}\nDev epoch two.\n`), - writeFile(join(epoch2Root, host, 'hooks', hookName), 'export default () => ({ outcome: "continue", additionalContext: "epoch two" });\n'), + writeFile(join(epoch2Root, 'skills', 'probe', 'SKILL.md'), `${skillBefore}\nDev epoch two.\n`), + writeFile(join(epoch2Root, 'hooks', hookName), 'export default () => ({ outcome: "continue", additionalContext: "epoch two" });\n'), ]); const callsAfterInstall = hostCommandCalls; const second = identity('epoch-2', epoch2Root); @@ -1183,9 +1187,9 @@ const assertCursorPluginRootVariable = (input: { ); } return Object.freeze([ - 'hooks/hooks.json#/hooks/sessionStart/0/command', - 'mcp.json#/mcpServers/probe/args/0', - 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + '.cursor-plugin/hooks.json#/hooks/sessionStart/0/command', + '.cursor-plugin/mcp.json#/mcpServers/probe/args/0', + '.cursor-plugin/mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ]); }; @@ -1284,9 +1288,9 @@ const assertCursorMarketplaceStaging = async ( }; /** - * Installs the unified `plugin` bundle as a Cursor local plugin in a fresh isolated home and asks Doctor - * for the static and registration verdicts. The bundle carries both `hooks/hooks.json` (Claude/Codex - * format) and `hooks/hooks-cursor.json` (Cursor format, named by `.cursor-plugin/plugin.json`), so a + * Installs the composite root as a Cursor local plugin in a fresh isolated home and asks Doctor + * for the static and registration verdicts. The root carries both `hooks/hooks.json` (Claude + * format) and `.cursor-plugin/hooks.json` (Cursor format, named by `.cursor-plugin/plugin.json`), so a * validator that ignored the manifest would report AB7320/AB6027 against a byte-for-byte install (#438). */ const assertUnifiedBundleCursorInstall = async ( @@ -1297,8 +1301,8 @@ const assertUnifiedBundleCursorInstall = async ( try { await mkdir(join(home, '.cursor'), { recursive: true }); const environment = isolatedEnvironment(options.environment, { HOME: home }); - const result = await runNodeCli(fixture, ['install', 'cursor', '--from', fixture.bundles.plugin, '--json'], { - cwd: fixture.bundles.plugin, + const result = await runNodeCli(fixture, ['install', 'cursor', '--from', fixture.artifactRoot, '--json'], { + cwd: fixture.artifactRoot, environment, }); assertProof(result.exitCode === 0, `Unified bundle Cursor install failed: ${commandDetail(result)}`); @@ -1309,12 +1313,12 @@ const assertUnifiedBundleCursorInstall = async ( const manifest = record(await readJson(join(destination, '.cursor-plugin', 'plugin.json'), 'unified bundle Cursor manifest')); assertProof( - manifest?.hooks === './hooks/hooks-cursor.json', - `Unified bundle Cursor manifest did not name hooks/hooks-cursor.json: ${JSON.stringify(manifest?.hooks)}`, + manifest?.hooks === './.cursor-plugin/hooks.json', + `Unified bundle Cursor manifest did not name .cursor-plugin/hooks.json: ${JSON.stringify(manifest?.hooks)}`, ); - await access(join(destination, 'hooks', 'hooks.json')).catch(() => fail('Unified bundle install lacks the Claude/Codex hooks/hooks.json.')); + await access(join(destination, 'hooks', 'hooks.json')).catch(() => fail('Unified bundle install lacks the Claude hooks/hooks.json.')); const cursorHooks = parseJson( - await readText(join(destination, 'hooks', 'hooks-cursor.json'), 'unified bundle Cursor hooks document'), + await readText(join(destination, '.cursor-plugin', 'hooks.json'), 'unified bundle Cursor hooks document'), 'unified bundle Cursor hooks document', ); assertProof(cursorHooksValidator(cursorHooks), `Unified bundle Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); @@ -1330,12 +1334,12 @@ const assertUnifiedBundleCursorInstall = async ( const finding = cursor?.inventory.findings.find((entry) => entry.path === destination); assertProof(finding?.state === 'installed', `Doctor reported the unified bundle install as ${JSON.stringify(finding?.state)} instead of installed.`); assertProof( - finding.hooks?.state === 'registered' && finding.hooks.source === join(destination, 'hooks', 'hooks-cursor.json'), - `Doctor did not register hooks from hooks/hooks-cursor.json: ${JSON.stringify(finding.hooks)}`, + finding.hooks?.state === 'registered' && finding.hooks.source === join(destination, '.cursor-plugin', 'hooks.json'), + `Doctor did not register hooks from .cursor-plugin/hooks.json: ${JSON.stringify(finding.hooks)}`, ); assertProof(finding.hooks.events.includes('sessionStart'), 'Doctor did not see the unified bundle sessionStart hook registration.'); return Object.freeze({ - hooksDocument: 'hooks/hooks-cursor.json', + hooksDocument: '.cursor-plugin/hooks.json', hooksRegistration: 'registered', install: 'installed', staticFindings: Object.freeze({ AB6027: 0, AB7320: 0 }), @@ -1378,10 +1382,10 @@ export const runCursorHostInstallProof = async ( `Cursor plugin logo ${JSON.stringify(logo)} does not resolve inside the deploy tree.`, ); await access(logoPath).catch(() => fail(`Cursor plugin logo ${JSON.stringify(logo)} is missing from the deploy tree.`)); - const hooksText = await readText(join(destination, 'hooks', 'hooks.json'), 'Cursor hooks document'); + const hooksText = await readText(join(destination, '.cursor-plugin', 'hooks.json'), 'Cursor hooks document'); const hooksDocument = parseJson(hooksText, 'Cursor hooks document'); assertProof(cursorHooksValidator(hooksDocument), `Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); - const mcpText = await readText(join(destination, 'mcp.json'), 'Cursor MCP document'); + const mcpText = await readText(join(destination, '.cursor-plugin', 'mcp.json'), 'Cursor MCP document'); const mcpDocument = parseJson(mcpText, 'Cursor MCP document'); assertProof(cursorMcpValidator(mcpDocument), `Cursor MCP document failed its pinned schema: ${JSON.stringify(cursorMcpValidator.errors)}`); const pluginRootLocations = assertCursorPluginRootVariable({ @@ -1809,7 +1813,20 @@ const liveSkillSource = (version: 'v1' | 'v2'): string => [ const liveHookSource = (version: 'v1' | 'v2'): string => `export default () => ({ additionalContext: 'live development proof ${version}', outcome: 'continue' as const });\n`; -const hostMcpDocument = (host: InstallHost): string => host === 'cursor' ? 'mcp.json' : '.mcp.json'; +const hostMcpDocument = (host: InstallHost): string => { + switch (host) { + case 'claude': + return '.mcp.json'; + case 'codex': + return codexArtifactPaths.mcp; + case 'cursor': + return cursorArtifactPaths.mcp; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown install host ${String(exhaustive)}.`); + } + } +}; const liveHostDestination = ( host: InstallHost, diff --git a/packages/agent-bundle/tests/support/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..9acaba634 100644 --- a/packages/agent-bundle/tests/support/packed-native-smoke.ts +++ b/packages/agent-bundle/tests/support/packed-native-smoke.ts @@ -327,7 +327,7 @@ export const runPackedClaudePluginProof = async (options: { ], { cwd: project, environment }); if (built.exitCode !== 0) throw new Error('packed-claude-proof:build'); - const pluginDirectory = join(artifact, 'claude'); + const pluginDirectory = artifact; const version = await run('claude', ['--version'], { cwd: project, environment }); const versionNumber = /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(version.stdout)?.[1]; if (version.exitCode !== 0 || versionNumber === undefined) { diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index af8ce63ce..73d7a3733 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -13,7 +13,6 @@ import { readTargetNativeHookCommands, type TargetHookContract, } from '../src/adapters/hook-contract.ts'; -import { pluginAdapter } from '../src/adapters/plugin.ts'; import { TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { normalizeProject, type NormalizationTargetRegistry } from '../src/config/index.ts'; @@ -254,10 +253,10 @@ it('builds adapter-owned native hook event, layout, and wrapper source', async ( }); expect(result.compiledHooks[0]).toMatchObject({ target: 'synthetic' }); - const wrapper = join(outputRoot, 'synthetic', 'runtime', 'synthetic-before-tool.mjs'); + const wrapper = join(outputRoot, 'runtime', 'synthetic-before-tool.mjs'); await expect(readFile(wrapper, 'utf8')).resolves.toContain('synthetic-wrapper-marker'); await expect(runWrapper(wrapper)).resolves.toBe('synthetic-wrapper-marker:{"nativeEvent":"SyntheticBeforeWrite"}'); - await expect(readFile(join(outputRoot, 'synthetic', 'native-events', 'registration.json'), 'utf8') + await expect(readFile(join(outputRoot, 'native-events', 'registration.json'), 'utf8') .then(JSON.parse)).resolves.toEqual({ hooks: { SyntheticBeforeWrite: [{ @@ -367,38 +366,6 @@ it('plans a thin epoch-bound event-route client and keeps standalone execution e expect(degradedSource).not.toContain('renderStandaloneEventRoute'); }); -it('bakes the concrete Cursor target only into the plugin Cursor event wrapper', () => { - const hook: NormalizedHook = { - ...planningHook('afterTool', []), - eventRoute: { event: 'tool/after', fallback: 'none', runtime: 'shared' }, - targets: ['plugin'], - }; - const model: NormalizedPlugin = { - ...planningModel([hook]), - targets: [{ - id: 'target:plugin', - name: 'plugin', - provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, - }], - }; - const plan = pluginAdapter.plan(model); - const hookEntries = plan.hookEntries ?? []; - const shared = hookEntries.find((entry) => !entry.relativePath.endsWith('.cursor.mjs')); - const cursor = hookEntries.find((entry) => entry.relativePath.endsWith('.cursor.mjs')); - - expect(shared?.nativeEvent).toBe('PostToolUse'); - expect(cursor?.nativeEvent).toBe('postToolUse'); - expect(shared?.virtualSource).toContain('const nativeEvent = "PostToolUse"'); - expect(cursor?.virtualSource).toContain('const nativeEvent = "postToolUse"'); - expect(shared?.virtualSource).toContain('const declaredHost = process.env.AGENT_BUNDLE_HOOK_HOST;'); - expect(shared?.virtualSource).toContain('process.env.PLUGIN_ROOT === undefined ? "claude" : "codex"'); - expect(shared?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })'); - expect(cursor?.virtualSource).toContain('const target = "cursor";'); - expect(cursor?.virtualSource).not.toContain('AGENT_BUNDLE_HOOK_HOST'); - expect(cursor?.virtualSource).not.toContain('process.env.PLUGIN_ROOT'); - expect(cursor?.virtualSource).toContain('requestEventRuntime({ artifactEpoch, endpointId, event: canonicalEvent, hostContractRevision: capabilityRevision, native, signal: controller.signal, target, timeoutMs })'); -}); - it('continues planning valid hooks after a prior hook mapping error', () => { const plan = planHooks(planningModel([ planningHook('beforeTool', ['shell']), diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 6c7db528b..6e4480a82 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -391,27 +391,23 @@ it('delegates one-shot and persistent MCP operations to an injected target runti expect(stdio).toHaveLength(1); expect(stdio[0]).toMatchObject({ args: [ - join(artifact, 'synthetic-mcp', 'scripts', 'server.mjs'), - join(artifact, 'synthetic-mcp', 'scripts', 'resource.mjs'), + join(artifact, 'scripts', 'server.mjs'), + join(artifact, 'scripts', 'resource.mjs'), ], command: 'runner-$SYNTHETIC_ROOT', - cwd: join(artifact, 'synthetic-mcp'), + cwd: artifact, env: { SESSION: expect.any(String) }, }); expect(http).toEqual([{ headers: { Authorization: expect.stringMatching(/^Bearer \/.+/) }, - url: `https://mcp.example.test/${artifact}/synthetic-mcp`, + url: `https://mcp.example.test/${artifact}`, }]); await expect(access(stdio[0]!.env.SESSION!)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(access(http[0]!.headers!.Authorization.slice('Bearer '.length))).rejects.toMatchObject({ code: 'ENOENT' }); const epochStore = new EpochStore({ projectRoot: root }); const staging = await epochStore.createStagingEpoch({ epoch: epoch(root), targets: ['synthetic-mcp'] }); - await Promise.all([ - cp(join(artifact, 'agent-bundle.hooks.json'), join(staging.root, 'agent-bundle.hooks.json')), - cp(join(artifact, 'agent-bundle.manifest.json'), join(staging.root, 'agent-bundle.manifest.json')), - cp(join(artifact, 'synthetic-mcp'), join(staging.root, 'synthetic-mcp'), { recursive: true }), - ]); + await cp(artifact, staging.root, { recursive: true }); await staging.publish(async () => undefined); const persistentStdio: Array<{ @@ -450,11 +446,11 @@ it('delegates one-shot and persistent MCP operations to an injected target runti expect(persistentStdio).toHaveLength(1); expect(persistentStdio[0]).toMatchObject({ args: [ - join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp', 'scripts', 'server.mjs'), - join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp', 'scripts', 'resource.mjs'), + join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'scripts', 'server.mjs'), + join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'scripts', 'resource.mjs'), ], command: stdio[0]!.command, - cwd: join(root, '.agent-bundle', 'epochs', 'synthetic-epoch', 'synthetic-mcp'), + cwd: join(root, '.agent-bundle', 'epochs', 'synthetic-epoch'), env: { SESSION: expect.any(String) }, }); await Promise.all([session.close(), persistent.close()]); diff --git a/packages/agent-bundle/tests/test-browser-rstest.test.ts b/packages/agent-bundle/tests/test-browser-rstest.test.ts index 30fbe0b06..8637727df 100644 --- a/packages/agent-bundle/tests/test-browser-rstest.test.ts +++ b/packages/agent-bundle/tests/test-browser-rstest.test.ts @@ -1,5 +1,6 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { describe, expect, it } from '@rstest/core'; @@ -60,6 +61,36 @@ describe('agentBundleBrowserRstest', () => { expect(metaModule).toContain('export const packageName = undefined;'); }); + it('mounts each app as one host of a composite selection, never as the selection identity (#555, #592)', { timeout: 60_000 }, async () => { + // The apps compile once for the whole selection the build stages; the + // registry's `target` is the host the harness mounts the app as — its + // preview profile and the binding's `target` — so a Claude+Codex project + // registers `claude`, not `claude+codex`, and an override names one host. + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-browser-pool-')); + try { + await cp(fixtureRoot, root, { recursive: true }); + const configPath = join(root, 'agent-bundle.config.ts'); + const config = await readFile(configPath, 'utf8'); + await writeFile(configPath, config.replace("targets: ['claude'],", "targets: ['codex', 'claude'],")); + + const overridden = await agentBundleBrowserRstest({ root, target: 'claude' }); + const overriddenSetup = await readFile(overridden.setupFiles[0]!, 'utf8'); + expect(overriddenSetup).toContain('"target":"claude"'); + expect(overriddenSetup).not.toContain('claude+codex'); + + const defaulted = await agentBundleBrowserRstest({ root }); + const defaultedSetup = await readFile(defaulted.setupFiles[0]!, 'utf8'); + expect(defaultedSetup).toMatch(/"target":"(?:claude|codex)"/u); + expect(defaultedSetup).not.toContain('claude+codex'); + + await expect(agentBundleBrowserRstest({ root, target: 'cursor' })).rejects.toThrow( + 'MCP App "panel" has no browser mount host selected by the project.', + ); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it('rejects a browser pool whose compiled manifest declares no apps', async () => { const root = resolve(import.meta.dirname, '../../../fixtures/integration/skills-only'); diff --git a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts index 20c7b1c8d..c3d448e49 100644 --- a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts +++ b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts @@ -271,7 +271,7 @@ beforeAll(async () => { return compiledHook.output; }; const pluginRoot = dirname(dirname(resolve(mcp.output))); - const endpointId = `${compiled.build.manifest.project.revision}:claude:${pluginRoot}`; + const endpointId = `${compiled.build.manifest.project.revision}:${pluginRoot}`; fixture = { endpoint: eventRuntimeEndpoint(endpointId), entry: mcp.output, diff --git a/packages/create-agent-bundle/README.md b/packages/create-agent-bundle/README.md index 33b90a8cc..d52e115c4 100644 --- a/packages/create-agent-bundle/README.md +++ b/packages/create-agent-bundle/README.md @@ -28,7 +28,7 @@ scripted and asks nothing — the remaining values fall back to their defaults. | --- | --- | | `-d, --dir ` | Project directory (also the first positional argument). `foo/bar` scaffolds into `foo/bar` and names the package `bar`; `@scope/name` keeps the scoped package name. | | `-t, --template ` | `minimal`, `mcp-server`, or `cli-tool`. | -| `--targets ` | Comma-separated host targets: `portable`, `claude`, `codex`, `cursor`, `plugin`. Default: `portable,codex,claude`. | +| `--targets ` | Comma-separated host targets: `portable`, `claude`, `codex`, `cursor`. Default: `portable,codex,claude`. | | `--package-manager ` | `npm`, `pnpm`, `yarn`, or `bun`. Default: detected from the invoking client. | | `--no-install` | Skip installing dependencies after scaffolding. | | `--framework-version ` | Pin the project's `agent-bundle` dependency to this spec (a version, a tarball path, or a URL). | diff --git a/packages/create-agent-bundle/src/options.ts b/packages/create-agent-bundle/src/options.ts index 8d36db8a4..b8042aa04 100644 --- a/packages/create-agent-bundle/src/options.ts +++ b/packages/create-agent-bundle/src/options.ts @@ -4,7 +4,7 @@ import { parseArgs } from 'node:util'; export const templateNames = ['minimal', 'mcp-server', 'cli-tool'] as const; export type TemplateName = (typeof templateNames)[number]; -export const targetNames = ['portable', 'claude', 'codex', 'cursor', 'plugin'] as const; +export const targetNames = ['portable', 'claude', 'codex', 'cursor'] as const; export type TargetName = (typeof targetNames)[number]; /** The target set every in-repo example ships with. */ @@ -141,9 +141,9 @@ export interface ProjectName { * and names the package `bar`; `@scope/foo` keeps the full scoped name as * the package name. The plugin name additionally drops the scope and is * sanitized to the strictest host contract — Cursor's lowercase kebab-case - * (`/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/`, at most 64 characters), which the - * unified `plugin` target enforces too and which is also a valid safe - * package-output name — so every selectable target validates and the + * (`/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/`, at most 64 characters), which is + * also a valid safe package-output name — so every selectable target + * validates and the * `plugin.name`-derived package executable convention always applies. */ export const formatProjectName = (input: string): ProjectName => { diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index b1c15501b..dfe59fdc2 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -30,7 +30,7 @@ const renderTargets = (targets: readonly TargetName[]): string => /** Derived from the shared default list so a change there cannot silently break the drift check. */ const defaultTargetsLiteral = `targets: [${renderTargets(defaultTargets)}]`; -const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor', 'plugin']; +const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor']; export interface ScaffoldRequest { readonly frameworkSpec: string; @@ -108,7 +108,7 @@ const rewriteConfigTargets = (contents: string, targets: readonly TargetName[]): /** The hosts the generated installer bin accepts, in the package build's order. */ const installableHosts = (targets: readonly TargetName[]): readonly TargetName[] => (['claude', 'codex', 'cursor'] as const) - .filter((host) => targets.some((target) => target === host || target === 'plugin')); + .filter((host) => targets.includes(host)); /** * Template READMEs are written against the default targets, so their install @@ -121,9 +121,9 @@ const readmeInstallProse = /^Installing the npm package does not mutate any host /** * Rewrite a template README's install instructions for the selected targets: - * one example line per installable host, or — when no `claude`, `codex`, - * `cursor`, or `plugin` target is selected and therefore no installer bin is - * generated — an explanation of how to get one. Templates without an install + * one example line per installable host, or — when no `claude`, `codex`, or + * `cursor` target is selected and therefore no installer bin is generated — + * an explanation of how to get one. Templates without an install * section (the skills-only template) pass through unchanged. */ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): string => { diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index 34f72e523..ff8a4d165 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -23,7 +23,7 @@ npm run test:projection # cli-dispatch + script-dispatch pool node dist/bin/my-agent-plugin.js greet World node dist/bin/my-agent-plugin.js greet World --shout node dist/bin/my-agent-plugin.js greet --help -node artifact/portable/scripts/hello.mjs World +node artifact/scripts/hello.mjs World # after publishing/installing the package npx my-agent-plugin-install install claude diff --git a/packages/create-agent-bundle/tests/options.test.ts b/packages/create-agent-bundle/tests/options.test.ts index cb85e4811..5c6bf46e1 100644 --- a/packages/create-agent-bundle/tests/options.test.ts +++ b/packages/create-agent-bundle/tests/options.test.ts @@ -68,8 +68,7 @@ describe('formatProjectName', () => { }); it('sanitizes the plugin name to the strictest host contract (Cursor lowercase kebab-case)', () => { - // Mirrored from cursorNamePattern in packages/agent-bundle/src/adapters/cursor.ts, - // which the unified `plugin` target enforces as well. + // Mirrored from cursorNamePattern in packages/agent-bundle/src/adapters/cursor.ts. const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; expect(formatProjectName('my plugin!').pluginName).toBe('my-plugin'); expect(formatProjectName('--weird--').pluginName).toBe('weird'); @@ -152,13 +151,13 @@ describe('resolveOptions', () => { it('respects explicit flags over prompts and detection', async () => { const resolved = await resolveOptions( - parseFlags(['dir', '-t', 'cli-tool', '--targets', 'plugin', '--package-manager', 'bun', '--no-install']), + parseFlags(['dir', '-t', 'cli-tool', '--targets', 'cursor', '--package-manager', 'bun', '--no-install']), { interactive: true, prompter: unusedPrompter, userAgent: 'pnpm/11.23.0' }, ); expect(resolved).toMatchObject({ install: false, packageManager: 'bun', - targets: ['plugin'], + targets: ['cursor'], template: 'cli-tool', }); }); diff --git a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts index a9aea40e9..71f633f17 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,10 +52,10 @@ it.concurrent('scaffolds the mcp-server template and serves the conventional ent ]); const artifact = join(projectRoot, 'artifact'); - const manifest = JSON.parse(await readFile(join(artifact, 'portable', 'mcp.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(artifact, 'mcp.json'), 'utf8')) as { readonly mcpServers: { readonly status: { readonly args: readonly [string, ...string[]] } }; }; - const entry = join(artifact, 'portable', manifest.mcpServers.status.args[0]); + const entry = join(artifact, manifest.mcpServers.status.args[0]); // The factory export was wrapped in the framework stdio lifecycle shell. await expect(readFile(entry, 'utf8')).resolves.toContain('stdio heartbeat'); @@ -121,7 +121,7 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif // The conventional plain script shipped inside the host artifact with the // framework process envelope around its `main` export. - const script = join(projectRoot, 'artifact', 'portable', 'scripts', 'hello.mjs'); + const script = join(projectRoot, 'artifact', 'scripts', 'hello.mjs'); await expect(execFile(process.execPath, [script, 'World'], { cwd: projectRoot, env: environment })) .resolves.toMatchObject({ stdout: 'Hello, World!\n' }); await expect(execFile(process.execPath, [script], { cwd: projectRoot, env: environment })) @@ -136,9 +136,9 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif // package-keyed object), unlike a bare array destructure. const packedPaths = packOutputFromJson(stdout).files.map((file) => file.path); expect(packedPaths).toContain('artifact/agent-bundle.manifest.json'); - expect(packedPaths).toContain('artifact/portable/plugin.json'); - expect(packedPaths).toContain('artifact/codex/.codex-plugin/plugin.json'); - expect(packedPaths).toContain('artifact/claude/.claude-plugin/plugin.json'); + expect(packedPaths).toContain('artifact/plugin.json'); + expect(packedPaths).toContain('artifact/.codex-plugin/plugin.json'); + expect(packedPaths).toContain('artifact/.claude-plugin/plugin.json'); expect(packedPaths).toContain('dist/bin/greeter-install.js'); } finally { await rm(packDestination, { force: true, recursive: true }); diff --git a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts index cac285a2c..9ea9a4159 100644 --- a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts +++ b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts @@ -38,6 +38,6 @@ it('scaffolds the minimal template, auto-installs, and passes its own check', as await npmRun(projectRoot, 'check'); await expectCleanValidate(projectRoot); - await expect(readFile(join(projectRoot, 'artifact', 'portable', 'skills', 'getting-started', 'SKILL.md'), 'utf8')) + await expect(readFile(join(projectRoot, 'artifact', 'skills', 'getting-started', 'SKILL.md'), 'utf8')) .resolves.toContain('# Getting started'); }, 600_000); diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index 2120e4693..5a7e541f3 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -170,10 +170,10 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys it.effect('renders README install instructions for the selected targets', () => Effect.gen(function* () { const path = yield* Path.Path; - const [defaults, cursorOnly, pluginOnly, portableOnly, minimal] = yield* Effect.all([ + const [defaults, cursorOnly, everyHost, portableOnly, minimal] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['cursor'] }), - scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['plugin'] }), + scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['claude', 'codex', 'cursor'] }), scaffoldTemplate('cli-tool', { pluginName: 'greeter', targets: ['portable'] }), scaffoldTemplate('minimal', { pluginName: 'skills-only', targets: ['portable'] }), ], { concurrency: 'unbounded' }); @@ -197,9 +197,9 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(cursorReadme).not.toContain('install claude'); expect(cursorReadme).not.toContain('install codex'); - // The composite plugin target installs into every host. - const pluginReadme = yield* readText(path.join(pluginOnly.root, 'README.md')); - expect(pluginReadme).toContain([ + // Selecting every host installs into every host. + const everyHostReadme = yield* readText(path.join(everyHost.root, 'README.md')); + expect(everyHostReadme).toContain([ 'npx status-plugin install claude', 'npx status-plugin install codex', 'npx status-plugin install cursor', diff --git a/packages/workbench/tests/artifact-client.test.ts b/packages/workbench/tests/artifact-client.test.ts index 20a4068f7..c0204bfa8 100644 --- a/packages/workbench/tests/artifact-client.test.ts +++ b/packages/workbench/tests/artifact-client.test.ts @@ -10,7 +10,7 @@ const inspection = { bytes: 512, kind: 'generated', mode: 0o755, - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], @@ -22,7 +22,7 @@ const inspection = { sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }, provenance: [{ - outputPath: 'claude/hooks/session-start.mjs', + outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], runtime: { executables: [], hooks: [], mcpServers: [], scripts: [] }, @@ -34,8 +34,8 @@ const inspection = { const diff = { added: [{ - after: { bytes: 512, kind: 'generated', path: 'claude/hooks/stop.mjs', sha256: 'c'.repeat(64), sourceInputs: [] }, - path: 'claude/hooks/stop.mjs', + after: { bytes: 512, kind: 'generated', path: 'hooks/stop.mjs', sha256: 'c'.repeat(64), sourceInputs: [] }, + path: 'hooks/stop.mjs', }], baseEpochId: 'epoch-1', candidateEpochId: 'epoch-2', diff --git a/packages/workbench/tests/artifacts-model.test.ts b/packages/workbench/tests/artifacts-model.test.ts index d07a87fae..63e8d0eec 100644 --- a/packages/workbench/tests/artifacts-model.test.ts +++ b/packages/workbench/tests/artifacts-model.test.ts @@ -20,7 +20,7 @@ const wrapper: ArtifactInspectionFile = { bytes: 512, kind: 'generated', mode: 0o755, - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }; @@ -28,7 +28,7 @@ const wrapper: ArtifactInspectionFile = { const agents: ArtifactInspectionFile = { bytes: 128, kind: 'copy', - path: 'claude/AGENTS.md', + path: 'AGENTS.md', sha256: 'c'.repeat(64), sourceInputs: [], }; @@ -38,17 +38,17 @@ const target: ArtifactInspectionTarget = { name: 'claude', tree: { children: [ - { file: agents, kind: 'file', name: 'AGENTS.md', path: 'claude/AGENTS.md' }, + { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, { - children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'claude/hooks/session-start.mjs' }], + children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], kind: 'directory', name: 'hooks', - path: 'claude/hooks', + path: 'hooks', }, ], kind: 'directory', name: 'claude', - path: 'claude', + path: '.', }, }; @@ -63,8 +63,8 @@ const inspection: ArtifactInspection = { sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }, provenance: [ - { outputPath: 'claude/hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }] }, - { outputPath: 'claude/AGENTS.md', sourceInputs: [] }, + { outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }] }, + { outputPath: 'AGENTS.md', sourceInputs: [] }, ], runtime: { executables: [wrapper], @@ -73,14 +73,14 @@ const inspection: ArtifactInspection = { file: wrapper, id: 'hook:session-start', name: 'session-start', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ - entryPaths: ['claude/mcp/review/server.mjs'], + entryPaths: ['mcp/review/server.mjs'], kind: 'stdio', - manifestPath: 'claude/.mcp.json', + manifestPath: '.mcp.json', name: 'review', target: 'claude', }], @@ -90,10 +90,10 @@ const inspection: ArtifactInspection = { }; const diff: ArtifactEpochDiff = { - added: [{ after: agents, path: 'claude/AGENTS.md' }], + added: [{ after: agents, path: 'AGENTS.md' }], baseEpochId: 'epoch-1', candidateEpochId: 'epoch-2', - changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'claude/hooks/session-start.mjs' }], + changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'hooks/session-start.mjs' }], removed: [{ before: { ...agents, path: 'claude/LEGACY.md' }, path: 'claude/LEGACY.md' }], unchanged: [{ after: agents, before: agents, path: 'claude/README.md' }], }; @@ -110,10 +110,10 @@ it('flattens one target tree into ordered directory and file rows', () => { const rows = artifactTreeRowsFor(target); expect(rows.map((row) => row.path)).toEqual([ - 'claude', - 'claude/hooks', - 'claude/hooks/session-start.mjs', - 'claude/AGENTS.md', + '.', + 'hooks', + 'hooks/session-start.mjs', + 'AGENTS.md', ]); expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 1]); expect(rows[0]).toMatchObject({ entry: 'directory', name: 'claude' }); @@ -149,25 +149,25 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { event: 'sessionStart', key: 'claude/hook:session-start', label: 'session-start · sessionStart · claude', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), target: 'claude', timeout: 30, }]); expect(runtime.mcpServers).toEqual([{ - entryPaths: ['claude/mcp/review/server.mjs'], + entryPaths: ['mcp/review/server.mjs'], key: 'claude/review', kind: 'stdio', label: 'review · stdio · claude', - manifestPath: 'claude/.mcp.json', + manifestPath: '.mcp.json', target: 'claude', }]); expect(runtime.executables).toEqual([{ bytes: 512, - key: 'claude/hooks/session-start.mjs', + key: 'hooks/session-start.mjs', kind: 'generated', mode: '0755', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), }]); expect(Object.isFrozen(runtime)).toBe(true); @@ -176,7 +176,7 @@ it('derives runtime rows for hooks, MCP servers, and executables', () => { it('orders provenance rows by output path and keeps their declared source inputs', () => { const rows = artifactProvenanceRowsFor(inspection.provenance); - expect(rows.map((row) => row.outputPath)).toEqual(['claude/AGENTS.md', 'claude/hooks/session-start.mjs']); + expect(rows.map((row) => row.outputPath)).toEqual(['AGENTS.md', 'hooks/session-start.mjs']); expect(rows[0]?.sourceInputs).toEqual([]); expect(rows[1]?.sourceInputs).toEqual([{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }]); }); @@ -190,7 +190,7 @@ it('groups an epoch diff into counted added, removed, changed, and unchanged row afterBytes: 128, afterSha256: 'c'.repeat(64), change: 'added', - path: 'claude/AGENTS.md', + path: 'AGENTS.md', }); expect(view.groups[0]?.rows[0]?.beforeSha256).toBeUndefined(); expect(view.groups[1]?.rows[0]).toMatchObject({ change: 'removed', path: 'claude/LEGACY.md' }); @@ -218,7 +218,7 @@ it('derives a ready view bound to the selected target', () => { expect(view.state).toBe('ready'); expect(view.targets.map((option) => option.name)).toEqual(['claude']); expect(view.selected?.name).toBe('claude'); - expect(view.tree.map((row) => row.path)).toContain('claude/hooks/session-start.mjs'); + expect(view.tree.map((row) => row.path)).toContain('hooks/session-start.mjs'); expect(view.hooks).toHaveLength(1); expect(view.mcpServers).toHaveLength(1); expect(view.executables).toHaveLength(1); diff --git a/packages/workbench/tests/artifacts-page.test.ts b/packages/workbench/tests/artifacts-page.test.ts index fb70b8981..34401a948 100644 --- a/packages/workbench/tests/artifacts-page.test.ts +++ b/packages/workbench/tests/artifacts-page.test.ts @@ -24,7 +24,7 @@ const wrapper: ArtifactInspectionFile = { bytes: 512, kind: 'generated', mode: 0o755, - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', sha256: 'a'.repeat(64), sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }; @@ -32,7 +32,7 @@ const wrapper: ArtifactInspectionFile = { const agents: ArtifactInspectionFile = { bytes: 128, kind: 'copy', - path: 'claude/AGENTS.md', + path: 'AGENTS.md', sha256: 'c'.repeat(64), sourceInputs: [], }; @@ -48,7 +48,7 @@ const inspection: ArtifactInspection = { sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }, provenance: [{ - outputPath: 'claude/hooks/session-start.mjs', + outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], runtime: { @@ -58,14 +58,14 @@ const inspection: ArtifactInspection = { file: wrapper, id: 'hook:session-start', name: 'session-start', - path: 'claude/hooks/session-start.mjs', + path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ - entryPaths: ['claude/mcp/review/server.mjs'], + entryPaths: ['mcp/review/server.mjs'], kind: 'stdio', - manifestPath: 'claude/.mcp.json', + manifestPath: '.mcp.json', name: 'review', target: 'claude', }], @@ -75,26 +75,26 @@ const inspection: ArtifactInspection = { name: 'claude', tree: { children: [ - { file: agents, kind: 'file', name: 'AGENTS.md', path: 'claude/AGENTS.md' }, + { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, { - children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'claude/hooks/session-start.mjs' }], + children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], kind: 'directory', name: 'hooks', - path: 'claude/hooks', + path: 'hooks', }, ], kind: 'directory', name: 'claude', - path: 'claude', + path: '.', }, }], }; const diff: ArtifactEpochDiff = { - added: [{ after: agents, path: 'claude/AGENTS.md' }], + added: [{ after: agents, path: 'AGENTS.md' }], baseEpochId: 'epoch-1', candidateEpochId: 'epoch-2', - changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'claude/hooks/session-start.mjs' }], + changed: [{ after: wrapper, before: { ...wrapper, bytes: 400, sha256: 'd'.repeat(64) }, path: 'hooks/session-start.mjs' }], removed: [], unchanged: [], }; @@ -141,11 +141,11 @@ it('renders the epoch identity, artifact tree, runtime metadata, and provenance' expect(markup).toContain('Provenance'); expect(markup).toContain('revision-9'); expect(markup).toContain('config-digest'); - expect(markup).toContain('claude/hooks/session-start.mjs'); + expect(markup).toContain('hooks/session-start.mjs'); expect(markup).toContain('0755'); expect(markup).toContain('session-start · sessionStart · claude'); expect(markup).toContain('review · stdio · claude'); - expect(markup).toContain('claude/.mcp.json'); + expect(markup).toContain('.mcp.json'); expect(markup).toContain('hooks/session-start.ts'); expect(markup).toContain('a'.repeat(64)); }); @@ -177,7 +177,7 @@ it('renders each diff group with its count and both epoch digests', () => { expect(markup).toContain('Changed'); expect(markup).toContain('Unchanged'); expect(markup).toContain('epoch-1'); - expect(markup).toContain('claude/AGENTS.md'); + expect(markup).toContain('AGENTS.md'); expect(markup).toContain('d'.repeat(64)); }); diff --git a/packages/workbench/tests/discovery.e2e.test.ts b/packages/workbench/tests/discovery.e2e.test.ts index 4372310cc..5d994d421 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'; @@ -102,6 +102,11 @@ e2e( const source = await readFile(configSource, 'utf8'); const anchor = ' servers: {\n timeline: {'; if (!source.includes(anchor)) throw new Error('Discovery probe fixture config anchor is missing.'); + const outputAnchor = ' dev: { runtime:'; + if (!source.includes(outputAnchor)) throw new Error('Discovery probe fixture config output anchor is missing.'); + // The example packages into `dist/plugins` (its prebuilt payloads + // live beside it under `dist/`); declare that as the artifact dist + // path so Doctor reads the composite root the build wrote (#555). await writeFile(configSource, source.replace(anchor, ` servers: { 'probe-down': { args: ['-e', 'process.exit(0)'], @@ -109,14 +114,14 @@ e2e( targets: ['portable', 'claude', 'codex'], transport: 'stdio', }, - timeline: {`)); + timeline: {`).replace(outputAnchor, ` output: { distPath: 'dist/plugins' }, +${outputAnchor}`)); const output = join(root, 'dist', 'plugins'); const result = await build({ output, root }); const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) { throw new Error(`Discovery probe fixture build failed: ${JSON.stringify(errors)}`); } - await cp(join(output, 'claude'), join(root, 'dist', 'claude'), { recursive: true }); }, }); await page.goto(workbenchUrl(fixture.url, 'hosts')); diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index ca948ee21..a4156289a 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -223,9 +223,12 @@ e2e('drives Hooks, scripts, logs, diagnostics, and repair in real Chrome', { tim await page.getByRole('link', { name: 'Playground', exact: true }).click(); await waitForSettledWorkbench(page); - await page.waitForFunction(() => document.querySelector('#playground-script-id')?.value === 'script:verify-release', undefined, { timeout: browserTimeout }); + // The composite root's `scripts/` holds every emitted script, so the + // catalog offers both of the example's scripts; pick the shared one. + await expect(page.locator('#playground-script-id option[value="script:verify-release"]')).toHaveCount(1, { timeout: browserTimeout }); expect(await page.locator('#playground-target').inputValue()).toBe('claude'); expect(await page.locator('#playground-operation').inputValue()).toBe('script.run'); + await page.locator('#playground-script-id').selectOption('script:verify-release'); expect(await page.locator('#playground-script-id').inputValue()).toBe('script:verify-release'); await page.getByRole('button', { name: 'Run script' }).click(); await expect(page.getByText('script.completed')).toBeVisible({ timeout: browserTimeout }); diff --git a/packages/workbench/tests/hook-client.test.ts b/packages/workbench/tests/hook-client.test.ts index 75c2c70f3..912cc530e 100644 --- a/packages/workbench/tests/hook-client.test.ts +++ b/packages/workbench/tests/hook-client.test.ts @@ -14,7 +14,7 @@ const simulation = { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }, nativeInput: { cwd: '/workspace', hook_event_name: 'SessionStart' }, nativeOutput: { hookSpecificOutput: { hookEventName: 'SessionStart' } }, @@ -32,7 +32,7 @@ it('lists epoch-bound hooks over the same foreground session', async () => { foreground: foreground(recordingFetch(calls, () => response({ hooks: [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, }], }))), }); @@ -118,7 +118,7 @@ it('returns route diagnostics instead of a simulation', async () => { it('rejects surplus fields throughout the hook list wire DTO', async () => { const listedHook = { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, }; const malformed = [ { hooks: [], schemaVersion: 1 }, diff --git a/packages/workbench/tests/hooks-model.test.ts b/packages/workbench/tests/hooks-model.test.ts index b00788e54..d3f52ca9a 100644 --- a/packages/workbench/tests/hooks-model.test.ts +++ b/packages/workbench/tests/hooks-model.test.ts @@ -16,11 +16,11 @@ import { const hooks: readonly HookPlaygroundHook[] = [ { binding: { epochId: 'epoch-1', hook: 'hook:stop', target: 'claude' }, - hook: { event: 'stop', id: 'hook:stop', name: 'stop', path: 'claude/hooks/stop.mjs', target: 'claude' }, + hook: { event: 'stop', id: 'hook:stop', name: 'stop', path: 'hooks/stop.mjs', target: 'claude' }, }, { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude', timeout: 30 }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude', timeout: 30 }, }, ]; @@ -39,7 +39,7 @@ const simulation: HookPlaygroundSimulation = { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }, nativeInput: { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1' }, nativeOutput: { hookSpecificOutput: { additionalContext: 'Ready', hookEventName: 'SessionStart' } }, @@ -64,7 +64,7 @@ it('orders hook options deterministically and keys them by target and hook', () expect(options.map((option) => option.key)).toEqual(['claude/hook:session-start', 'claude/hook:stop']); expect(options.map((option) => option.label)).toEqual(['Session start · Claude', 'Stop · Claude']); - expect(options[0]).toMatchObject({ event: 'sessionStart', path: 'claude/hooks/session-start.mjs', timeout: 30 }); + expect(options[0]).toMatchObject({ event: 'sessionStart', path: 'hooks/session-start.mjs', timeout: 30 }); expect(options[1]?.timeout).toBeUndefined(); expect(Object.isFrozen(options)).toBe(true); expect(hookOptionKeyFor({ epochId: 'epoch-2', hook: 'hook:stop', target: 'codex' })).toBe('codex/hook:stop'); @@ -81,7 +81,7 @@ it('derives canonical intent and host mapping rows from a simulation', () => { { label: 'Native event', value: 'SessionStart' }, { label: 'Native selector', value: 'SessionStart' }, { label: 'Matcher', value: 'startup' }, - { label: 'Wrapper path', value: 'claude/hooks/session-start.mjs' }, + { label: 'Wrapper path', value: 'hooks/session-start.mjs' }, { label: 'Native projection', value: 'deterministic' }, ]); }); @@ -93,7 +93,7 @@ it('omits the matcher row when the emitted manifest declares none', () => { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }); expect(rows.map((row) => row.label)).not.toContain('Matcher'); diff --git a/packages/workbench/tests/hooks-page.test.ts b/packages/workbench/tests/hooks-page.test.ts index 28ec2f858..0ec704604 100644 --- a/packages/workbench/tests/hooks-page.test.ts +++ b/packages/workbench/tests/hooks-page.test.ts @@ -16,7 +16,7 @@ import { hookPlaygroundViewFor } from '../src/hooks/hooks-model.ts'; const hooks: readonly HookPlaygroundHook[] = [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'claude/hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, }]; const simulation: HookPlaygroundSimulation = { @@ -34,7 +34,7 @@ const simulation: HookPlaygroundSimulation = { nativeProjection: 'deterministic', nativeSelector: 'SessionStart', target: 'claude', - wrapperPath: 'claude/hooks/session-start.mjs', + wrapperPath: 'hooks/session-start.mjs', }, nativeInput: { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup' }, nativeOutput: { hookSpecificOutput: { additionalContext: 'Ready to review', hookEventName: 'SessionStart' } }, @@ -112,7 +112,7 @@ it('renders the canonical intent, host mapping, and native trace of a simulation expect(markup).toContain('Canonical result'); expect(markup).toContain('sessionStart'); expect(markup).toContain('SessionStart'); - expect(markup).toContain('claude/hooks/session-start.mjs'); + expect(markup).toContain('hooks/session-start.mjs'); expect(markup).toContain('startup'); expect(markup).toContain('hook_event_name'); expect(markup).toContain('hookSpecificOutput'); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index cd90723d4..7c631ca38 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -1146,7 +1146,7 @@ e2e('opens one real epoch MCP session and keeps its playground operations respon const epochId = artifact.activeEpoch.id; const modelDigest = artifact.activeEpoch.modelDigest; await expect(server.openRuntimeClientSurface('mcp.edit-timeline')).resolves.toBeUndefined(); - const manifest = JSON.parse(await readFile(join(project.root, '.agent-bundle', 'epochs', epochId, 'portable', 'mcp.json'), 'utf8')) as { + const manifest = JSON.parse(await readFile(join(project.root, '.agent-bundle', 'epochs', epochId, 'mcp.json'), 'utf8')) as { readonly mcpServers: Readonly<{ readonly fixture: Readonly<{ readonly args?: readonly string[]; readonly command: string }>; }>; @@ -1240,7 +1240,7 @@ e2e('opens one real epoch MCP session and keeps its playground operations respon launch: { args: ['[REDACTED]'], command: manifest.mcpServers.fixture.command, - cwd: join(project.root, '.agent-bundle', 'epochs', epochId, 'portable'), + cwd: join(project.root, '.agent-bundle', 'epochs', epochId), env: { NO_COLOR: '1' }, kind: 'stdio', }, diff --git a/packages/workbench/tests/workbench-capabilities.test.ts b/packages/workbench/tests/workbench-capabilities.test.ts index 7e0af083d..a1615f13c 100644 --- a/packages/workbench/tests/workbench-capabilities.test.ts +++ b/packages/workbench/tests/workbench-capabilities.test.ts @@ -28,21 +28,21 @@ const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {} executables: [], hooks: Array.from({ length: hooks }, (_, index) => ({ event: 'sessionStart', - file: file(`claude/hooks/hook-${String(index)}.mjs`), + file: file(`hooks/hook-${String(index)}.mjs`), id: `hook:${String(index)}`, name: `hook-${String(index)}`, path: `hooks/hook-${String(index)}.mjs`, target: 'claude', })), mcpServers: Array.from({ length: mcpServers }, (_, index) => ({ - entryPaths: [`portable/mcp/server-${String(index)}.mjs`], + entryPaths: [`mcp/server-${String(index)}.mjs`], kind: 'stdio' as const, - manifestPath: `portable/mcp/server-${String(index)}.json`, + manifestPath: `mcp/server-${String(index)}.json`, name: `server-${String(index)}`, target: 'portable', })), scripts: Array.from({ length: scripts }, (_, index) => ({ - file: file(`portable/scripts/script-${String(index)}.mjs`), + file: file(`scripts/script-${String(index)}.mjs`), id: `script:${String(index)}`, name: `script-${String(index)}`, target: 'portable', diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 506c0acb7..1738f6547 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -26,11 +26,13 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/artifact-cli-bin.test.ts', 'packages/agent-bundle/tests/artifact-validator.test.ts', 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', + 'packages/agent-bundle/tests/build-compose.test.ts', 'packages/agent-bundle/tests/build-reproducibility.test.ts', 'packages/agent-bundle/tests/build.test.ts', 'packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts', 'packages/agent-bundle/tests/cli-routes-build.test.ts', 'packages/agent-bundle/tests/cli.test.ts', + 'packages/agent-bundle/tests/composite-rules.test.ts', 'packages/agent-bundle/tests/dev-contract-adoption.test.ts', 'packages/agent-bundle/tests/dev-artifact-service.test.ts', 'packages/agent-bundle/tests/dev-host-install.test.ts', @@ -64,7 +66,6 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/mcp.test.ts', 'packages/agent-bundle/tests/package-build.test.ts', 'packages/agent-bundle/tests/path-token-resolver.test.ts', - 'packages/agent-bundle/tests/plugin-bundle.test.ts', 'packages/agent-bundle/tests/prebuilt-payload.test.ts', 'packages/agent-bundle/tests/prepack.test.ts', 'packages/agent-bundle/tests/provider-typegen.test.ts', diff --git a/scripts/measure-hook-cold-start.mjs b/scripts/measure-hook-cold-start.mjs index 3fb204f3a..1aac69521 100644 --- a/scripts/measure-hook-cold-start.mjs +++ b/scripts/measure-hook-cold-start.mjs @@ -65,11 +65,14 @@ const measureOnce = async (hookPath) => { }; const findGeneratedHook = async (artifactRoot) => { - const claudeHooks = join(artifactRoot, 'claude', 'hooks'); - const names = await readdir(claudeHooks); - const hook = names.find((name) => name.endsWith('.mjs') && !name.includes('cursor')); - if (hook === undefined) throw new Error(`No generated stdio hook under ${claudeHooks}`); - return join(claudeHooks, hook); + // The built artifact is the plugin root itself (#555): `hooks/` sits at its + // top, and the claude-only fixture compiles one unsuffixed `hooks/.mjs` + // wrapper (a hook several selected hosts share would be `..mjs`). + const hooksDirectory = join(artifactRoot, 'hooks'); + const names = await readdir(hooksDirectory); + const hook = names.find((name) => name.endsWith('.mjs')); + if (hook === undefined) throw new Error(`No generated stdio hook under ${hooksDirectory}`); + return join(hooksDirectory, hook); }; const measure = async () => { @@ -109,7 +112,7 @@ const measure = async () => { const rounded = samplesMs.map((value) => Math.round(value * 100) / 100); return { effect: '4.0.0-rc.112', - hookPath: 'claude/hooks/.mjs', + hookPath: 'hooks/.mjs', kind: 'generated-stdio-hook-cold-start', maxMs: Math.round(Math.max(...rounded) * 100) / 100, measuredAt: new Date().toISOString(), diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index ea1809c9b..fa3b83765 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -86,9 +86,10 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck ``` `pnpm check` inside the package runs validate, build, typecheck, and both test pools. One -`agent-bundle build --output artifact` produces everything: complete Claude and Codex outputs -beneath `artifact/` — each host's plugin metadata, Skill, bundled CLI script, and -lifecycle-wrapped MCP server — plus the npm package beneath `dist/`. +`agent-bundle build --output artifact` produces everything: one composite plugin root at +`artifact/` carrying the Claude and Codex projections — each host's manifest in its own dotfolder +over one shared Skill, bundled CLI script, and lifecycle-wrapped MCP server — plus the npm +package beneath `dist/`. To exercise the built CLI without packing a tarball, link the built bin from any writable directory already on `PATH`: diff --git a/website/docs/en/examples/hooks-and-scripts.mdx b/website/docs/en/examples/hooks-and-scripts.mdx index 2f0de850d..869c5169e 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -18,8 +18,9 @@ an authored script. ## What it proves - **A hook is authored as a handler, not a host document.** `src/hooks/session-start.ts` is one - module. The build lowers it into each host's own hook document shape and emits the wrapper that - runs it. See [Hooks](../guide/authoring/hooks.mdx). + module. The build lowers it into each host's own hook document shape and emits the wrapper each + host's document runs from the shared `hooks/` directory. See + [Hooks](../guide/authoring/hooks.mdx). - **Both script declaration modes.** `verify-release` ships by convention — any unclaimed plain script under `src/scripts/` is discovered — while `detect-risk` stays explicitly configured because it restricts its targets to `portable`. The example keeps both modes covered on @@ -29,8 +30,9 @@ an authored script. nonzero return becomes a real blocking exit rather than a swallowed value. See [Scripts and assets](../guide/authoring/scripts-assets.mdx). - **Emitted scripts resolve their own assets.** `assets: ['release/*.json']` copies the release - manifest and risk register into every target, and each emitted script reads them relative to - its own module — never relative to the shell's working directory. + manifest and risk register once into the artifact root's `assets/`, shared by every target, and + each emitted script reads them relative to its own module — never relative to the shell's + working directory. - **A failed rebuild keeps the last-good artifact.** That is the reversible walkthrough below. ## What is authored diff --git a/website/docs/en/examples/mcp-app.mdx b/website/docs/en/examples/mcp-app.mdx index 7b098c43b..8447814dc 100644 --- a/website/docs/en/examples/mcp-app.mdx +++ b/website/docs/en/examples/mcp-app.mdx @@ -14,7 +14,7 @@ to see how the surfaces fit together instead of studying one of them alone. - **Public dependencies:** `agent-bundle` (`workspace:*`), `@modelcontextprotocol/server`, `@modelcontextprotocol/ext-apps`, `zod`; `@rstest/browser`, `@rstest/core`, `@rstest/playwright`, and `playwright` for the browser-app test -- **Targets:** `portable`, `codex`, `claude` — the MCP App resource stays portable +- **Targets:** `portable`, `codex`, `claude` — the MCP App is declared for `portable` - **Credentials:** none — the eval and the fixture check read only checked-in data - **Source:** [`examples/mcp-app`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/mcp-app) @@ -27,9 +27,10 @@ to see how the surfaces fit together instead of studying one of them alone. - **The generated stdio lifecycle is not yours to write.** The build wraps that factory in the lifecycle shell: the console-to-stderr guard, signal handling, stdin-EOF exit, bounded shutdown, and the heartbeat. -- **An MCP App is a compiled resource, not a served page.** The declared app compiles to - `mcp-apps/status.html` under the portable target with a stable `resourceUri`. Codex and Claude - keep their host artifacts but not this portable App resource. +- **An MCP App is a compiled resource, not a served page.** The declared app is scoped to + `portable`, so it is compiled when that projection is selected — once, to + `mcp-apps/status.html` in the artifact root, with a stable `resourceUri` — and inlined into the + generated `status` server entry, which every selected host launches from that same root. - **Degraded evidence is first-class.** The `status` server serves immutable `compiler` and `payments-api` health records, and `payments-api` deliberately returns degraded latency. The interesting output is a failing check that is reported honestly, not a green screenshot. @@ -60,8 +61,8 @@ to see how the surfaces fit together instead of studying one of them alone. Run it and wait for the finalized session: the emitted checker resolves the packaged status fixture beside its emitted module, so it succeeds independently of the shell's working directory. -4. **Artifacts** with the portable target selected is where `mcp-apps/status.html` appears. - Before two eval runs exist, **Comparisons** deliberately shows +4. **Artifacts** shows the composite plugin root, where `mcp-apps/status.html` appears whichever + target is selected. Before two eval runs exist, **Comparisons** deliberately shows `At least two recorded runs are needed before a comparison can be aligned.` — the precise empty state, not an error. 5. **MCP playground** defaults to portable and the `status` server. Open the session, list tools, diff --git a/website/docs/en/examples/skills-starter.mdx b/website/docs/en/examples/skills-starter.mdx index 122b77773..c3b89f86d 100644 --- a/website/docs/en/examples/skills-starter.mdx +++ b/website/docs/en/examples/skills-starter.mdx @@ -21,9 +21,10 @@ recorded evidence rather than a claim. and nothing else. Every `src/skills/*/SKILL.md` directory is found by convention, so the config never lists a Skill. That is the authoring model described in [Skills](../guide/authoring/skills.mdx). -- **One source, three host layouts.** The same authored documents are lowered into the portable, - Codex, and Claude trees. The Workbench's Source and Generated views show whether a target - copied a document or adapted it. +- **One source, three host projections.** The same authored documents are lowered for the + portable, Codex, and Claude projections of one composite plugin root — `skills/` is emitted once + and every selected host reads it. The Workbench's Source and Generated views show whether a + host copied a document or adapted it. - **Deterministic eval evidence.** The eval suites run through the deterministic harness against checked-in fixtures, so a Skill's coverage is a recorded run rather than an assertion. Coverage is labeled *indirect* because a deterministic harness cannot observe host Skill activation. @@ -48,8 +49,9 @@ it is needed. health, and the next useful actions. 2. **Skills** lists `dependency-upgrade`, `incident-triage`, and `release-review`. Browse their linked checklists and report templates, and switch between Source and Generated per target. -3. **Artifacts** defaults to the Claude target. Change the target to compare the portable, Codex, - and Claude output trees and their provenance. +3. **Artifacts** shows the composite plugin root. Switching the target between portable, Codex, + and Claude changes which projection is in focus, but every choice shows the same tree — the + one directory all three hosts read — and each file's provenance. 4. **Evals** defaults to the `release-readiness` suite. Run `release-artifact-is-ready` and inspect the passing trial; it consumes only the checked-in evidence fixture. 5. To practice repair, make a reversible edit to the release policy, press **Rebuild**, and wait diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 5ba1f6f3d..5f553c693 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -299,16 +299,20 @@ in [What happens when a route throws](./mcp.mdx#what-happens-when-a-route-throws ### What is on the wire -Both shapes share the emitted `hooks/hooks.json` wiring, and both compile into a wrapper the host -invokes as `node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"` (or the host's own root token). The -unified `plugin` target also writes `hooks/hooks-cursor.json` and `hooks/.cursor.mjs` for -Cursor (`preToolUse` / `postToolUse` camelCase). Claude Code and Codex load `hooks/hooks.json` by -convention and never scan `hooks/` for other documents, so the Claude manifest carries no `hooks` -pointer: Claude Code reports a pointer at the conventional file as a duplicate hooks file -(`manifest.hooks` is for additional documents only). Each wrapper compares the envelope's -`hook_event_name` against the spelling baked for its own host (`PreToolUse` / `PostToolUse` for -Claude and Codex), so `native hook_event_name must equal postToolUse` on a Claude session means a -Cursor-built wrapper is installed under the Claude plugin root, not that Claude sends camelCase. A +Both shapes compile into a wrapper under the root `hooks/` directory that the host invokes as +`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"` (or with its own root token). A hook that only +one selected host receives compiles to `hooks/.mjs`; a hook shared by several selected hosts +compiles once per host as `hooks/..mjs`, because each wrapper bakes in its host's +event spelling — `PreToolUse` / `PostToolUse` for Claude Code and Codex, camelCase `preToolUse` / +`postToolUse` for Cursor — and each host's document points at its own wrapper. Claude Code's +document is `hooks/hooks.json`, which it loads by convention without scanning `hooks/` for other +documents, so the Claude manifest carries no `hooks` pointer: Claude Code reports a pointer at the +conventional file as a duplicate hooks file (`manifest.hooks` is for additional documents only). +Codex and Cursor manifests carry an explicit `hooks` pointer, so their documents sit beside their +manifests as `.codex-plugin/hooks.json` and `.cursor-plugin/hooks.json`. Each wrapper compares the +envelope's `hook_event_name` against the spelling baked for its own host, so +`native hook_event_name must equal postToolUse` on a Claude session means Claude's document is +invoking a Cursor wrapper, not that Claude sends camelCase. A config-declared handler runs in-process inside that wrapper. An event route with `runtime: 'shared'` instead forwards to the warm runtime living inside the generated MCP server process, so hooks share state with tools: @@ -367,6 +371,12 @@ that never clears) is reported on the server's stderr — one line per distinct at most every 30 seconds — while the server keeps standing by. A startup that fails for any other reason still fails closed, as does a stale socket the owner cannot remove. +Inside one composite root each selected host reaches that runtime through the first generated +MCP server its own MCP document lists — one process when every selected host lists the same +server, one per host otherwise. The runtime's socket is identified by the artifact alone (its +epoch and root directory, never the `targets` selection), and the invoking host rides on each +request, so whichever process owns the socket answers every selected host's wrappers. + A route with `runtime: 'standalone'` bundles its module into the wrapper itself: the same canonical identity, the same projection, no shared process state. `AB4817` refuses a route that requires the shared runtime on a target where no generated MCP entry hosts it and no diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx index 452878432..b9dd24ca8 100644 --- a/website/docs/en/guide/authoring/index.mdx +++ b/website/docs/en/guide/authoring/index.mdx @@ -38,15 +38,20 @@ identity from `agent-bundle/meta` instead of a hand-maintained version module. ## Targets -`targets` selects the artifact layouts the build emits: +`targets` selects the host projections the build lays into one composite plugin root — the +single directory at `artifact/` (or `output.distPath`) that every selected host reads: -| Target | What it emits | +| Target | What it projects into the root | | --- | --- | -| `plugin` | One multi-host bundle with `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared component directories. | -| `claude` | The Claude Code plugin layout. | -| `codex` | The Codex plugin layout. | -| `cursor` | The Cursor plugin layout. | -| `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0). Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients. | +| `claude` | The Claude Code plugin layout: `.claude-plugin/`, `hooks/hooks.json`, `.mcp.json`. | +| `codex` | The Codex plugin layout: `.codex-plugin/` with its own `hooks.json` and `mcp.json`. | +| `cursor` | The Cursor plugin layout: `.cursor-plugin/` with its own `hooks.json` and `mcp.json`. | +| `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0): root `plugin.json` and `mcp.json`. Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients. | + +Host manifests live in their own dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and +`assets/` are emitted once and shared. Omit `targets` and the build emits only the `portable` +projection. Order is irrelevant — `['codex', 'claude']` and `['claude', 'codex']` produce the same +bytes — and any other name, `plugin` included, is an unknown target (`AB4100`). The portable standard packages only Skills and MCP servers, so rules, commands, and hooks are honestly unavailable on that target rather than silently dropped. Claude Code consumes the @@ -57,7 +62,7 @@ standard only through CLI translation, which is why its dedicated target remains | Field | Owns | | --- | --- | | `plugin` | Project identity. Required. | -| `targets` | Which artifact layouts to emit. | +| `targets` | Which host projections the composite plugin root carries. | | `skills` | Explicit Skill paths or globs, replacing the `src/skills/*` convention. See [Skills](./skills.mdx). | | `hooks` | Lifecycle hook handlers, keyed by canonical event. See [Hooks](./hooks.mdx). | | `mcp` | MCP server declarations and their MCP Apps. See [MCP servers and Apps](./mcp.mdx). | @@ -106,8 +111,8 @@ host version actually honours. Two Claude Code surfaces deserve a closer look because their contracts are narrower than their names suggest: -- **`claude.lspServers`** — emitted as a plugin-root `.lsp.json` by the `claude` target and the - Claude half of `plugin`. Path tokens expand only in `command`, `args`, `env`, and +- **`claude.lspServers`** — emitted as a root `.lsp.json` whenever `claude` is among the selected + projections. Path tokens expand only in `command`, `args`, `env`, and `workspaceFolder`. agent-bundle does not include the language-server binary; install it separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not receive this configuration. diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 6c848e21d..895934c7d 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -224,12 +224,13 @@ the static MCP `inputSchema`, the generated route types, the Workbench, and ### The routed CLI inside host artifacts The package bin only reaches users who install the npm package, while hooks, Skills, and scripts -ship with the host artifact. So the build also emits the same compiled command graph into every -selected target as `/bin/.mjs` (plus `bin/-flight.mjs` when any -command renders). Every built-in target publishes the `cli` capability that admits it. The -artifact bin is a self-contained ESM module with no shebang or executable bit — run it as -`node /bin/.mjs `, exactly like `scripts/*.mjs`. Help, argv -parsing, output modes, exit codes, and signals match the package bin; the one difference is that +ship with the host artifact. So the build also emits the same compiled command graph once into +the artifact root as `bin/.mjs` (plus `bin/-flight.mjs` when any +command renders), shared by every selected host. Every built-in target publishes the `cli` +capability that admits it. The artifact bin is a self-contained ESM module with no shebang or +executable bit — run it as `node /bin/.mjs `, exactly like +`scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals match the package bin; +the one difference is that workspace-durable state without a host-supplied `AGENT_BUNDLE_PLUGIN_ROOT` anchors on the artifact root (the parent of `bin/`, the same fallback the generated MCP worker uses) instead of `$PWD/.agent-bundle/state`, so a co-installed CLI and server share one store. @@ -278,7 +279,7 @@ fabricated identity, and a release build refuses a project with no release versi ## Packaging and installers -When package outputs and at least one Claude, Codex, or Cursor host pack are built inside the +When package outputs and at least one Claude, Codex, or Cursor projection are built inside the project, the framework also emits one self-contained package-relative installer at `dist/bin/.js` — or `-install.js` when that name is taken, with a numeric suffix if both are. Declare the matching `package.json` `bin` value. Its grammar is diff --git a/website/docs/en/guide/authoring/scripts-assets.mdx b/website/docs/en/guide/authoring/scripts-assets.mdx index 88688766b..663d8d7c5 100644 --- a/website/docs/en/guide/authoring/scripts-assets.mdx +++ b/website/docs/en/guide/authoring/scripts-assets.mdx @@ -34,7 +34,7 @@ depends on the entry's extension: | Entry | Output | | --- | --- | -| JavaScript / TypeScript | Bundled to `scripts/.mjs` in every selected target artifact. | +| JavaScript / TypeScript | Bundled once to `scripts/.mjs` in the artifact root, shared by every selected host the entry reaches. | | `.sh`, `.bash`, `.py` | Copied byte-for-byte, preserving source file modes. | ### The convention @@ -94,8 +94,8 @@ It cannot be handed a browser-supplied command. ## Assets -Files under a root `assets/` directory copy byte-for-byte into every target artifact's `assets/` -directory. That convention needs no configuration. +Files under a root `assets/` directory copy byte-for-byte into the artifact root's `assets/` +directory, once, shared by every selected host. That convention needs no configuration. A top-level `assets` list **replaces** that convention with explicit entries — literal file paths, whole directories, or globs, all resolved from the project root: diff --git a/website/docs/en/guide/authoring/skills.mdx b/website/docs/en/guide/authoring/skills.mdx index e9bc1a0bb..8128a60c1 100644 --- a/website/docs/en/guide/authoring/skills.mdx +++ b/website/docs/en/guide/authoring/skills.mdx @@ -87,6 +87,12 @@ their host so they reach only that adapter: `targets: ['claude']` is `AB3006`. A Skill is emitted to every target the project selects — there is no per-Skill artifact restriction. +Every selected host reads the same composite plugin root, so `skills//SKILL.md` exists +once. A host extension that lowers the document differently for one selected host than for +another — `targets: { claude: { effort: high } }` in a `claude` + `codex` build, say — is a +fatal `AB4103` collision. Keep the emitted document identical for every selected host, or build +those hosts into separate artifacts (one `targets` entry per build). + ## Path tokens Host placeholder syntax differs — `${CLAUDE_PLUGIN_ROOT}` is not what Cursor or the portable diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 0e18f863b..f3135d4f6 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -32,7 +32,7 @@ These are contracts, not defaults: | Overview | Project identity, normalized model, and diagnostics. | | Routes | The compiled route catalog from the same compiler pass as `inspect --routes`: each route's source module, config summary, and a generated input editor; a route with a static contract names the contract's declaring module and any other routes sharing it. | | Skills | Every Skill document, including each host's lowered output. | -| Artifacts | The artifact tree with provenance and epoch comparison. | +| Artifacts | The composite plugin root — one tree, whichever selected host is in focus — with provenance and epoch comparison. | | MCP | An artifact-bound playground with the raw protocol trace, MCP App previews, and a launcher for the standalone MCP Inspector. | | Hooks | A playground that runs the emitted hook wrapper. | | Playground | A durable, ordered trace with replay and export. | @@ -149,8 +149,9 @@ agent-bundle dev proxy --root --server --target --strict` | Content-addressed proof that the emitted bytes match the manifest, plus host-tool findings where a host publishes a validator. | -| Install | `agent-bundle install --from ` | The bundle registered in Claude, Codex, or Cursor. | +| Build | `agent-bundle build` | One composite plugin root carrying every selected host projection, plus the `dist/` package build when the project declares `bin`/`lib`. | +| Validate | `agent-bundle validate --artifact --strict` | Content-addressed proof that the emitted bytes match the manifest, plus host-tool findings where a selected host publishes a validator. | +| Install | `agent-bundle install --from ` | The bundle registered in Claude, Codex, or Cursor — all three from the same root. | The build already validates the project before it writes anything, so a separate `validate` run against source is a fast pre-flight rather than a required stage. Validating the **artifact** is the interesting one, because it needs no project sources at all. -### How a target compiles +### How the root compiles -The build plans every target first, then lowers each target's outputs in at most two stages -into one staged root, published atomically once the artifact validates: +The build plans every selected host projection first and merges them by path into one tree — +two projections may share a path only when their bytes agree (`AB4103`), and a component scoped +to some hosts may not sit where another selected host would discover it (`AB4105`). It then +lowers the compiled surfaces in at most two stages into one staged root, published atomically +once the artifact validates: -1. **MCP Apps** — the browser environment, compiled through `@rsbuild/core`. Present only for a - target whose project declares App routes, and always first: the MCP entries embed its HTML. +1. **MCP Apps** — the browser environment, compiled through `@rsbuild/core`. Present only when + the project declares App routes, and always first: the MCP entries embed its HTML. 2. **Agent-host surfaces** — the routed CLI bin, bundled scripts, hook wrappers, MCP stdio entries, and each surface's react-server Flight worker, lowered together through **one Rslib - instance per target** (one Rsbuild environment per output, one Rspack multi-compiler). A - surface reaches its worker by file name at run time, so nothing orders the two within the - stage, and each surface keeps its own source evidence for the manifest. - -Both stages and the `dist/` package build compose their bundler config the same way — profile, -`tools.rsbuild`, `tools.rspack`, then the framework invariants — as described under + instance** (one Rsbuild environment per output, one Rspack multi-compiler). A surface reaches + its worker by file name at run time, so nothing orders the two within the stage, and each + surface keeps its own source evidence for the manifest. + +Compiled surfaces are built once and attributed to the **composite identity** — the selected +hosts sorted and joined by `+`, such as `claude+codex` — never once per host. Both stages and the +`dist/` package build compose their bundler config the same way — profile, `tools.rsbuild`, +`tools.rspack`, then the framework invariants — as described under [`tools`](../../reference/configuration.mdx#tools). `agent-bundle inspect --bundler` prints the -result. +result; its `distPath.root` is the artifact output itself. + +## What ships inside the root -## What ships inside a target directory +Host manifests live in their dotfolders at the root (`.claude-plugin/`, `.codex-plugin/`, +`.cursor-plugin/`, and the portable `plugin.json`), each pointing at its own hook and MCP +documents, while `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are shared and +emitted once. The full tree and the per-host document locations are in +[Targets and artifacts](../../reference/targets-artifacts.mdx). -Every built target contains a generated `INSTALL.md` written with the bundle's **real** plugin -and marketplace names — not placeholders — so the file can be followed verbatim. The multi-host -`plugin` target also carries a generated `AGENTS.md` explaining how to install it into each host. +Whenever a built-in host is selected the root carries one generated `INSTALL.md`, a section per +selected host, written with the bundle's **real** plugin and marketplace names — not placeholders +— so the file can be followed verbatim. -Claude and Codex targets always include local marketplace manifests, which is what lets their -public CLIs install the emitted directory directly. Cursor, portable, and composite targets -include a standalone `install.mjs`, because Cursor exposes marketplace management but no -non-interactive plugin install verb. +The Claude and Codex projections always include local marketplace manifests, which is what lets +their public CLIs install the root directly. Selecting `cursor` or `portable` adds a standalone +`install.mjs`, because Cursor exposes marketplace management but no non-interactive plugin +install verb. `agent-bundle.manifest.json` records every emitted file with its SHA-256, so validation compares -real bytes rather than checking that a path exists. The per-target layouts themselves are covered -in [Project structure](../start/project-structure.mdx). +real bytes rather than checking that a path exists. Builds are reproducible: two builds of one unchanged source tree emit byte-identical artifacts — -the same manifest and the same digests — whatever `--output` names and however the per-build -staging directory is named. The module identifiers the bundler writes into compiled entries -derive from the project root only, never from the staging or output directory or from any -absolute path of the building machine, so installed copies, preview packages, and -[`doctor`](./installation.mdx) comparisons see the same bytes from the same source. The -generated modules those identifiers name are served from memory under the reserved +the same manifest and the same digests — whatever `--output` names, however `targets` is ordered, +and however the per-build staging directory is named. The module identifiers the bundler writes +into compiled entries derive from the project root only, never from the staging or output +directory or from any absolute path of the building machine, so installed copies, preview +packages, and [`doctor`](./installation.mdx) comparisons see the same bytes from the same source. +The generated modules those identifiers name are served from memory under the reserved `.agent-bundle-virtual/` directory of the project root; the build refuses to compile while anything occupies that directory. @@ -71,8 +82,8 @@ occupies that directory. A project that also ships as an npm package has a second output: `dist/bin/.js` executables and a library entry, described in [Package entries](../authoring/package-entries.mdx). When -package outputs and at least one host pack are built together, the framework also emits a -package-relative installer bin — see [Host installation](./installation.mdx). +package outputs and a root with at least one built-in host are built together, the framework also +emits a package-relative installer bin — see [Host installation](./installation.mdx). `agent-bundle prepack` is the gate for that half: it runs the release build, dry-runs `npm pack` without scripts, and verifies packaged outputs, artifact hashes, bins, and versions. diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 388de02bd..8ce13ddbb 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -4,52 +4,57 @@ description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: t # Host installation -Every built target directory contains a generated `INSTALL.md` with commands that use the -bundle's real plugin and marketplace names. Every command in it is a host command (`claude plugin -…`, `codex plugin …`) or the bundled `install.mjs`, so a consumer who receives the built pack — -from npm, a marketplace checkout, or a zip — never needs the `agent-bundle` CLI to install, -reinstall, or uninstall it; `INSTALL.md` says so and marks the CLI optional wherever it is -mentioned. The framework CLI performs those same operations for developers who have it: +The composite plugin root `build` emits contains one generated `INSTALL.md` — a section per +selected host — with commands that use the bundle's real plugin and marketplace names. Every +command in it is a host command (`claude plugin …`, `codex plugin …`) or the bundled +`install.mjs`, so a consumer who receives the built root — from npm, a marketplace checkout, or +a zip — never needs the `agent-bundle` CLI to install, reinstall, or uninstall it; `INSTALL.md` +says so and marks the CLI optional wherever it is mentioned. The framework CLI performs those +same operations for developers who have it, and every host installs from the same directory: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex -npx agent-bundle install cursor --from artifact/cursor +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +npx agent-bundle install cursor --from artifact ``` -`--from` accepts either a target bundle directory or a **source-free artifact root**, as long as -the root contains the selected host's target directory. +`--from` names the **artifact root**: the directory that holds the selected host's manifest +directly (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or +`.cursor-plugin/plugin.json`). It needs no project sources, and there is no `/` +lookup — a root that lacks the host's manifest is refused (`AB7001`). Builds write no +`artifact/` subdirectory to point at; the root is the bundle. ## What each host accepts | Host | Mechanism | Scopes | | --- | --- | --- | -| Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the unified `plugin` bundle's `hooks/hooks-cursor.json` is never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | -| Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. | `user` | -| Cursor | Copies the bundle into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hooks document the plugin manifest names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target), so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | +| Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` and `.mcp.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the Codex and Cursor documents beside their own manifests in the same root are never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | +| Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. `.codex-plugin/plugin.json` points at its own `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json`, so Codex never falls back to discovering another host's documents in the root. | `user` | +| Cursor | Copies the root into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hook and MCP documents `.cursor-plugin/plugin.json` names — `.cursor-plugin/hooks.json` and `.cursor-plugin/mcp.json`, beside the manifest — so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | -Because Claude and Codex targets always ship local marketplace manifests, their public CLIs can -install the emitted directory directly. The installer fails with a typed diagnostic when the +Because the Claude and Codex projections always ship local marketplace manifests, their public +CLIs can install the root directly. The installer fails with a typed diagnostic when the selected host binary is unavailable, rather than reporting a success it did not achieve. Host installation diagnostics are the `AB700x` family: bundle identity, host availability, scope, command failure, and collision checks. ## The standalone installer -Cursor, portable, and composite targets include an `install.mjs` that copies the bundle into -`~/.cursor/plugins/local/` without overwriting collisions: +A root built with `cursor` or `portable` among its `targets` includes an `install.mjs` that copies +the root into `~/.cursor/plugins/local/` without overwriting collisions: ```sh -npx agent-bundle install cursor --from artifact/cursor -# or, from the emitted target directory: +npx agent-bundle install cursor --from artifact +# or, from the root itself: node ./install.mjs ``` Its staged copy is idempotent for identical content and refuses version or content collisions. It never invokes `sudo` and never changes `PATH`. -For a `portable` (Agent Plugins 1.0.0) bundle the installer also expands the standard's -placeholders in the Cursor copy of `mcp.json`, because Cursor 3.18.25 does not: it leaves +For a root Cursor loads as an Agent Plugins 1.0.0 pack — `portable` selected without `cursor`, +so the root `plugin.json` is the only manifest Cursor finds — the installer also expands the +standard's placeholders in the Cursor copy of `mcp.json`, because Cursor 3.18.25 does not: it leaves `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` unexpanded in `args`, `env` values, and `cwd`, provides no `PLUGIN_ROOT` / `PLUGIN_DATA` variables (spec §9.1), defaults an omitted `cwd` to the home directory, and resolves plugin-relative `./` commands against the workspace folder (spec §7.2.1), @@ -59,16 +64,16 @@ plugin-root `cwd`, resolved `./` commands, and `PLUGIN_ROOT` / `PLUGIN_DATA` in server's environment. The bundle itself is untouched and stays spec-conformant for other Agent Plugins clients; the pre-expansion document is kept in the install receipt (`cursorExpansion`), and `agent-bundle doctor --host cursor` proves the expansion with `AB7326`. Nothing here is -Cursor behaviour: the provenance is `derived`, and a `cursor`-target bundle is never rewritten. +Cursor behaviour: the provenance is `derived`, and a root that carries `.cursor-plugin/plugin.json` +— `cursor` selected — is copied unchanged, because Cursor loads that manifest instead. -Artifact validation rejects a built-in target -whose required install surface is missing, so a bundle cannot ship without the installer it -promises. +Artifact validation rejects a root whose required install surface is missing, so a bundle cannot +ship without the installer it promises. ## The package-relative installer bin -When package outputs ship one of those host packs, the build also emits a package-relative -installer bin. It uses the plugin name when no configured bin claims it, and +When package outputs ship beside a root with a built-in host selected, the build also emits a +package-relative installer bin. It uses the plugin name when no configured bin claims it, and `-install` otherwise (with a numeric suffix if both are taken). Map that name to the generated `dist/bin/*.js` file in `package.json`; consumers then run: @@ -107,8 +112,8 @@ reinstalling because `plugin update` is version-gated; Codex runs `codex plugin A plugin configured through environment variables — session cookies, API tokens, a host name — is configured at the install, not in the artifact: manifest `env` is compiled, content-addressed, and re-emitted on every build, which is the wrong place for a secret. Instead, put a `.env` file at the -plugin root of the installed copy (the directory that holds `.claude-plugin/`, `.cursor-plugin/`, -or the Codex plugin manifest, beside `mcp/`, `hooks/`, and `bin/`), optionally with a `.env.local` +plugin root of the installed copy (the directory that holds `.claude-plugin/`, `.codex-plugin/`, +or `.cursor-plugin/`, beside `mcp/`, `hooks/`, and `bin/`), optionally with a `.env.local` beside it: ```sh @@ -164,13 +169,13 @@ variables each declares (`AB7331`, informational) — never a name or a value. ```sh # exact paths, nothing changes -npx agent-bundle uninstall cursor --from artifact/cursor --plan +npx agent-bundle uninstall cursor --from artifact --plan # receipt-owned files; state/ kept -npx agent-bundle uninstall cursor --from artifact/cursor +npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove -npx agent-bundle uninstall claude --from artifact/claude -npx agent-bundle uninstall codex --from artifact/codex --purge-data --confirm-purge -node artifact/cursor/install.mjs --uninstall [--mode marketplace] +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 @@ -236,7 +241,7 @@ registration proof and the rows' `errors` already hold that verdict. | `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). | | `AB7326` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. | | `AB7327` | warning | `doctor --from` compared an installed Claude copy whose `claude plugin list --json` row reports `enabled: false`; the recovery is `claude plugin enable @ [--scope ]`. | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names — `.cursor-plugin/hooks.json` — so Claude Code's `hooks/hooks.json` in the same root is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | ## Next diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index bd63e3d2d..1453fb40b 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -58,20 +58,21 @@ diagnostic involved — see the [CLI exit codes](../../reference/cli.mdx#exit-co ## Claude Code strict validation -When Claude Code is on `PATH`, artifact validation runs the installed `claude plugin validate` -for emitted `claude` and unified `plugin` targets. `agent-bundle build` runs the same checks over -the artifact it just wrote (`--no-host-validation` skips them; `--strict` promotes host warnings to -build errors), and `agent-bundle doctor --host claude --from ` runs them over the -bundle and over every installed copy Claude lists. Claude Code decides what it checks from the -manifest it is pointed at, and a bundle directory that holds both `.claude-plugin/plugin.json` and -`.claude-plugin/marketplace.json` is treated as a marketplace, whose validation never opens the -plugin's hook, skill, agent, or command files. Agent Bundle therefore runs two commands: +Host validation runs only for the selected projections, over the composite root. When Claude Code +is on `PATH` and `claude` is among the selected projections, artifact validation runs the installed +`claude plugin validate` over the root. `agent-bundle build` runs the same checks over the root it +just wrote (`--no-host-validation` skips them; `--strict` promotes host warnings to build errors), +and `agent-bundle doctor --host claude --from ` runs them over the root and over every +installed copy Claude lists. Claude Code decides what it checks from the manifest it is pointed +at, and a root that holds both `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` +is treated as a marketplace, whose validation never opens the plugin's hook, skill, agent, or +command files. Agent Bundle therefore runs two commands: ```sh # plugin.json, hooks/hooks.json, skills/, agents/, commands/ -claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/plugin.json --strict # marketplace schema, plugin names, source paths -claude plugin validate /.claude-plugin/marketplace.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict ``` - Host **errors** become Agent Bundle errors (`AB6021`). @@ -84,14 +85,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: @@ -115,10 +116,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/`, @@ -126,11 +127,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 @@ -151,7 +152,7 @@ schema generators as a **drift signal**, never as a substitute plugin contract. | Code | Severity | Meaning | | --- | --- | --- | | `AB6026` | info | Every Cursor report states that Cursor publishes no plugin-validate verb, and names the vendored schema pin used locally. | -| `AB6027` | error | A required generated Cursor document is missing, or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target, or an inline object), falling back to `hooks/hooks.json` only when the field is absent; a named file that is missing or leaves the plugin root is an error. | +| `AB6027` | error | A required generated Cursor document is missing, or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names (`.cursor-plugin/hooks.json`, beside the manifest, or an inline object), falling back to `hooks/hooks.json` only when the field is absent; a named file that is missing or leaves the plugin root is an error. | | `AB6028` | error | Generated bytes violate pinned Cursor loader evidence: manifest-candidate precedence selects a fallback manifest, a symlink resolves outside the bundle, or `CURSOR_PLUGIN_ROOT` appears outside loader-substituted fields. | | `AB6029` | info / warning | The Cursor Agent version probe is unavailable (`ENOENT`, info) or could not complete (warning). Local pinned-schema validation still runs. | | `AB6030` | info | The Codex CLI is unavailable, or the installed release publishes no plugin validation command. | diff --git a/website/docs/en/guide/start/index.mdx b/website/docs/en/guide/start/index.mdx index 277279ea3..f3ac12100 100644 --- a/website/docs/en/guide/start/index.mdx +++ b/website/docs/en/guide/start/index.mdx @@ -1,13 +1,13 @@ --- -description: 'agent-bundle compiles one typed config into installable Claude Code, Codex, Cursor, and portable agent plugin artifacts.' +description: 'agent-bundle compiles one typed config into one installable plugin root for Claude Code, Codex, Cursor, and the portable Agent Plugins format.' --- # Introduction agent-bundle compiles an agent plugin — Skills, hooks, MCP servers, and scripts, described -by one typed config — into installable artifacts for Claude Code, Codex, and Cursor, plus a -portable layout. You write the plugin once; the compiler emits each host's manifests and -wrappers. +by one typed config — into one installable plugin root for Claude Code, Codex, and Cursor, plus +the portable Agent Plugins format. You write the plugin once; the compiler emits each host's +manifests and wrappers into that root. Node.js 22.19 or later is required. @@ -25,11 +25,12 @@ source tree: npx agent-bundle build --root . ``` -With `targets: ['plugin']`, that single command emits one multi-host bundle at `artifact/plugin/`: -`.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared `skills/`, -`hooks/`, `mcp/`, and `scripts/` directories. The bundle's generated `AGENTS.md` explains how to -install it into each host. Per-host layouts are available as the `claude`, `codex`, `cursor`, -and `portable` targets. +That single command emits one composite plugin root at `artifact/`: the manifests of every +selected host (`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, the portable +`plugin.json`) over shared `skills/`, `hooks/`, `mcp/`, `bin/`, and `scripts/` directories, +plus one `INSTALL.md`. `targets` selects which host projections the root carries — `claude`, +`codex`, `cursor`, `portable`; omitted, it selects `portable` alone — and every host installs +the same directory. ## What the config owns @@ -40,7 +41,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, // [!code highlight] - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -50,7 +51,7 @@ export default defineConfig({ The same config also owns the npm package build — no second bundler config, no bin shims, no hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a -library output alongside the host artifacts. An MCP entry that default-exports a server factory +library output beside the plugin root. An MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle. `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. diff --git a/website/docs/en/guide/start/installation.mdx b/website/docs/en/guide/start/installation.mdx index eed016a0a..ee445679a 100644 --- a/website/docs/en/guide/start/installation.mdx +++ b/website/docs/en/guide/start/installation.mdx @@ -82,7 +82,8 @@ npx agent-bundle --help npx agent-bundle validate --root . ``` -`validate` checks project source. Once you have built something, `validate --artifact ` -checks the built artifact instead, with no project sources required. +`validate` checks project source. Once you have built something, `validate --artifact artifact` +checks the built plugin root instead — the one directory every selected host installs — with no +project sources required. Next: [Quick start](./quick-start.mdx). diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index 7f33644af..692fdf607 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -14,7 +14,7 @@ silent, and config always wins when both describe the same thing. my-plugin/ ├── agent-bundle.config.ts # project identity, targets, and policy ├── package.json # authoritative release version and package identity -├── assets/ # static files copied byte-for-byte into every artifact +├── assets/ # static files copied byte-for-byte into the artifact root └── src/ ├── skills//SKILL.md # one Skill per directory, with its own resources ├── commands/*.md # host slash-command documents @@ -46,7 +46,7 @@ my-plugin/ | `src/mcp/.ts` | Stdio entry for a declared MCP server that names no `entry`, `command`, or `url`. | Declare `entry` explicitly. | | `src/mcp//{tools,resources,prompts}/*` | Generated MCP server routes. The path supplies identity; each module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote`. | | `src/mcp//apps/*` | Browser MCP App entries compiled to self-contained HTML and registered on the generated server. Static `config.resourceUri` is required. | Use a custom server, or prefix the file with `_`. | -| `src/scripts/.ts` | A plain script compiled to `scripts/.mjs` in every selected target. Nested modules are a hard error (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry. | +| `src/scripts/.ts` | A plain script compiled once to `scripts/.mjs` at the artifact root, shared by every selected host. Nested modules are a hard error (`AB4808`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry. | | `src/scripts/.tsx` | A rendered script: the async default component receives `argv` and `signal` and renders through the Agent renderer with the CLI output contract. | Rename to `.ts`, prefix a path segment with `_`, or claim the file. | | `src/cli.ts` | A package bin named after `plugin.name`. | `bin: false` | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one executable. Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Supersedes the `src/cli.ts` convention. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_`. | @@ -55,7 +55,7 @@ my-plugin/ | `src/mcp//layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.` to a non-generated mode. | | `src/state.ts` | Project state: default-exports `defineState`. Generated MCP, routed-CLI, and rendered-script request scopes mount it. | `state: false`, or rename to `_state.ts`. | | `src/providers/.{ts,tsx}` | A request-context provider mounted at `providers.` on the request handle; its factory receives the request's identity, lineage, and read-only state/notice handles. | Prefix the file with `_`. | -| `assets/` | Static resources copied byte-for-byte into every target artifact's `assets/` directory. | Declare a top-level `assets` list instead. | +| `assets/` | Static resources copied byte-for-byte into the artifact root's `assets/` directory, once for every selected host. | Declare a top-level `assets` list instead. | Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. Discovered entries carry `provenance.kind: 'conventional'` in the @@ -101,38 +101,57 @@ shadows it. The `bin: false` and `lib: false` opt-outs stay silent. `agent-bundle build` writes two independent things. -### Host artifacts +### The composite plugin root -One directory per selected target under the artifact root. The CLI defaults that root to +One directory at the artifact output, whatever `targets` selects. The CLI defaults that output to `artifact/`, so it never collides with the package build below; `output.distPath` or `--output` -moves it: +moves it. Every selected host reads this directory as its plugin root — there is no +`artifact//` partition — so `targets` decides which host manifests appear at the root, not +where anything lands. Omit `targets` and the root carries the `portable` projection alone. ```text artifact/ -├── agent-bundle.manifest.json # every emitted file with its SHA-256 -└── plugin/ # targets: ['plugin'] — one multi-host bundle - ├── .claude-plugin/ - ├── .codex-plugin/ - ├── .cursor-plugin/ - ├── bin/.mjs # the routed CLI, when src/cli/** exists - ├── skills/ - ├── hooks/ - ├── mcp/ - ├── scripts/ - ├── assets/ - ├── AGENTS.md - └── INSTALL.md +├── .claude-plugin/plugin.json # claude, with marketplace.json beside it +├── .codex-plugin/plugin.json # codex, with hooks.json and mcp.json beside it +├── .agents/plugins/marketplace.json # codex marketplace +├── .cursor-plugin/plugin.json # cursor, with hooks.json and mcp.json beside it +├── .mcp.json # claude MCP document +├── plugin.json # portable (Agent Plugins) manifest +├── mcp.json # portable MCP document +├── hooks/ +│ ├── hooks.json # claude hook document +│ ├── .mjs # wrapper for a hook one selected host reaches +│ ├── ..mjs # one wrapper per host for a hook several reach +│ └── hooks-flight.mjs +├── mcp/mcp--.mjs # compiled MCP entries, emitted once +├── bin/.mjs # the routed CLI, when src/cli/** exists +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # components, emitted once +├── INSTALL.md # one section per selected host +├── install.mjs # when cursor or portable is selected +├── agent-bundle.manifest.json # every emitted file with its SHA-256 +└── agent-bundle.hooks.json # hook index over the selected hosts ``` -Per-host layouts are available as the `claude`, `codex`, `cursor`, and `portable` targets. -`agent-bundle.manifest.json` sits at the artifact root beside the target directories and records -every emitted file with its SHA-256, so artifact validation is content-addressed rather than a guess. - -`output.distPath` moves the artifact root; it never changes the framework-owned layout inside -each target. Precedence is the CLI `--output`, then `output.distPath`, then the default — -`artifact` for `agent-bundle build`, which also emits the package build, and `dist` for the -programmatic `build()` without `packageOutputs`. Values must be non-empty, project-root-contained -relative POSIX paths. +Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and +`assets/` are emitted once and shared. Hook and MCP documents appear when the project declares +hooks or MCP servers — plus one empty Codex or Cursor document whenever another selected host +claims the conventional `hooks/hooks.json`, `.mcp.json`, or `mcp.json` path, so that host's folder discovery +never loads the other host's file — and `bin/` only when it has a routed CLI. Two selected hosts that +would write the same path with different bytes cannot share the root, and the build fails with +`AB4103`; a command or rule scoped to some of the selected hosts but sitting in a +directory another selected host scans is `AB4105`. Both recover by making the component +identical for every selected host, or by building those hosts into separate artifacts. + +`agent-bundle.manifest.json` records every emitted file with its SHA-256, so artifact validation +is content-addressed rather than a guess. Compiled surfaces are attributed to the composite +identity — the selected hosts sorted and joined with `+`, such as `claude+codex` — and the order +of `targets` never changes the output. + +`output.distPath` moves the root; it never changes the framework-owned layout inside it. +Precedence is the CLI `--output`, then `output.distPath`, then the default — `artifact` for +`agent-bundle build`, which also emits the package build, and `dist` for the programmatic +`build()` without `packageOutputs`. Values must be non-empty, project-root-contained relative +POSIX paths. ### The npm package build diff --git a/website/docs/en/guide/start/quick-start.mdx b/website/docs/en/guide/start/quick-start.mdx index 1cf94ba34..3a4ee19a2 100644 --- a/website/docs/en/guide/start/quick-start.mdx +++ b/website/docs/en/guide/start/quick-start.mdx @@ -57,7 +57,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' } } }, @@ -76,19 +76,20 @@ export default defineConfig({ }); ``` -The release version comes from `package.json`. A `plugin.version` field still works as a -deprecated compatibility axis, but a value that disagrees with `package.json` reports the -`AB4008` warning. +`targets` selects the host projections the one artifact root carries — `claude`, `codex`, +`cursor`, `portable`; omit it and the root carries `portable` alone. The release version comes +from `package.json`. A `plugin.version` field still works as a deprecated compatibility axis, but +a value that disagrees with `package.json` reports the `AB4008` warning. ## Build, or work interactively ```sh -npx agent-bundle build --root . # write installable artifacts to artifact/ -npx agent-bundle dev --root . # local workbench with live rebuilds +npx agent-bundle build --root . # write the composite plugin root to artifact/ +npx agent-bundle dev --root . # local workbench with live rebuilds ``` -`build` validates the project and writes the artifact, plus the `bin`/`lib` package build when -declared. `dev` serves the loopback developer Workbench and rebuilds as inputs change: project +`build` validates the project and writes the artifact root, plus the `bin`/`lib` package build +when declared. `dev` serves the loopback developer Workbench and rebuilds as inputs change: project overview and diagnostics, Skill documents, the artifact tree with provenance and epoch comparison, an artifact-bound MCP playground with the raw protocol trace, a hook playground that runs the emitted wrapper, and eval runs. @@ -96,7 +97,7 @@ runs the emitted wrapper, and eval runs. ## Inspect what the compiler decided ```sh -npx agent-bundle inspect --root . # normalized config and per-target plans +npx agent-bundle inspect --root . # normalized config and host projection plans npx agent-bundle inspect --root . --skills # add the skill focus npx agent-bundle validate --root . # check project source ``` @@ -106,22 +107,24 @@ that a convention was actually picked up. ## Install the result -Every built target directory contains a generated `INSTALL.md` with commands that use the -bundle's real plugin and marketplace names. With the `portable`, `codex`, and `claude` targets -built above, the host installs are: +The artifact root carries one generated `INSTALL.md`, a section per selected host, with commands +that use the bundle's real plugin and marketplace names. Every host installs the same directory, +so `--from` always names the root. With the `portable`, `codex`, and `claude` targets built +above, the host installs are: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +node artifact/install.mjs # the portable pack, via its generated installer ``` -Add `cursor` to `targets` to build `artifact/cursor`, and `npx agent-bundle install cursor --from -artifact/cursor` installs it the same way. A `plugin` build has one `INSTALL.md` covering every host. +Add `cursor` to `targets` and the same root gains `.cursor-plugin/`; `npx agent-bundle install +cursor --from artifact` installs it the same way. For an install-free development loop against Claude Code: ```sh -claude --plugin-dir artifact/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` ## Next steps diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index cb9f085fb..41352b0a5 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -1,12 +1,12 @@ --- pageType: home -description: 'Compile skills, hooks, MCP servers, and scripts from one typed config into installable Claude Code, Codex, and Cursor artifacts.' +description: 'Compile skills, hooks, MCP servers, and scripts from one typed config into one installable plugin root for Claude Code, Codex, and Cursor.' titleSuffix: ' - Agent plugin compiler for Claude Code, Codex, and Cursor' hero: name: agent-bundle text: One typed config, every agent host - tagline: Describe skills, hooks, MCP servers, and scripts once. Compile installable artifacts for Claude Code, Codex, and Cursor. + tagline: Describe skills, hooks, MCP servers, and scripts once. Compile one installable plugin root for Claude Code, Codex, and Cursor. image: src: /logo.svg alt: agent-bundle logo @@ -60,20 +60,21 @@ features: link: /guide/development/evaluations span: 4 - icon: 📦 - title: Every target ships on its own - details: A built target directory is the unit you install — it carries its host manifests and a generated INSTALL.md. The artifact root beside it holds agent-bundle.manifest.json, the SHA-256 record that validation, MCP, hooks, and evals read. + title: One root, every host + details: The artifact root is the unit you install — every selected host's manifest sits in its own dotfolder over shared Skills, hooks, and MCP entries, beside a generated INSTALL.md and agent-bundle.manifest.json, the SHA-256 record that validation, MCP, hooks, and evals read. link: /guide/distribution/ span: 4 --- -import { Steps, Tab, Tabs } from '@rspress/core/theme'; +import { Steps } from '@rspress/core/theme'; ## What you write, and what you get -The input is one config file and a conventional `src/` tree. The output is one artifact root -holding a ready-to-install directory per host — each with its own host manifests, generated -wrappers, and installation instructions written with the bundle's real names — plus the root -`agent-bundle.manifest.json` that the whole artifact is validated against. +The input is one config file and a conventional `src/` tree. The output is one composite plugin +root: every selected host's manifest in its own dotfolder over shared component directories, +generated wrappers, installation instructions written with the bundle's real names, and the +`agent-bundle.manifest.json` the whole artifact is validated against. Every host installs that +same directory.
@@ -115,71 +116,50 @@ declaration, because a handler has to be bound to an event. ### The compiler emits - - - -```text title="artifact/claude/" -artifact/claude/ +```text title="artifact/" +artifact/ ├── .claude-plugin/ -│ ├── plugin.json +│ ├── plugin.json # Claude Code manifest │ └── marketplace.json -├── .mcp.json -├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -└── INSTALL.md -``` - - - - -```text title="artifact/codex/" -artifact/codex/ -├── .codex-plugin/plugin.json -├── .agents/plugins/marketplace.json -├── .mcp.json +├── .codex-plugin/ +│ ├── plugin.json # Codex manifest +│ ├── hooks.json # Codex hook document +│ └── mcp.json # Codex MCP document +├── .agents/plugins/marketplace.json # Codex marketplace +├── .mcp.json # Claude Code MCP document +├── plugin.json # portable (Agent Plugins) manifest +├── mcp.json # portable MCP document ├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs +│ ├── hooks.json # Claude Code hook document +│ ├── session-start-….claude.mjs # one wrapper per host the hook reaches +│ ├── session-start-….codex.mjs +│ └── hooks-flight.mjs +├── mcp/ +│ ├── mcp-status-….mjs # compiled once, shared by every host +│ └── mcp-status-…-flight.mjs ├── scripts/check-service.mjs ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md -└── INSTALL.md +├── INSTALL.md # one section per selected host +├── install.mjs # installer for portable and Cursor +├── agent-bundle.manifest.json # every emitted file with its SHA-256 +└── agent-bundle.hooks.json # hook index over the selected hosts ``` - - - -```text title="artifact/portable/" -artifact/portable/ -├── plugin.json -├── mcp.json -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -├── install.mjs -└── INSTALL.md -``` +Every selected host reads this one directory as its plugin root: Claude Code finds +`.claude-plugin/`, `hooks/hooks.json`, and `.mcp.json`; Codex finds `.codex-plugin/` and the +marketplace under `.agents/`; Agent Plugins clients find `plugin.json` and `mcp.json`. Skills, +compiled MCP entries, and scripts are emitted once, never copied per host. The Agent Plugins open standard has no hook surface. The hook above declares no `targets`, so it -inherits only the selected targets that support hooks and `portable` is skipped here without a -diagnostic. Naming it explicitly — `targets: ['portable']` — is `AB4204` at build time instead. +inherits only the selected targets that support hooks — `claude` and `codex` — and is compiled +once per host it reaches, which is why two wrappers appear. `portable` is skipped without a +diagnostic; naming it explicitly — `targets: ['portable']` — is `AB4204` at build time instead. - - - -Generated wrapper names end in a short digest of the declaration they were compiled from, not of -their contents. `artifact/agent-bundle.manifest.json` records every emitted file with its SHA-256, so -later validation compares real bytes rather than checking that a path exists. +Generated wrapper names carry a short digest of the declaration they were compiled from, not of +their contents. `agent-bundle.manifest.json` records every emitted file with its SHA-256, so later +validation compares real bytes rather than checking that a path exists.
@@ -208,27 +188,33 @@ from route-unit tests up to a bundle installed through the real host CLI — and ### Ship -`agent-bundle build` validates the project and writes one directory per target. +`agent-bundle build` validates the project and writes one composite plugin root. [Validation](/guide/distribution/validation) checks the artifact against its manifest, and -[installation](/guide/distribution/installation) uses each host's own install path. +[installation](/guide/distribution/installation) points each host's own install path at that +root. ## One source, every host -| Target | What it emits | Installed with | +`targets` selects which host projections the one root carries; omit it and the root carries +`portable` alone. Every row below reads the same directory — only the manifest a host looks for +and the install verb differ. + +| Target | What it adds to the root | Installed with | | --- | --- | --- | -| `claude` | The Claude Code plugin layout, with plugin and local marketplace manifests. | `claude plugin marketplace add` and `claude plugin install`, or `agent-bundle install claude`. | -| `codex` | The Codex plugin layout, with plugin and local marketplace manifests. | `codex plugin marketplace add` and `codex plugin add`, or `agent-bundle install codex`. | -| `cursor` | The Cursor plugin layout. | The generated `install.mjs`, or `agent-bundle install cursor`. | -| `portable` | The [Agent Plugins](https://agent-plugins.org) open standard — Skills and MCP servers — read natively by Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT. | The generated `install.mjs`. | -| `plugin` | One multi-host bundle carrying the Claude, Codex, and Cursor manifests over shared component directories. | `install.mjs` or any of the host CLIs. | +| `claude` | `.claude-plugin/plugin.json` and a local `marketplace.json`, plus `hooks/hooks.json` and `.mcp.json`. | `claude plugin marketplace add` and `claude plugin install`, or `agent-bundle install claude --from artifact`. | +| `codex` | `.codex-plugin/plugin.json` with its `hooks.json` and `mcp.json`, plus `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` and `codex plugin add`, or `agent-bundle install codex --from artifact`. | +| `cursor` | `.cursor-plugin/plugin.json` with its `hooks.json` and `mcp.json`. | The generated `install.mjs`, or `agent-bundle install cursor --from artifact`. | +| `portable` | The [Agent Plugins](https://agent-plugins.org) open standard — `plugin.json` and `mcp.json` over Skills and MCP servers — read natively by Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT. | The generated `install.mjs`. | Hosts differ in what they can load, so the compiler says so at build time: a surface you select for a target that cannot express it is a reported diagnostic, never a quiet omission. The one deliberate exception is a hook with no `targets` of its own, which inherits only the hosts that -support hooks — as the `portable` tab above shows — rather than failing. Every diagnostic has a -stable `AB` code documented in the [diagnostics reference](/reference/diagnostics). +support hooks — as the tree above shows — rather than failing. Two selected hosts that would +write the same path with different bytes cannot share one root, and the build says so with +`AB4103`. Every diagnostic has a stable `AB` code documented in the +[diagnostics reference](/reference/diagnostics). ## Start here diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 4c644ddaa..c6326c88b 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -37,12 +37,12 @@ npx agent-bundle --version | `--root ` | `process.cwd()` | Project root. | | `--config ` | — | Configuration file relative to `--root`. | | `--mode ` | `production` | Configuration mode. | -| `--target ` | all configured | Target to select. Repeatable. | +| `--target ` | config `targets`, else `portable` | Host projection to select: `claude`, `codex`, `cursor`, or `portable`. Repeatable; the selection replaces the configured `targets` for this run, and every selected host lands in the one composite root whatever the order. Any other name, `plugin` included, is `AB4100`. | | `--json` | off | Write one machine-readable JSON document. | **Artifact commands** — `mcp list`, `mcp invoke`, `mcp run`, `serve-app`, `hooks list`, `hooks simulate`: the same `--root`, `--config`, `--mode`, and `--json`, plus `--artifact ` to use exactly -one built artifact and `--target ` to select the artifact target. `--target` is +one built artifact and `--target ` to select the host projection. `--target` is **required** for `mcp list`, `mcp invoke`, `mcp run`, and `hooks simulate`; `serve-app` defaults it to `portable` and takes no `--json`. @@ -119,32 +119,33 @@ Both take the source option group plus: | Option | Default | Meaning | | --- | --- | --- | -| `--output ` | config `output.distPath`, else `artifact` | Artifact output path relative to `--root`. `dist` belongs to the package build. | +| `--output ` | config `output.distPath`, else `artifact` | The composite plugin root, relative to `--root`: one directory every selected host reads, with no per-host subdirectory beneath it. `dist` belongs to the package build. | -Both build package outputs when the project declares `bin` or `lib`. +Both build package outputs when the project declares `bin` or `lib`. The layout of the root is +in [Targets and artifacts](./targets-artifacts.mdx). `build` alone also takes: | Option | Default | Meaning | | --- | --- | --- | -| `--host-validation` / `--no-host-validation` | on | After the artifact is written, run the installed Claude Code developer validator and load check over 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 `claude` is among the selected projections — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. | | `--strict` | off | Promote host-tool warnings to errors. | -The JSON document carries the reports under `hostValidation`, one per validated target, each +The JSON document carries the reports under `hostValidation`, one per validated projection, each with a `load` verdict (`loaded`, `refused`, `unregistered`, or `failed`) when the load check ran. Programmatic `build()` calls never run host validation unless `hostValidation: true` is passed. ## install ```sh -agent-bundle install [--from ] [--scope ] [--mode ] \ +agent-bundle install [--from ] [--scope ] [--mode ] \ [--replace] [--json] ``` | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | Target bundle directory or artifact root. | +| `--from ` | `process.cwd()` | The artifact root. The host's manifest must sit directly under it (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or `.cursor-plugin/plugin.json`); there is no `/` lookup, and every host installs from the same directory (`AB7001` when the manifest is absent). | | `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Codex and Cursor are user-scoped. | | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | @@ -161,14 +162,14 @@ marketplace mode — that `uninstall` and `doctor` consume. ## uninstall ```sh -agent-bundle uninstall [--from ] [--scope ] [--mode ] \ +agent-bundle uninstall [--from ] [--scope ] [--mode ] \ [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json] ``` | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | Bundle directory or artifact root that identifies the plugin (name, version, marketplace). | +| `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. | @@ -197,7 +198,7 @@ cached tree and has no keep-data option). The package-relative installer bin acc | Option | Default | Meaning | | --- | --- | --- | | `--host ` | all supported | Host to inspect. Repeatable; `claude`, `codex`, or `cursor`. | -| `--from ` | — | Target bundle directory or artifact root to compare against. | +| `--from ` | — | The artifact root to compare against; each inspected host's manifest must sit directly under it. | | `--json` | off | Write one machine-readable JSON document. | Doctor exits `1` when it reports any error diagnostic, and never modifies anything. With `--from`, @@ -228,7 +229,7 @@ from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not co | Option | Default | Meaning | | --- | --- | --- | | `--artifact ` | — | Validate exactly this built artifact instead of project source. | -| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. For `claude` and `plugin` targets this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | +| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools over the root for the selected projections that have one. When `claude` is selected this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | | `--strict` | off | Promote host-tool warnings to errors. | ## eval @@ -251,7 +252,7 @@ from `eval`. | Flag | Focus | | --- | --- | -| `--bundler` | The synthesized bundler configuration, lowered as the production build lowers it regardless of `NODE_ENV`. | +| `--bundler` | The synthesized bundler configuration, lowered as the production build lowers it regardless of `NODE_ENV`. Its `distPath.root` is the artifact output, ``, because every selected projection compiles into the one composite root. | | `--hooks` | Hooks. | | `--routes` | The compiled route graph. | | `--skills` | Skills. | diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 2bec05454..445b02a98 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -87,6 +87,15 @@ build with no version anywhere is refused with `AB4013` rather than shipping the `0.0.0-dev.` development fallback. The field remains only for compatibility and will be removed under the normal breaking-change policy. +## targets + +The host projections `build` lays into the composite plugin root: any combination of `claude`, +`codex`, `cursor`, and `portable`. Order is irrelevant — the selection is sorted by name, so +`['codex', 'claude']` and `['claude', 'codex']` build the same bytes. Omit the key and only the +`portable` projection is emitted. `--target` on the command line replaces the configured list for +one run. Any other name, `plugin` included, is an unknown target (`AB4100`). What each projection +places in the root is in [Targets and artifacts](./targets-artifacts.mdx). + ## hooks Keys are the seven canonical events: `sessionStart`, `beforeTool`, `afterTool`, `stop`, @@ -102,7 +111,8 @@ without a reason fails. ## output and runtime -`output.distPath` is the artifact output directory relative to the project root. The CLI +`output.distPath` is the artifact output directory relative to the project root — the composite +plugin root every selected host reads, with no per-host subdirectory beneath it. The CLI (`build`, `prepack`, `dev`) defaults it to `artifact`, because the CLI also runs the package build and that owns `dist/`; the programmatic `build()` defaults to `dist` unless `packageOutputs: true`. The per-invocation `--output` flag wins, but is subject to the same project-root diff --git a/website/docs/en/reference/index.mdx b/website/docs/en/reference/index.mdx index 182300e94..9e82f9de9 100644 --- a/website/docs/en/reference/index.mdx +++ b/website/docs/en/reference/index.mdx @@ -15,7 +15,7 @@ only the contract. | --- | --- | | [CLI](./cli.mdx) | Every command, argument, option, default, and exit code. | | [Configuration](./configuration.mdx) | Every `agent-bundle.config.ts` field, its type, and its validation rule. | -| [Targets and artifacts](./targets-artifacts.mdx) | What each target emits, and the artifact manifest contract. | +| [Targets and artifacts](./targets-artifacts.mdx) | The composite plugin root, what each host projection adds to it, and the artifact manifest contract. | | [Host capability matrix](./hosts.md) | The pinned per-host capability tables: versions, manifests, install surfaces, path tokens, MCP transports, plugin components. Generated at build time. | | [Event and hook matrix](./events.md) | Canonical events to native events per host, tool selectors to native matchers, deferred native events. Generated at build time. | | [Notice delivery matrix](./notices.md) | Which notice channels each host supports and why the rest are unavailable. Generated at build time. | diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index bcc06e7fa..5cb3146ab 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -13,8 +13,9 @@ The compiler and the Workbench need **Node.js 22.19 or later**; generated execut ## Host path tokens -Generated documents reference the plugin root through the token each host substitutes, and the -compiler writes the right spelling per target rather than assuming one: +Generated documents reference the plugin root — the composite root every selected host loads — +through the token each host substitutes, and the compiler writes the right spelling into each +host's own documents rather than assuming one: | Host | Plugin root | Plugin data | | --- | --- | --- | @@ -38,10 +39,9 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | Variable | Read by | Meaning | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | +| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/` — and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | -| `AGENT_BUNDLE_HOOK_HOST` | Generated hook wrappers | Pins the declared host explicitly instead of detecting it. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench hook playground sets it. | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | Contributor test suites | `1` compares the installed host CLI contract. | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | Contributor test suites | `1` runs the signed-in Claude native smoke. | @@ -95,7 +95,8 @@ the framework's reads `plugin.value.stateRoot` instead of re-deriving the path. Under `mcp run`, plugin-root anchors in **env values** expand to the project root by default, not the artifact: the artifact is an ephemeral build product there, and anchoring durable state on it would fragment that state on every rebuild. Pass `--plugin-root ` pointing at the -artifact target root for a byte-faithful rehearsal of a copied-artifact launch. +artifact root itself — the composite plugin root, not a host subdirectory — for a byte-faithful +rehearsal of a copied-artifact launch. A per-server state directory uses the server name when it is a single safe segment; any other name becomes a content-addressed `server-` segment, so a name like `../shared` can never diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 467ee1ca9..8d020e722 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -1,35 +1,140 @@ --- -description: 'What each agent-bundle target emits and the artifact manifest contract: file kinds, digests, source inputs, target schema pins, and recorded validation status.' +description: 'The composite plugin root agent-bundle build emits: where each host projection places its manifest, hook, and MCP documents, the hook wrapper naming rule, the AB4103/AB4105/AB4106 composition rules, and the artifact manifest contract.' --- # Targets and artifacts -The target table — which layout each target emits, and why the portable standard omits rules, -commands, and hooks — is in [Configuration model](../guide/authoring/index.mdx). The directory -layouts are in [Project structure](../guide/start/project-structure.mdx). This page is the -artifact contract those layouts have to satisfy. +The target table — what each host projection carries, and why the portable standard omits rules, +commands, and hooks — is in [Configuration model](../guide/authoring/index.mdx). The source-tree +layout is in [Project structure](../guide/start/project-structure.mdx). This page is the output +contract: the one directory `build` emits, and the artifact manifest its bytes have to satisfy. + +## The composite plugin root + +`agent-bundle build` writes **one directory** at the artifact output — `artifact/` by default; +`output.distPath` or `--output` moves it. `targets` selects the **host projections** laid into +that root: `claude`, `codex`, `cursor`, and `portable`, in any combination. Every selected host +reads the same directory as its plugin root; there is no per-host subdirectory to point a host at. + +- Omit `targets` — in config and on the command line — and the build emits only the `portable` + projection. +- Order does not matter. `['codex', 'claude']` and `['claude', 'codex']` produce byte-identical + output, and the normalized model, the artifact manifest, and `inspect` list the selection + sorted by name. +- `plugin` is not a target. `targets: ['plugin']` or `--target plugin` is an unknown target + (`AB4100`): the composite root is already the output of every build. + +Built with all four projections, the host-test example's root looks like this (component +directories appear only when the project authors them): + +```text +artifact/ +├── .agents/plugins/marketplace.json # Codex marketplace +├── .claude-plugin/plugin.json # Claude Code manifest +├── .claude-plugin/marketplace.json +├── .codex-plugin/plugin.json # Codex manifest +├── .codex-plugin/hooks.json # Codex hook document +├── .codex-plugin/mcp.json # Codex MCP document +├── .cursor-plugin/plugin.json # Cursor manifest +├── .cursor-plugin/marketplace.json +├── .cursor-plugin/hooks.json # Cursor hook document +├── .cursor-plugin/mcp.json # Cursor MCP document +├── .mcp.json # Claude Code MCP document +├── plugin.json # portable (Agent Plugins) manifest +├── mcp.json # portable MCP document +├── hooks/hooks.json # Claude Code hook document +├── hooks/.mjs # wrapper, hook selected for ONE host +├── hooks/..mjs # wrappers, hook shared by >1 selected host +├── hooks/hooks-flight.mjs +├── mcp/mcp--.mjs # compiled MCP entries (+ -flight.mjs) +├── bin/.mjs, bin/-flight.mjs # routed CLI (when src/cli exists) +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # emitted once +├── INSTALL.md # when any built-in host is selected +├── install.mjs # when cursor or portable is selected +├── agent-bundle.manifest.json # selected projections + provenance +└── agent-bundle.hooks.json # hook index over selected hosts +``` + +Host manifests live in their dotfolders at the root. `skills/`, `hooks/`, `mcp/`, `scripts/`, +`bin/`, and `assets/` are shared and emitted **once** — no per-host copies. Nothing else appears +at the root: no generated `AGENTS.md`, no `hooks/hooks-cursor.json`. + +### Where each host reads its documents + +| Host | Manifest | Hook document | MCP document | Marketplace | +| --- | --- | --- | --- | --- | +| Claude Code | `.claude-plugin/plugin.json` | `hooks/hooks.json` | `.mcp.json` | `.claude-plugin/marketplace.json` | +| Codex | `.codex-plugin/plugin.json` | `.codex-plugin/hooks.json` | `.codex-plugin/mcp.json` | `.agents/plugins/marketplace.json` | +| Cursor | `.cursor-plugin/plugin.json` | `.cursor-plugin/hooks.json` | `.cursor-plugin/mcp.json` | `.cursor-plugin/marketplace.json` when `marketplace: true` | +| portable | `plugin.json` | — | `mcp.json` | — | + +The paths are fixed whatever the selection, so a single-host root and a four-host root share one +layout. Claude Code and the portable Agent Plugins format load their documents from the +conventional plugin-root locations and cannot be redirected; Codex and Cursor manifests carry +explicit `hooks` and MCP pointers, so their documents sit beside their manifests. Both of those +hosts also fall back to folder discovery of the conventional paths when the pointer is absent, so +a Codex or Cursor projection with no document of its own still points at an empty one whenever +another selected host claims the conventional path — Cursor never loads Claude Code's +`hooks/hooks.json`. + +### Hook wrappers + +A hook wrapper bakes in the host it was planned for (its codec, its `target`, its host contract +revision), so one wrapper cannot serve two hosts: + +- A hook that reaches exactly one selected host keeps the plain name, `hooks/.mjs`. +- A hook that reaches several selected hosts is emitted once per host as + `hooks/..mjs`, and each host's hook document points at its own wrapper. + +Which hosts a hook reaches is its `targets` (every target, by default) intersected with the +selection, so the same source hook is `hooks/audit.mjs` in a `claude`-only root and +`hooks/audit.claude.mjs` plus `hooks/audit.cursor.mjs` in a `claude` + `cursor` root. Native hooks +are preserved for every selected host. `agent-bundle.hooks.json` indexes the wrappers over the +selected hosts. + +### Compiled surfaces + +MCP entries, scripts, routed CLI bins, and MCP Apps are compiled **once** and attributed to the +**composite identity** — the selected host names, sorted and joined by `+`, such as +`claude+codex` — rather than to any one host. `agent-bundle inspect --bundler` shows the same +thing: its `distPath.root` is the artifact output, ``, with no host segment beneath it. + +### One root, one set of bytes + +Merging projections by path only works when the hosts agree on the bytes. Three rules keep the +root honest; all are errors, and `validate` and `inspect` report them exactly where `build` +refuses: + +| Code | Rule | +| --- | --- | +| `AB4103` | Two selected projections plan the same path with different bytes. Projections are compared in host-name order and paths in path order, so a given selection reports the same collision however `targets` is written. The usual cause is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`), which lowers to different `skills//SKILL.md` bytes for Claude Code than for the other hosts. Make the component identical for every selected host, or build the conflicting hosts into separate artifacts. | +| `AB4105` | A component scoped to a subset of the selected hosts — a command or rule with frontmatter `targets` — sits in a conventional directory another selected host scans (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Extend `targets` to every selected host that discovers the directory, or build those hosts separately. Skills are never host-scoped: every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | +| `AB4106` | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with another target. Only the built-in hosts agree on where their unshared documents live, which directories each discovers, and one install surface, so a third-party adapter is built alone: `targets: ['']` into its own `--output`. A single-target selection never triggers it. | + +All three are listed with `AB4100` in the [Diagnostics reference](./diagnostics.md). -## Every target directory is distributable +## The root is distributable -A built target directory is the unit of distribution: no packaging step follows the build. Each -one carries the components the host reads, a generated `INSTALL.md` written with the bundle's -real plugin and marketplace names, and the install surface that target requires. +The composite root is the unit of distribution: no packaging step follows the build. It carries +the components the selected hosts read, one generated `INSTALL.md` — a section per selected host, +written with the bundle's real plugin and marketplace names — and the install surface those hosts +require, emitted once for the whole selection: -| Target | Marketplace manifests | Install surface | +| Selected host | Marketplace manifest | Install surface | | --- | --- | --- | -| `claude` | Local marketplace manifest. | `claude plugin marketplace add` + `claude plugin install`. | -| `codex` | Local marketplace manifest. | `codex plugin marketplace add` + `codex plugin add`. | -| `cursor` | Local marketplace manifest (`.cursor-plugin/marketplace.json`) when `marketplace: true`. | `install.mjs`. | +| `claude` | `.claude-plugin/marketplace.json`. | `claude plugin marketplace add` + `claude plugin install`. | +| `codex` | `.agents/plugins/marketplace.json`. | `codex plugin marketplace add` + `codex plugin add`. | +| `cursor` | `.cursor-plugin/marketplace.json` when `marketplace: true`. | `install.mjs`. | | `portable` | — | `install.mjs`. | -| `plugin` | Claude, Codex, and Cursor manifests, plus a generated `AGENTS.md`. | `install.mjs` and the host CLIs. | -Artifact validation errors when a target is missing a required install-surface file, so a bundle -cannot ship without the installer its `INSTALL.md` promises. The npm pack inventory checks the -same paths (`AB7010`). +`INSTALL.md` is written whenever a built-in host is selected; `install.mjs` when `cursor` or +`portable` is among them. Artifact validation errors when a required install-surface file is +missing, so a root cannot ship without the installer its `INSTALL.md` promises. The npm pack +inventory checks the same paths (`AB7010`). ## agent-bundle.manifest.json -One manifest is emitted per artifact and is the input to every later integrity check — +One manifest is emitted per root and is the input to every later integrity check — `validate --artifact`, `prepack`, `doctor --from`, and the packed and installed-host proof levels. | Field | Contents | @@ -38,9 +143,9 @@ One manifest is emitted per artifact and is the input to every later integrity c | `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, the validated `packageName` and `packageVersion` (absent for unpackaged development projects), and `sourceInputs`. | | `files` | Every emitted file: `path`, `bytes`, `sha256`, `kind`, optional `mode`, and the `sourceInputs` it derives from. | | `runtime` | `{ node }` — the selected generated-executable floor. | -| `targets` | Per target: `name`, `adapterRevision`, `observedVersion`, and the pinned `schemas` it was validated against. | +| `targets` | One row per selected projection, sorted by name: `name`, `adapterRevision`, `observedVersion`, and the pinned `schemas` it was validated against. | | `agentSkills` | The Agent Skills specification pin: `specification`, `sourceRevision`, `schemaSha256`. | -| `validation` | Recorded status for `source`, `artifact`, and each target. | +| `validation` | Recorded status for `source`, `artifact`, and each selected projection. | `kind` is one of `bundle`, `copy`, `generated`, or `prebuilt` — a bundled entry, a byte-for-byte copy, a compiler-generated document, or a prebuilt payload file. `sourceInputs` records each @@ -78,4 +183,5 @@ schema pin, and emitted files and source inputs. ## Next - [Artifact validation](../guide/distribution/validation.mdx) — the checks that read this manifest. +- [Host installation](../guide/distribution/installation.mdx) — installing the root into each host. - [Runtime environment](./runtime-environment.mdx) — what the emitted executables assume at run time. diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index 20a1cddbe..8731c319a 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -71,8 +71,9 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck ``` 包内的 `pnpm check` 会运行 validate、build、typecheck 与两个测试池。一次 -`agent-bundle build --output artifact` 产出全部内容:`artifact/` 下完整的 Claude 与 Codex 输出——各宿主 -的插件元数据、Skill、打包后的 CLI 脚本与被生命周期包装的 MCP 服务器——以及 `dist/` 下的 npm 包。 +`agent-bundle build --output artifact` 产出全部内容:位于 `artifact/` 的一个复合插件根目录,承载 Claude +与 Codex 两个投影——各宿主的清单位于自己的点目录中,共用同一份 Skill、打包后的 CLI 脚本与被生命周期 +包装的 MCP 服务器——以及 `dist/` 下的 npm 包。 想在不打包 tarball 的情况下试用构建好的 CLI,可以从任意已在 `PATH` 上的可写目录链接构建产物中的 bin: diff --git a/website/docs/zh/examples/hooks-and-scripts.mdx b/website/docs/zh/examples/hooks-and-scripts.mdx index 7ba4a1468..3fea6c981 100644 --- a/website/docs/zh/examples/hooks-and-scripts.mdx +++ b/website/docs/zh/examples/hooks-and-scripts.mdx @@ -17,15 +17,17 @@ description: '钩子与脚本示例:一个 session-start 钩子、两个输出 ## 它证明什么 - **钩子是写成处理函数,而不是宿主文档。** `src/hooks/session-start.ts` 只是一个模块。构建会把它降级为 - 各宿主自己的钩子文档形状,并输出运行它的包装器。见[钩子](../guide/authoring/hooks.mdx)。 + 各宿主自己的钩子文档形状,并在共享的 `hooks/` 目录中输出各宿主文档所运行的包装器。见 + [钩子](../guide/authoring/hooks.mdx)。 - **两种脚本声明方式都在。** `verify-release` 按约定发布——`src/scripts/` 下任何未被声明占用的普通脚本 都会被发现——而 `detect-risk` 保持显式配置,因为它要把自己的 target 限制为 `portable`。示例故意让两种 方式都有覆盖。 - **进程外壳属于框架。** 两个脚本都导出 `main` 并返回退出码。argv 处理、等待与退出码采纳都归生成的外壳 所有,因此一个非零返回值会变成真正的阻断性退出,而不是被吞掉的返回值。见 [脚本与资源](../guide/authoring/scripts-assets.mdx)。 -- **输出的脚本自行解析自己的资源。** `assets: ['release/*.json']` 把发布清单与风险登记表复制进每个 - target,而每个输出脚本都相对自己所在的模块去读取它们——而不是相对 shell 的工作目录。 +- **输出的脚本自行解析自己的资源。** `assets: ['release/*.json']` 把发布清单与风险登记表复制一份到产物 + 根目录的 `assets/` 中,由所有 target 共享,而每个输出脚本都相对自己所在的模块去读取它们——而不是相对 + shell 的工作目录。 - **失败的重建保留上一个可用产物。** 这就是下面那段可逆演练。 ## 编写了什么 diff --git a/website/docs/zh/examples/mcp-app.mdx b/website/docs/zh/examples/mcp-app.mdx index cc23f6499..b20fe94e1 100644 --- a/website/docs/zh/examples/mcp-app.mdx +++ b/website/docs/zh/examples/mcp-app.mdx @@ -13,7 +13,7 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 - **公开依赖:** `agent-bundle`(`workspace:*`)、`@modelcontextprotocol/server`、 `@modelcontextprotocol/ext-apps`、`zod`;浏览器端 App 测试还用到 `@rstest/browser`、`@rstest/core`、 `@rstest/playwright` 与 `playwright` -- **Target:** `portable`、`codex`、`claude`——MCP App 资源仅保留在 portable +- **Target:** `portable`、`codex`、`claude`——MCP App 只为 `portable` 声明 - **凭据:** 不需要——eval 与夹具检查只读取签入的数据 - **源码:** [`examples/mcp-app`](https://github.com/ScriptedAlchemy/agent-bundle/tree/main/examples/mcp-app) @@ -24,9 +24,9 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 [MCP 服务器与 MCP App](../guide/authoring/mcp.mdx)。 - **生成的 stdio 生命周期不由你来写。** 构建会把那个工厂包进生命周期外壳:console 重定向到 stderr 的 守卫、信号处理、stdin EOF 退出、有界关停与心跳。 -- **MCP App 是编译出来的资源,不是被服务的页面。** 声明的 app 在 portable target 下编译为 - `mcp-apps/status.html`,并带有稳定的 `resourceUri`。Codex 与 Claude 保留各自的宿主产物,但不包含这份 - portable 的 App 资源。 +- **MCP App 是编译出来的资源,不是被服务的页面。** 声明的 app 限定在 `portable`,因此只要选中了该投影 + 它就会被编译——只编译一次,位于产物根目录的 `mcp-apps/status.html`,带有稳定的 `resourceUri`——并被 + 内联进生成的 `status` 服务器入口,而所有选中宿主都从同一个根目录启动这个入口。 - **降级证据是一等公民。** `status` 服务器提供不可变的 `compiler` 与 `payments-api` 健康记录,而 `payments-api` 故意返回降级的延迟数据。有意思的输出是一项被如实报告的失败检查,而不是一张全绿截图。 - **浏览器表面自成一个证明级别。** `tests/browser-app/status-panel.browser.test.ts` 通过 @@ -52,8 +52,8 @@ description: 'MCP App 示例:把一条服务就绪度工作流表达为生成 eval 覆盖对比。**Hooks** 默认是一份已填充的 Claude `sessionStart` 规范输入。 3. **Playground** 默认是脚本执行、Claude target 与 `check-service-fixture`。运行它并等待会话定稿:输出 的检查器会解析自己输出模块旁边打包好的状态夹具,因此它的成功与 shell 的工作目录无关。 -4. **Artifacts** 在选中 portable target 时,就是 `mcp-apps/status.html` 出现的地方。在存在两次 eval - 运行之前,**Comparisons** 会刻意显示 +4. **Artifacts** 显示复合插件根目录,无论选中哪个 target,`mcp-apps/status.html` 都出现在这里。在存在 + 两次 eval 运行之前,**Comparisons** 会刻意显示 `At least two recorded runs are needed before a comparison can be aligned.`——这是精确的空状态, 不是错误。 5. **MCP playground** 默认是 portable 与 `status` 服务器。打开会话、列出工具、选择 `show-status`、 diff --git a/website/docs/zh/examples/skills-starter.mdx b/website/docs/zh/examples/skills-starter.mdx index 01ebef87f..d050bd497 100644 --- a/website/docs/zh/examples/skills-starter.mdx +++ b/website/docs/zh/examples/skills-starter.mdx @@ -19,8 +19,9 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S - **约定发现。** `agent-bundle.config.ts` 只声明插件与它的三个 target,别的什么都没有。每个 `src/skills/*/SKILL.md` 目录都按约定被发现,因此配置从不列出任何 Skill。这正是 [Skills](../guide/authoring/skills.mdx) 中描述的编写模型。 -- **一份源码,三种宿主布局。** 同一批编写好的文档被降级到 portable、Codex 与 Claude 三棵输出树中。 - Workbench 的 Source 与 Generated 视图会显示某个 target 究竟是复制还是改写了一份文档。 +- **一份源码,三种宿主投影。** 同一批编写好的文档为同一个复合插件根目录中的 portable、Codex 与 Claude + 投影分别降级——`skills/` 只输出一份,由所有选中宿主共同读取。Workbench 的 Source 与 Generated 视图 + 会显示某个宿主究竟是复制还是改写了一份文档。 - **确定性的 eval 证据。** eval 套件通过确定性 harness 针对签入夹具运行,因此某个 Skill 的覆盖是一次 记录下来的运行,而不是一句断言。覆盖被标记为*间接*,因为确定性 harness 无法观察宿主端的 Skill 激活。 @@ -43,8 +44,8 @@ description: 'Skills 起步项目示例:三个按约定发现的工程运维 S 操作。 2. **Skills** 列出 `dependency-upgrade`、`incident-triage` 与 `release-review`。浏览它们链接的清单与 报告模板,并按 target 在 Source 与 Generated 之间切换。 -3. **Artifacts** 默认选中 Claude target。切换 target 即可对比 portable、Codex 与 Claude 的输出树及其 - 来源信息。 +3. **Artifacts** 显示复合插件根目录。在 portable、Codex 与 Claude 之间切换 target 只是改变聚焦的 + 投影,每种选择看到的都是同一棵树——三个宿主共同读取的那一个目录——以及每个文件的来源信息。 4. **Evals** 默认选中 `release-readiness` 套件。运行 `release-artifact-is-ready` 并查看通过的试次; 它只消费签入的证据夹具。 5. 想演练修复,就对发布策略做一处可逆修改,按 **Rebuild**,并等待 Failed 或 Idle 结果,而不是 Building diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index 13ed01bfc..7105b8e7b 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -265,15 +265,18 @@ const rendered = await renderRoute('event:tool/after', { ### 线上到底传了什么 -两种形态共享输出的 `hooks/hooks.json` 接线,并且都编译成宿主以 -`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"`(或宿主自己的根令牌)调用的包装层。统一 `plugin` -target 还会为 Cursor 写出 `hooks/hooks-cursor.json` 与 `hooks/.cursor.mjs`(`preToolUse` / -`postToolUse` 小驼峰)。Claude Code 与 Codex 按约定加载 `hooks/hooks.json`,且从不扫描 `hooks/` -下的其他文档,因此 Claude 清单不写 `hooks` 指针:Claude Code 会把指向约定文件的指针报告为重复的 -hooks 文件(`manifest.hooks` 只用于额外文档)。每个包装层都拿信封里的 `hook_event_name` 与为 -自己宿主烘焙的拼写比较(Claude 与 Codex 为 `PreToolUse` / `PostToolUse`),所以 Claude 会话里出现 -`native hook_event_name must equal postToolUse`,说明 Claude 插件根目录下装的是为 Cursor 构建的 -包装层,而不是 Claude 发送了小驼峰。配置声明的处理器 +两种形态都编译成位于根目录 `hooks/` 下的包装层,由宿主以 +`node "${CLAUDE_PLUGIN_ROOT}/hooks/.mjs"`(或宿主自己的根令牌)调用。只有一个选中宿主 +接收的钩子编译为 `hooks/.mjs`;被多个选中宿主共享的钩子则为每个宿主各编译一份 +`hooks/..mjs`,因为每个包装层都烘焙了自己宿主的事件拼写——Claude Code 与 Codex 为 +`PreToolUse` / `PostToolUse`,Cursor 为小驼峰的 `preToolUse` / `postToolUse`——而每个宿主的文档 +都指向它自己的包装层。Claude Code 的文档是 `hooks/hooks.json`,它按约定加载该文件且从不扫描 +`hooks/` 下的其他文档,因此 Claude 清单不写 `hooks` 指针:Claude Code 会把指向约定文件的指针 +报告为重复的 hooks 文件(`manifest.hooks` 只用于额外文档)。Codex 与 Cursor 的清单带有显式的 +`hooks` 指针,因此它们的文档紧挨着各自的清单,分别是 `.codex-plugin/hooks.json` 与 +`.cursor-plugin/hooks.json`。每个包装层都拿信封里的 `hook_event_name` 与为自己宿主烘焙的拼写 +比较,所以 Claude 会话里出现 `native hook_event_name must equal postToolUse`,说明 Claude 的 +文档调用的是为 Cursor 构建的包装层,而不是 Claude 发送了小驼峰。配置声明的处理器 在该包装层进程内运行。`runtime: 'shared'` 的事件路由则转发给生成的 MCP 服务器进程内的常驻运行时, 因此钩子与工具共享状态: @@ -316,6 +319,11 @@ Claude Code 会话共享同一个 `--plugin-dir`,Cursor 也会从同一个缓 服务端会把失败报告到自己的 stderr——每种不同的失败一行,最多每 30 秒重复一次——并继续待命。因其他 任何原因失败的启动仍然失败即关闭,所有者无法删除的过期 socket 也一样。 +在同一个复合根目录里,每个所选宿主都通过自己 MCP 文档所列出的第一个生成式 MCP 服务器到达该运行时—— +当所有所选宿主都列出同一个服务器时是一个进程,否则每个宿主一个。运行时的 socket 只由制品本身标识(它的 +epoch 与根目录,而非 `targets` 选择),发起调用的宿主随每个请求一起传递,因此无论哪个进程持有该 socket, +都会应答所有所选宿主的包装层。 + `runtime: 'standalone'` 的路由把它的模块打进包装层自身:同样的规范身份、同样的投影、没有共享的进程状态。 `AB4817` 会拒绝这样的路由:它需要共享运行时,但在某个 target 上没有生成的 MCP 入口承载它,也没有独立 回退。 diff --git a/website/docs/zh/guide/authoring/index.mdx b/website/docs/zh/guide/authoring/index.mdx index 40964934b..1eaa9e564 100644 --- a/website/docs/zh/guide/authoring/index.mdx +++ b/website/docs/zh/guide/authoring/index.mdx @@ -36,15 +36,20 @@ export default defineConfig({ ## Targets -`targets` 选择构建要输出的产物布局: +`targets` 选择构建铺进同一个复合插件根目录的宿主投影——也就是 `artifact/`(或 +`output.distPath`)下那个由所有选中宿主共同读取的单一目录: -| Target | 输出什么 | +| Target | 向根目录投影什么 | | --- | --- | -| `plugin` | 一份多宿主捆绑包,`.claude-plugin/`、`.codex-plugin/` 与 `.cursor-plugin/` 三份清单共享同一套组件目录。 | -| `claude` | Claude Code 插件布局。 | -| `codex` | Codex 插件布局。 | -| `cursor` | Cursor 插件布局。 | -| `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0)。Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 都是原生客户端。 | +| `claude` | Claude Code 插件布局:`.claude-plugin/`、`hooks/hooks.json`、`.mcp.json`。 | +| `codex` | Codex 插件布局:`.codex-plugin/`,内含它自己的 `hooks.json` 与 `mcp.json`。 | +| `cursor` | Cursor 插件布局:`.cursor-plugin/`,内含它自己的 `hooks.json` 与 `mcp.json`。 | +| `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0):根目录的 `plugin.json` 与 `mcp.json`。Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 都是原生客户端。 | + +宿主清单各自位于自己的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` +只输出一份,由所有宿主共享。省略 `targets` 时构建只输出 `portable` 投影。顺序无关紧要—— +`['codex', 'claude']` 与 `['claude', 'codex']` 产生完全相同的字节——而其他任何名称(包括 +`plugin`)都是未知 target(`AB4100`)。 可移植标准只打包 Skills 与 MCP 服务器,因此规则、命令与钩子在该 target 上是诚实地不可用,而不是被 悄悄丢弃。Claude Code 只能通过 CLI 转译消费该标准,这正是它仍需要专属 target 的原因。 @@ -54,7 +59,7 @@ export default defineConfig({ | 字段 | 负责 | | --- | --- | | `plugin` | 项目标识。必填。 | -| `targets` | 要输出哪些产物布局。 | +| `targets` | 复合插件根目录承载哪些宿主投影。 | | `skills` | 显式的 Skill 路径或 glob,取代 `src/skills/*` 约定。见 [Skills](./skills.mdx)。 | | `hooks` | 按规范事件分键的生命周期钩子处理器。见[钩子](./hooks.mdx)。 | | `mcp` | MCP 服务器声明及其 MCP App。见 [MCP 服务器与 MCP App](./mcp.mdx)。 | @@ -98,7 +103,7 @@ Cursor 插件的其余一切都从跨宿主模型推导。[宿主参考](../../r 有两个 Claude Code 表面值得细看,因为它们的契约比名字所暗示的更窄: -- **`claude.lspServers`** —— 由 `claude` target 以及 `plugin` 的 Claude 那一半输出为插件根目录的 +- **`claude.lspServers`** —— 只要 `claude` 在选中的投影之中,就输出为根目录的 `.lsp.json`。路径 token 只在 `command`、`args`、`env` 与 `workspaceFolder` 中展开。agent-bundle 不包含语言服务器二进制文件,请单独安装它,以确保所声明的命令位于 `PATH` 上。Codex、Cursor 与 可移植格式不会收到这份配置。 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index cd413528b..baadee83a 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -203,8 +203,8 @@ Workbench 与 `agent-bundle inspect --routes` 读取的正是它。 ### 宿主产物中的路由式 CLI 包 bin 只能到达安装了 npm 包的用户,而 hook、Skill 与脚本是随宿主产物一起交付的。因此构建还会把同一张 -编译后的命令图发射进每个所选 target,路径为 `/bin/.mjs`(若有任一命令需要渲染, -则再加上 `bin/-flight.mjs`)。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 +编译后的命令图只发射一次到产物根目录,路径为 `bin/.mjs`(若有任一命令需要渲染,则再加上 +`bin/-flight.mjs`),由所有选中宿主共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 `node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 包 bin 一致;唯一的区别是,在宿主未提供 `AGENT_BUNDLE_PLUGIN_ROOT` 时,工作区级持久状态锚定在产物根目录 @@ -248,7 +248,7 @@ import meta, { name, packageName, packageVersion, version } from 'agent-bundle/m ## 打包与安装器 -当包输出与至少一个 Claude、Codex 或 Cursor 宿主包在同一项目内构建时,框架还会输出一个自包含、相对 +当包输出与至少一个 Claude、Codex 或 Cursor 投影在同一项目内构建时,框架还会输出一个自包含、相对 包路径的安装器,位于 `dist/bin/.js`——若该名称已被占用则为 `-install.js`, 两者都被占用时再追加数字后缀。请在 `package.json` 的 `bin` 中声明对应取值。它的语法是 `install [--scope ] [--json]`,帮助中只列出已构建的宿主;它通过 `import.meta.url` 而 diff --git a/website/docs/zh/guide/authoring/scripts-assets.mdx b/website/docs/zh/guide/authoring/scripts-assets.mdx index f59650a9d..44f3966ae 100644 --- a/website/docs/zh/guide/authoring/scripts-assets.mdx +++ b/website/docs/zh/guide/authoring/scripts-assets.mdx @@ -32,7 +32,7 @@ export default defineConfig({ | 入口 | 输出 | | --- | --- | -| JavaScript / TypeScript | 在每个所选 target 产物中打包为 `scripts/.mjs`。 | +| JavaScript / TypeScript | 只打包一次,位于产物根目录的 `scripts/.mjs`,由该条目所抵达的所有选中宿主共享。 | | `.sh`、`.bash`、`.py` | 按字节复制,并保留源文件权限位。 | ### 约定 @@ -84,7 +84,7 @@ react-server worker。 ## 资源 -根 `assets/` 目录下的文件按字节复制到每个 target 产物的 `assets/` 目录。这个约定不需要任何配置。 +根 `assets/` 目录下的文件按字节复制到产物根目录的 `assets/` 目录,只复制一份,由所有选中宿主共享。这个约定不需要任何配置。 顶层 `assets` 列表会**取代**该约定,改为显式条目——字面文件路径、整个目录,或 glob,全部相对项目 根目录解析: diff --git a/website/docs/zh/guide/authoring/skills.mdx b/website/docs/zh/guide/authoring/skills.mdx index 7aa6e624a..dc2b72775 100644 --- a/website/docs/zh/guide/authoring/skills.mdx +++ b/website/docs/zh/guide/authoring/skills.mdx @@ -82,6 +82,11 @@ export default defineConfig({ Skill frontmatter 中的 `targets` 就是上述按宿主划分的对象,别无他用;写成列表(如 `targets: ['claude']`)是 `AB3006`。Skill 会输出到项目选择的每一个 target——不存在按 Skill 限制产物的机制。 +所有选中宿主读取的是同一个复合插件根目录,因此 `skills//SKILL.md` 只存在一份。若某个宿主扩展 +让文档在一个选中宿主上的降级结果与另一个宿主不同——例如在 `claude` + `codex` 的构建中写 +`targets: { claude: { effort: high } }`——就是致命的 `AB4103` 冲突。请让输出的文档对每个选中宿主 +都完全一致,或者把这些宿主分开构建到不同的产物中(每次构建只写一个 `targets` 条目)。 + ## 路径 token 各宿主的占位符语法并不相同——`${CLAUDE_PLUGIN_ROOT}` 并不是 Cursor 或可移植标准所使用的写法。请改为 diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index f6271e6bf..b078b9a66 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -28,7 +28,7 @@ npx agent-bundle dev --root . --port 3100 --no-open | Overview | 项目标识、规范化模型与诊断。 | | Routes | 来自与 `inspect --routes` 相同编译器 pass 的已编译路由目录:每条路由的源码模块、配置摘要与生成的输入编辑器;带静态契约的路由还会指明契约的声明模块及共享该契约的其他路由。 | | Skills | 每个 Skill 文档,包括各宿主降级后的输出。 | -| Artifacts | 带 provenance 与 epoch 对比的产物树。 | +| Artifacts | 复合插件根目录——无论聚焦哪个选中宿主,都是同一棵树——以及 provenance 与 epoch 对比。 | | MCP | 绑定到产物的 playground,带原始协议轨迹、MCP App 预览,以及独立 MCP Inspector 的启动器。 | | Hooks | 运行输出的钩子包装层的 playground。 | | Playground | 可重放、可导出的持久有序轨迹。 | @@ -131,7 +131,8 @@ agent-bundle dev proxy --root --server --target --strict` | 内容寻址地证明输出字节与清单一致,外加在宿主发布了校验器时的宿主工具结论。 | -| 安装 | `agent-bundle install --from ` | 把捆绑包注册进 Claude、Codex 或 Cursor。 | +| 构建 | `agent-bundle build` | 一个承载全部所选宿主投影的组合插件根目录;当项目声明了 `bin`/`lib` 时,还有 `dist/` 包构建。 | +| 校验 | `agent-bundle validate --artifact --strict` | 内容寻址地证明输出字节与清单一致,外加在所选宿主发布了校验器时的宿主工具结论。 | +| 安装 | `agent-bundle install --from ` | 把捆绑包注册进 Claude、Codex 或 Cursor——三者都从同一个根目录安装。 | 构建在写出任何东西之前就已经校验过项目,因此针对源码单独运行一次 `validate` 更像是快速的预检,而不是 必需的阶段。真正有意思的是校验**产物**,因为它完全不需要项目源码。 -### 一个 target 如何编译 +### 根目录如何编译 -构建先为每个 target 做规划,然后把该 target 的输出最多分两个阶段降级到同一个暂存根目录,待产物校验通过后 -原子地发布: +构建先为每个所选宿主投影做规划,再按路径把它们合并成一棵树——两个投影只有在字节一致时才能共享同一路径 +(`AB4103`),而只面向部分宿主的组件不能放在另一个所选宿主会发现它的位置(`AB4105`)。随后它把编译产出面 +最多分两个阶段降级到同一个暂存根目录,待产物校验通过后原子地发布: 1. **MCP Apps**——浏览器环境,通过 `@rsbuild/core` 编译。只有当项目声明了 App 路由时这一阶段才存在,并且 始终最先运行:MCP 入口会内嵌它产出的 HTML。 2. **智能体宿主面**——路由式 CLI bin、打包的脚本、hook 包装器、MCP stdio 入口,以及每个面各自的 - react-server Flight worker,全部一起通过**每个 target 一个 Rslib 实例**降级(每个输出一个 Rsbuild - environment,一个 Rspack 多编译器)。宿主面在运行时按文件名找到它的 worker,因此阶段内二者无需排序; - 每个面为清单保留各自的源码证据。 + react-server Flight worker,全部一起通过**一个 Rslib 实例**降级(每个输出一个 Rsbuild environment, + 一个 Rspack 多编译器)。宿主面在运行时按文件名找到它的 worker,因此阶段内二者无需排序;每个面为清单 + 保留各自的源码证据。 -两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、`tools.rspack`,最后是 -框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。`agent-bundle inspect --bundler` 会 -打印合成结果。 +编译产出面只构建一次,归属于**组合身份**——所选宿主按名称排序并以 `+` 连接,例如 `claude+codex`—— +绝不会逐宿主各编译一次。两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、 +`tools.rspack`,最后是框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。 +`agent-bundle inspect --bundler` 会打印合成结果;它的 `distPath.root` 就是产物输出本身。 -## 一个 target 目录里发布了什么 +## 根目录里发布了什么 -每个已构建 target 都包含一份生成的 `INSTALL.md`,其中使用捆绑包**真实的**插件名与市场名——而不是 -占位符——因此这份文件可以逐字照做。多宿主的 `plugin` target 还带有一份生成的 `AGENTS.md`,说明如何把它 -安装到各个宿主。 +宿主清单位于根目录下各自的点目录中(`.claude-plugin/`、`.codex-plugin/`、`.cursor-plugin/`,以及 portable +的 `plugin.json`),各自指向自己的钩子与 MCP 文档;而 `skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 +`assets/` 是共享的,只输出一次。完整的目录树与各宿主的文档位置见 +[Target 与产物](../../reference/targets-artifacts.mdx)。 -Claude 与 Codex target 始终包含本地市场清单,正是这一点让它们的公开 CLI 能够直接安装输出的目录。 -Cursor、portable 与组合 target 则包含一个独立的 `install.mjs`,因为 Cursor 提供了市场管理能力,却没有 -非交互式的插件安装动词。 +只要选中了内置宿主,根目录就会携带一份生成的 `INSTALL.md`——每个所选宿主一节,使用捆绑包**真实的** +插件名与市场名,而不是占位符——因此这份文件可以逐字照做。 + +Claude 与 Codex 投影始终包含本地市场清单,正是这一点让它们的公开 CLI 能够直接安装该根目录。选中 `cursor` +或 `portable` 时还会加上一个独立的 `install.mjs`,因为 Cursor 提供了市场管理能力,却没有非交互式的插件 +安装动词。 `agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此校验比对的是真实字节,而不是检查某个 -路径是否存在。各 target 自身的布局在[项目结构](../start/project-structure.mdx)中介绍。 +路径是否存在。 构建是可复现的:对同一份未改动的源码树构建两次,会得到逐字节相同的产物——相同的清单、相同的摘要—— -无论 `--output` 叫什么名字,也无论每次构建的暂存目录叫什么名字。打包器写进编译入口里的模块标识只由 -项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此已安装的副本、预览包与 -[`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。这些标识所命名的生成模块从内存 -中提供,位于项目根目录下保留的 `.agent-bundle-virtual/` 目录;只要有任何东西占用该目录,构建就会拒绝编译。 +无论 `--output` 叫什么名字、`targets` 以什么顺序书写,也无论每次构建的暂存目录叫什么名字。打包器写进 +编译入口里的模块标识只由项目根目录推导,绝不会来自暂存目录、输出目录或构建机器上的任何绝对路径,因此 +已安装的副本、预览包与 [`doctor`](./installation.mdx) 的比对在同一源码下看到的都是相同的字节。这些标识 +所命名的生成模块从内存中提供,位于项目根目录下保留的 `.agent-bundle-virtual/` 目录;只要有任何东西占用 +该目录,构建就会拒绝编译。 ## 面向 npm 的那一半 同时作为 npm 包发布的项目还有第二份输出:`dist/bin/.js` 可执行文件与一个库入口,见 -[包入口](../authoring/package-entries.mdx)。当包输出与至少一个宿主包被一起构建时,框架还会输出一个 -相对包的安装器 bin ——见[宿主安装](./installation.mdx)。 +[包入口](../authoring/package-entries.mdx)。当包输出与至少选中了一个内置宿主的根目录被一起构建时,框架 +还会输出一个相对包的安装器 bin ——见[宿主安装](./installation.mdx)。 `agent-bundle prepack` 是这一半的门禁:它运行发布构建、以不执行脚本的方式 dry-run `npm pack`,并核实 打包后的输出、产物哈希、bin 与版本号。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index fd61e5a80..42591276c 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -4,59 +4,64 @@ description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cur # 宿主安装 -每个已构建 target 目录都包含一份生成的 `INSTALL.md`,其中的命令使用捆绑包真实的插件名与市场名。其中每条 -命令都是宿主命令(`claude plugin …`、`codex plugin …`)或随行的 `install.mjs`,因此无论从 npm、市场检出 -还是 zip 拿到已构建包的消费者,安装、重装或卸载它都不需要 `agent-bundle` CLI;`INSTALL.md` 明言这一点, -并在每处提及该 CLI 时标注其为可选。框架 CLI 为拥有它的开发者执行同样的操作: +`build` 输出的组合插件根目录包含一份生成的 `INSTALL.md`——每个所选宿主一节——其中的命令使用捆绑包真实 +的插件名与市场名。其中每条命令都是宿主命令(`claude plugin …`、`codex plugin …`)或随行的 `install.mjs`, +因此无论从 npm、市场检出还是 zip 拿到已构建根目录的消费者,安装、重装或卸载它都不需要 `agent-bundle` CLI; +`INSTALL.md` 明言这一点,并在每处提及该 CLI 时标注其为可选。框架 CLI 为拥有它的开发者执行同样的操作,而且 +所有宿主都从同一个目录安装: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex -npx agent-bundle install cursor --from artifact/cursor +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +npx agent-bundle install cursor --from artifact ``` -`--from` 既接受一个 target 捆绑包目录,也接受一个**不含源码的产物根目录**,只要该根目录包含所选宿主的 -target 目录。 +`--from` 指定的是**产物根目录**:直接存放所选宿主清单(`.claude-plugin/plugin.json`、 +`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`)的那个目录。它不需要项目源码,也不存在 +`/` 查找——缺少该宿主清单的根目录会被拒绝(`AB7001`)。构建不会写出可供指向的 +`artifact/` 子目录;根目录本身就是捆绑包。 ## 各宿主接受什么 | 宿主 | 机制 | 作用域 | | --- | --- | --- | -| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);统一 `plugin` 捆绑包的 `hooks/hooks-cursor.json` 从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | -| Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。 | `user` | -| Cursor | 把捆绑包复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载插件清单所指定的 hooks 文档(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`),因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | +| Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json` 与 `.mcp.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);同一根目录中紧挨着 Codex 与 Cursor 各自清单的文档从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | +| Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。`.codex-plugin/plugin.json` 指向自己的 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`,因此 Codex 绝不会回退到发现根目录中另一宿主的文档。 | `user` | +| Cursor | 把根目录复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载 `.cursor-plugin/plugin.json` 所指定的钩子与 MCP 文档——紧挨着清单的 `.cursor-plugin/hooks.json` 与 `.cursor-plugin/mcp.json`——因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | -由于 Claude 与 Codex target 始终随行本地市场清单,它们的公开 CLI 可以直接安装输出的目录。当所选宿主 +由于 Claude 与 Codex 投影始终随行本地市场清单,它们的公开 CLI 可以直接安装该根目录。当所选宿主 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 `AB700x` 家族:捆绑包标识、宿主可用性、作用域、命令失败与冲突检查。 ## 独立安装器 -Cursor、portable 与组合 target 包含一个 `install.mjs`,它把捆绑包复制到 +`targets` 中包含 `cursor` 或 `portable` 的根目录带有一个 `install.mjs`,它把根目录复制到 `~/.cursor/plugins/local/`,且不会覆盖冲突内容: ```sh -npx agent-bundle install cursor --from artifact/cursor -# 或者,在输出的 target 目录中执行: +npx agent-bundle install cursor --from artifact +# 或者,在根目录本身中执行: node ./install.mjs ``` 它的分阶段复制对内容相同的情况是幂等的,并会拒绝版本或内容冲突。它绝不调用 `sudo`,也绝不修改 `PATH`。 -对于 `portable`(Agent Plugins 1.0.0)捆绑包,安装器还会在 `mcp.json` 的 Cursor 副本中展开该标准的占位符, +对于 Cursor 会按 Agent Plugins 1.0.0 包加载的根目录——选中了 `portable` 而未选 `cursor`,因此根目录的 +`plugin.json` 是 Cursor 能找到的唯一清单——安装器还会在 `mcp.json` 的 Cursor 副本中展开该标准的占位符, 因为 Cursor 3.18.25 自己不会展开:它把 `args`、`env` 值和 `cwd` 中的 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 原样留下,不提供 `PLUGIN_ROOT` / `PLUGIN_DATA` 变量(规范 §9.1),把省略的 `cwd` 默认为主目录,并把相对插件的 `./` 命令解析到工作区文件夹(规范 §7.2.1),因此每个符合规范的 stdio 服务器都无法启动。Cursor 副本会得到绝对的 插件根、作为数据目录的 `~/.cursor/agent-bundle/plugin-data/`(由安装器创建)、指向插件根的 `cwd`、已解析的 `./` 命令,以及每个 stdio 服务器环境中的 `PLUGIN_ROOT` / `PLUGIN_DATA`。捆绑包本身不被改动,对其他 Agent Plugins 客户端仍然符合规范;展开前的文档保存在安装回执中(`cursorExpansion`),`agent-bundle doctor --host cursor` 以 -`AB7326` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而 `cursor` target 的捆绑包绝不会被重写。 +`AB7326` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而带有 +`.cursor-plugin/plugin.json` 的根目录——选中了 `cursor`——会被原样复制,因为 Cursor 转而加载那份清单。 -产物校验会拒绝缺少必需安装表面的内置 target,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 +产物校验会拒绝缺少必需安装表面的根目录,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 ## 相对包的安装器 bin -当包输出随行上述某个宿主包时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 +当包输出与选中了内置宿主的根目录一起发布时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 插件名,否则使用 `-install`(两者都被占用时追加数字后缀)。请在 `package.json` 中把这个 名字映射到生成的 `dist/bin/*.js` 文件;消费者随后运行: @@ -89,7 +94,7 @@ node ./install.mjs 通过环境变量配置的插件——会话 cookie、API 令牌、主机名——应在安装处配置,而不是在产物里:清单 `env` 是编译 产物,按内容寻址且每次构建都会重新输出,不是放置密钥的地方。取而代之,在已安装副本的插件根目录(存放 -`.claude-plugin/`、`.cursor-plugin/` 或 Codex 插件清单、与 `mcp/`、`hooks/`、`bin/` 同级的目录)放一个 +`.claude-plugin/`、`.codex-plugin/` 或 `.cursor-plugin/`、与 `mcp/`、`hooks/`、`bin/` 同级的目录)放一个 `.env` 文件,可选地再放一个 `.env.local`: ```sh @@ -136,13 +141,13 @@ RTORRENT_SSH_HOST=nas.local ```sh # 确切路径,不做改动 -npx agent-bundle uninstall cursor --from artifact/cursor --plan +npx agent-bundle uninstall cursor --from artifact --plan # 回执归属的文件;保留 state/ -npx agent-bundle uninstall cursor --from artifact/cursor +npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove -npx agent-bundle uninstall claude --from artifact/claude -npx agent-bundle uninstall codex --from artifact/codex --purge-data --confirm-purge -node artifact/cursor/install.mjs --uninstall [--mode marketplace] +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`):版本、内容哈希、投递模式、作用域、归属的文件与 @@ -196,7 +201,7 @@ Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核 | `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 | | `AB7326` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 | | `AB7327` | warning | `doctor --from` 比对了一份其 `claude plugin list --json` 行报告 `enabled: false` 的已安装 Claude 副本;修复方式是 `claude plugin enable @ [--scope ]`。 | -| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | +| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个——`.cursor-plugin/hooks.json`——因此同一根目录中 Claude Code 的 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | ## 下一步 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 4cdad9f31..efc59e4f8 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -46,19 +46,19 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, ## Claude Code 严格校验 -当 Claude Code 位于 `PATH` 上时,产物校验会为输出的 `claude` target 以及统一的 `plugin` target 运行 -已安装的 `claude plugin validate`。`agent-bundle build` 会对刚写出的产物运行同样的检查 -(`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而 -`agent-bundle doctor --host claude --from ` 会对该 bundle 以及 Claude 列出的每一份已安装副本运行 +宿主校验只为所选投影运行,且作用于组合根目录。当 Claude Code 位于 `PATH` 上、且所选投影中包含 `claude` +时,产物校验会对根目录运行已安装的 `claude plugin validate`。`agent-bundle build` 会对刚写出的根目录运行 +同样的检查(`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而 +`agent-bundle doctor --host claude --from ` 会对该根目录以及 Claude 列出的每一份已安装副本运行 这些检查。Claude Code 根据被指向的清单决定检查什么:一个同时包含 -`.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的 bundle 目录会被当作 marketplace, +`.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的根目录会被当作 marketplace, 而 marketplace 校验从不打开插件的 hook、skill、agent 或 command 文件。因此 Agent Bundle 运行两条命令: ```sh # plugin.json、hooks/hooks.json、skills/、agents/、commands/ -claude plugin validate /.claude-plugin/plugin.json --strict +claude plugin validate /.claude-plugin/plugin.json --strict # marketplace schema、插件名、source 路径 -claude plugin validate /.claude-plugin/marketplace.json --strict +claude plugin validate /.claude-plugin/marketplace.json --strict ``` - 宿主的 **error** 会变成 Agent Bundle 的 error(`AB6021`)。 @@ -69,14 +69,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` 行: @@ -99,10 +98,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/` 之后,在该会话中运行 @@ -110,11 +109,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 文件。 @@ -132,7 +131,7 @@ schema 校验它们输出的 JSON 文档,并把宿主的 schema 生成器视 | 代码 | 严重级别 | 含义 | | --- | --- | --- | | `AB6026` | info | 每份 Cursor 报告都会声明 Cursor 未发布 plugin-validate 动词,并指明本地校验所用的 vendored schema 固定版本。 | -| `AB6027` | error | 某个必需的生成式 Cursor 文档缺失,或者已存在的 plugin、市场、MCP 或 hooks 文档不可读、不是合法 JSON,或被其固定 schema 拒绝。hooks 文档是 `.cursor-plugin/plugin.json` 的 `hooks` 字段所指定的那个(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`,或内联对象),仅在该字段缺失时才回退到 `hooks/hooks.json`;指定的文件缺失或逃逸出插件根目录时为 error。 | +| `AB6027` | error | 某个必需的生成式 Cursor 文档缺失,或者已存在的 plugin、市场、MCP 或 hooks 文档不可读、不是合法 JSON,或被其固定 schema 拒绝。hooks 文档是 `.cursor-plugin/plugin.json` 的 `hooks` 字段所指定的那个(紧挨着清单的 `.cursor-plugin/hooks.json`,或内联对象),仅在该字段缺失时才回退到 `hooks/hooks.json`;指定的文件缺失或逃逸出插件根目录时为 error。 | | `AB6028` | error | 生成的字节违反了被固定的 Cursor 加载器证据:清单候选优先级选中了回退清单、符号链接解析到捆绑包之外,或者 `CURSOR_PLUGIN_ROOT` 出现在加载器替换字段之外。 | | `AB6029` | info / warning | Cursor Agent 版本探测不可用(`ENOENT`,info),或未能完成(warning)。本地的固定 schema 校验仍会运行。 | | `AB6030` | info | Codex CLI 不可用,或已安装的 Codex 版本未发布插件校验命令。 | diff --git a/website/docs/zh/guide/start/index.mdx b/website/docs/zh/guide/start/index.mdx index 8d44f8e30..4c2d646ba 100644 --- a/website/docs/zh/guide/start/index.mdx +++ b/website/docs/zh/guide/start/index.mdx @@ -1,12 +1,12 @@ --- -description: 'agent-bundle 将一份带类型的配置编译为可安装到 Claude Code、Codex、Cursor 的插件产物以及可移植布局。' +description: 'agent-bundle 将一份带类型的配置编译为 Claude Code、Codex、Cursor 与可移植 Agent Plugins 格式都能安装的同一个插件根目录。' --- # 介绍 agent-bundle 把一个智能体插件——由一份带类型的配置描述的 Skills、钩子、MCP 服务器与脚本——编译为 -可安装到 Claude Code、Codex 与 Cursor 的产物,外加一份可移植布局。插件只写一次,编译器负责生成 -每个宿主各自的清单与包装层。 +一个可安装到 Claude Code、Codex 与 Cursor 的插件根目录,外加可移植的 Agent Plugins 格式。插件只写 +一次,编译器负责把每个宿主各自的清单与包装层生成到这同一个根目录中。 需要 Node.js 22.19 或更高版本。 @@ -22,10 +22,10 @@ agent-bundle 把这件事反转过来。宿主专属布局是编译器的职责 npx agent-bundle build --root . ``` -当 `targets: ['plugin']` 时,这一条命令会在 `artifact/plugin/` 生成一份多宿主捆绑包:`.claude-plugin/`、 -`.codex-plugin/` 与 `.cursor-plugin/` 三份清单,共享同一套 `skills/`、`hooks/`、`mcp/` 与 `scripts/` -目录。捆绑包中生成的 `AGENTS.md` 会说明如何把它安装到各个宿主。单宿主布局则由 `claude`、`codex`、 -`cursor` 与 `portable` 这几个 target 提供。 +这一条命令会在 `artifact/` 生成一个复合插件根目录:每个所选宿主的清单(`.claude-plugin/`、 +`.codex-plugin/`、`.cursor-plugin/`,以及可移植格式的 `plugin.json`)覆盖在共享的 `skills/`、`hooks/`、 +`mcp/`、`bin/` 与 `scripts/` 目录之上,外加一份 `INSTALL.md`。`targets` 选择根目录承载哪些宿主投影 +——`claude`、`codex`、`cursor`、`portable`;省略时只选择 `portable`——而所有宿主安装的都是同一个目录。 ## 配置负责什么 @@ -36,7 +36,7 @@ import { defineConfig } from 'agent-bundle/config'; export default defineConfig({ plugin: { name: 'my-plugin', description: 'What it does.' }, // [!code highlight] - targets: ['plugin'], + targets: ['claude', 'codex', 'cursor'], skills: ['src/skills/*'], hooks: { sessionStart: { handler: './src/session-start.ts' } }, mcp: { servers: { tools: { entry: './src/mcp.ts' } } }, @@ -44,8 +44,8 @@ export default defineConfig({ ``` 同一份配置还负责 npm 包构建——不需要第二份打包器配置、不需要 bin 垫片、也不需要手写 stdio 生命周期。 -`bin` 与 `lib` 条目(或 `src/cli.ts`、`src/index.ts`、`src/mcp/.ts` 这几个约定)会在宿主 -产物之外一并生成可执行的 `dist/bin/.js` 包与库输出。默认导出服务器工厂函数的 MCP 入口会运行在 +`bin` 与 `lib` 条目(或 `src/cli.ts`、`src/index.ts`、`src/mcp/.ts` 这几个约定)会在插件 +根目录之外一并生成可执行的 `dist/bin/.js` 包与库输出。默认导出服务器工厂函数的 MCP 入口会运行在 框架自有的 stdio 生命周期之下。`tools.rsbuild` / `tools.rspack` 是唯一的打包器逃生舱。 ## 编写模型 diff --git a/website/docs/zh/guide/start/installation.mdx b/website/docs/zh/guide/start/installation.mdx index d0b0518c3..ad171b471 100644 --- a/website/docs/zh/guide/start/installation.mdx +++ b/website/docs/zh/guide/start/installation.mdx @@ -75,7 +75,7 @@ npx agent-bundle --help npx agent-bundle validate --root . ``` -`validate` 检查项目源码。构建出产物之后,改用 `validate --artifact ` 检查已构建的产物,此时 -完全不需要项目源码。 +`validate` 检查项目源码。构建出产物之后,改用 `validate --artifact artifact` 检查已构建的插件根目录 +——也就是所有所选宿主安装的那一个目录——此时完全不需要项目源码。 下一步:[快速开始](./quick-start.mdx)。 diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index 43867f81a..1c04bfeb4 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -13,7 +13,7 @@ description: 'agent-bundle 识别的约定 src/ 根目录、配置与约定之 my-plugin/ ├── agent-bundle.config.ts # 项目标识、targets 与策略 ├── package.json # 权威的发布版本号与包标识 -├── assets/ # 按字节复制到每个产物中的静态文件 +├── assets/ # 按字节复制到产物根目录中的静态文件 └── src/ ├── skills//SKILL.md # 每个目录一个 Skill,并带有自己的资源 ├── commands/*.md # 宿主斜杠命令文档 @@ -45,7 +45,7 @@ my-plugin/ | `src/mcp/.ts` | 某个已声明、但未指定 `entry`、`command` 或 `url` 的 MCP 服务器的 stdio 入口。 | 显式声明 `entry`。 | | `src/mcp//{tools,resources,prompts}/*` | 生成式 MCP 服务器路由。路径提供身份;每个模块提供静态 `config`、schema,以及一个 async 默认 Server Component。 | 把 `routes.servers.` 设为 `custom`、`command` 或 `remote`。 | | `src/mcp//apps/*` | 浏览器 MCP App 入口,编译为自包含 HTML 并注册到生成的服务器上。必须提供静态 `config.resourceUri`。 | 使用自定义服务器,或给文件名加 `_` 前缀。 | -| `src/scripts/.ts` | 一个普通脚本,在每个所选 target 中编译为 `scripts/.mjs`。嵌套模块是硬错误(`AB4808`)。 | 给某一段路径加 `_` 前缀,或用显式 `scripts` 条目认领该文件。 | +| `src/scripts/.ts` | 一个普通脚本,只编译一次,输出为产物根目录下的 `scripts/.mjs`,所有所选宿主共用。嵌套模块是硬错误(`AB4808`)。 | 给某一段路径加 `_` 前缀,或用显式 `scripts` 条目认领该文件。 | | `src/scripts/.tsx` | 渲染式脚本:async 默认组件接收 `argv` 与 `signal`,并按 CLI 输出契约通过 Agent 渲染器渲染。 | 改名为 `.ts`、给某一段路径加 `_` 前缀,或认领该文件。 | | `src/cli.ts` | 一个以 `plugin.name` 命名的包 bin。 | `bin: false` | | `src/cli/**/*.{ts,tsx}` | 路由式 CLI 命令,编译进一张做过冲突检查的命令图与一个可执行文件。嵌套即身份:`src/cli/library/audit.ts` 以 ` library audit` 运行。它取代 `src/cli.ts` 约定。 | `bin: false`、`routes.cli: 'conventional'`,或给某一段路径加 `_` 前缀。 | @@ -54,7 +54,7 @@ my-plugin/ | `src/mcp//layout.{ts,tsx}` | 按服务器的布局,嵌套在根布局之内,包裹该生成式服务器的路由。 | 重命名为 `_layout.tsx`,或把 `routes.servers.` 设为非生成模式。 | | `src/state.ts` | 项目状态:默认导出 `defineState`。生成的 MCP、路由式 CLI 与渲染式脚本的请求作用域都会挂载它。 | `state: false`,或改名为 `_state.ts`。 | | `src/providers/.{ts,tsx}` | 一个请求上下文 provider,挂载在请求句柄的 `providers.` 上;其工厂会收到请求的身份、lineage 以及只读的 state/notices 句柄。 | 给文件名加 `_` 前缀。 | -| `assets/` | 静态资源,按字节复制到每个 target 产物的 `assets/` 目录。 | 改为声明顶层 `assets` 列表。 | +| `assets/` | 静态资源,按字节复制到产物根目录的 `assets/` 目录,所有所选宿主共用一份。 | 改为声明顶层 `assets` 列表。 | 路由与包入口约定精确匹配 `.ts` 与 `.tsx` 文件;state 约定则专指 `src/state.ts`。被发现的条目在 规范化模型中带有 `provenance.kind: 'conventional'`,因此 `agent-bundle inspect` 能告诉你某个文件 @@ -97,35 +97,50 @@ export default defineConfig({ `agent-bundle build` 会写出两类彼此独立的东西。 -### 宿主产物 +### 复合插件根目录 -在产物根目录下,每个所选 target 一个目录。命令行把该根目录默认为 `artifact/`,因此它永远不会与下文的包构建 -冲突;`output.distPath` 或 `--output` 可以移动它: +无论 `targets` 选择了什么,产物输出位置都只有一个目录。命令行把该输出默认为 `artifact/`,因此它永远 +不会与下文的包构建冲突;`output.distPath` 或 `--output` 可以移动它。每个所选宿主都把这个目录当作 +自己的插件根目录来读取——不存在 `artifact//` 这样的分区——因此 `targets` 决定的是根目录中出现 +哪些宿主清单,而不是任何东西落在哪里。省略 `targets` 时,根目录只承载 `portable` 投影。 ```text artifact/ -├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 -└── plugin/ # targets: ['plugin'] — 一个多宿主捆绑包 - ├── .claude-plugin/ - ├── .codex-plugin/ - ├── .cursor-plugin/ - ├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 - ├── skills/ - ├── hooks/ - ├── mcp/ - ├── scripts/ - ├── assets/ - ├── AGENTS.md - └── INSTALL.md +├── .claude-plugin/plugin.json # claude,旁边是 marketplace.json +├── .codex-plugin/plugin.json # codex,旁边是 hooks.json 与 mcp.json +├── .agents/plugins/marketplace.json # codex marketplace +├── .cursor-plugin/plugin.json # cursor,旁边是 hooks.json 与 mcp.json +├── .mcp.json # claude MCP 文档 +├── plugin.json # portable(Agent Plugins)清单 +├── mcp.json # portable MCP 文档 +├── hooks/ +│ ├── hooks.json # claude 钩子文档 +│ ├── .mjs # 只到达一个所选宿主的钩子的包装脚本 +│ ├── ..mjs # 到达多个宿主的钩子,每个宿主一个包装脚本 +│ └── hooks-flight.mjs +├── mcp/mcp--.mjs # 编译后的 MCP 入口,只输出一次 +├── bin/.mjs # 路由式 CLI,存在 src/cli/** 时出现 +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 组件目录,只输出一次 +├── INSTALL.md # 每个所选宿主一节 +├── install.mjs # 选择了 cursor 或 portable 时出现 +├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 +└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 ``` -单宿主布局由 `claude`、`codex`、`cursor` 与 `portable` 这几个 target 提供。 -`agent-bundle.manifest.json` 位于产物根目录、与各 target 目录并列,记录了每个产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 +宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 +所有宿主共用。钩子与 MCP 文档在项目声明了钩子或 MCP 服务器时出现——此外,只要另一个所选宿主占用了 +约定路径 `hooks/hooks.json`、`.mcp.json` 或 `mcp.json`,Codex 或 Cursor 就会额外输出一份空文档,使该宿主的目录 +发现永远不会加载到其他宿主的文件——`bin/` 只在项目有路由式 CLI 时出现。两个所选宿主若要以不同字节写出同一路径,就无法共用根目录,构建会以 `AB4103` 失败;一个只面向 +部分所选宿主的命令或规则,却位于另一个所选宿主会扫描的目录中,则是 `AB4105`。两者的恢复方式 +相同:让该组件对每个所选宿主都完全一致,或把这些宿主分别构建到不同的产物中。 -`output.distPath` 只移动产物根目录;它从不改变每个 target 内部由框架拥有的布局。优先级是 CLI -`--output`,然后 `output.distPath`,最后是默认值——对同时输出包构建的 `agent-bundle build` 是 -`artifact`,对不带 `packageOutputs` 的编程式 `build()` 是 `dist`。取值必须是非空、限定在项目根目录内的 -相对 POSIX 路径。 +`agent-bundle.manifest.json` 记录了每个产出文件及其 SHA-256,因此产物校验是内容寻址的,而不是猜测。 +编译后的表面归属于复合身份——所选宿主按名称排序后以 `+` 连接,例如 `claude+codex`——`targets` 的 +书写顺序永远不会改变输出。 + +`output.distPath` 只移动根目录;它从不改变根目录内部由框架拥有的布局。优先级是 CLI `--output`, +然后 `output.distPath`,最后是默认值——对同时输出包构建的 `agent-bundle build` 是 `artifact`,对不带 +`packageOutputs` 的编程式 `build()` 是 `dist`。取值必须是非空、限定在项目根目录内的相对 POSIX 路径。 ### npm 包构建 diff --git a/website/docs/zh/guide/start/quick-start.mdx b/website/docs/zh/guide/start/quick-start.mdx index 7c1015de0..418a68691 100644 --- a/website/docs/zh/guide/start/quick-start.mdx +++ b/website/docs/zh/guide/start/quick-start.mdx @@ -52,7 +52,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' } } }, @@ -70,17 +70,18 @@ export default defineConfig({ }); ``` -发布版本号来自 `package.json`。`plugin.version` 字段仍然可用,但它是已废弃的兼容轴;取值与 -`package.json` 不一致时会报告 `AB4008` 警告。 +`targets` 选择这一个产物根目录承载哪些宿主投影——`claude`、`codex`、`cursor`、`portable`;省略它时 +根目录只承载 `portable`。发布版本号来自 `package.json`。`plugin.version` 字段仍然可用,但它是已废弃的 +兼容轴;取值与 `package.json` 不一致时会报告 `AB4008` 警告。 ## 构建,或交互式开发 ```sh -npx agent-bundle build --root . # 把可安装产物写到 artifact/ -npx agent-bundle dev --root . # 带实时重建的本地 Workbench +npx agent-bundle build --root . # 把复合插件根目录写到 artifact/ +npx agent-bundle dev --root . # 带实时重建的本地 Workbench ``` -`build` 会校验项目并写出产物;声明了 `bin`/`lib` 时还会一并完成包构建。`dev` 在 loopback 上提供 +`build` 会校验项目并写出产物根目录;声明了 `bin`/`lib` 时还会一并完成包构建。`dev` 在 loopback 上提供 开发者 Workbench,并随输入变化持续重建:项目概览与诊断、Skill 文档、带 provenance 与 epoch 对比的 产物树、绑定到产物、带原始协议轨迹的 MCP playground、运行真实生成包装层的钩子 playground,以及 eval 运行。 @@ -88,7 +89,7 @@ eval 运行。 ## 查看编译器的判断 ```sh -npx agent-bundle inspect --root . # 规范化配置与逐 target 计划 +npx agent-bundle inspect --root . # 规范化配置与每个宿主投影的计划 npx agent-bundle inspect --root . --skills # 加上 skill focus npx agent-bundle validate --root . # 检查项目源码 ``` @@ -97,21 +98,23 @@ npx agent-bundle validate --root . # 检查项目源码 ## 安装结果 -每个构建出的 target 目录中都包含生成的 `INSTALL.md`,其中的命令使用该捆绑包真实的插件名与市场名。按上文构建出 -`portable`、`codex` 与 `claude` 三个 target 后,宿主安装命令是: +产物根目录带有一份生成的 `INSTALL.md`,每个所选宿主一节,其中的命令使用该捆绑包真实的插件名与市场名。 +所有宿主安装的都是同一个目录,因此 `--from` 始终指向根目录。按上文构建出 `portable`、`codex` 与 +`claude` 三个 target 后,宿主安装命令是: ```sh -npx agent-bundle install claude --from artifact/claude --scope user -npx agent-bundle install codex --from artifact/codex +npx agent-bundle install claude --from artifact --scope user +npx agent-bundle install codex --from artifact +node artifact/install.mjs # portable 包,通过生成的安装器 ``` -把 `cursor` 加入 `targets` 就会构建出 `artifact/cursor`,然后用 `npx agent-bundle install cursor --from -artifact/cursor` 以同样方式安装。`plugin` 构建则只有一份覆盖所有宿主的 `INSTALL.md`。 +把 `cursor` 加入 `targets`,同一个根目录就会多出 `.cursor-plugin/`;然后用 `npx agent-bundle install +cursor --from artifact` 以同样方式安装。 若想在 Claude Code 上进行免安装的开发循环: ```sh -claude --plugin-dir artifact/claude plugin list --json +claude --plugin-dir artifact plugin list --json ``` ## 下一步 diff --git a/website/docs/zh/index.mdx b/website/docs/zh/index.mdx index f49fc66a9..5369d2c20 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -1,12 +1,12 @@ --- pageType: home -description: '用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为可直接安装到 Claude Code、Codex 与 Cursor 的产物。' +description: '用一份带类型的配置描述 Skill、钩子、MCP 服务器与脚本,编译为 Claude Code、Codex 与 Cursor 都能直接安装的同一个插件根目录。' titleSuffix: ' - 面向 Claude Code、Codex 与 Cursor 的 Agent 插件编译器' hero: name: agent-bundle text: 一份带类型的配置,适配所有 Agent 宿主 - tagline: 一次性描述 Skill、钩子、MCP 服务器与脚本,编译出可直接安装到 Claude Code、Codex 与 Cursor 的产物。 + tagline: 一次性描述 Skill、钩子、MCP 服务器与脚本,编译出 Claude Code、Codex 与 Cursor 都能直接安装的同一个插件根目录。 image: src: /logo.svg alt: agent-bundle 徽标 @@ -60,19 +60,19 @@ features: link: /zh/guide/development/evaluations span: 4 - icon: 📦 - title: 每个 target 都能独立交付 - details: 已构建的 target 目录就是你安装的那个单位——它自带宿主清单与生成的 INSTALL.md。旁边的产物根目录保存着 agent-bundle.manifest.json,即校验、MCP、钩子与评测所读取的 SHA-256 记录。 + title: 一个根目录,所有宿主 + details: 产物根目录就是你安装的那个单位——每个所选宿主的清单各自放在自己的点目录中,共用同一套 Skill、钩子与 MCP 入口,旁边是生成的 INSTALL.md 与 agent-bundle.manifest.json,即校验、MCP、钩子与评测所读取的 SHA-256 记录。 link: /zh/guide/distribution/ span: 4 --- -import { Steps, Tab, Tabs } from '@rspress/core/theme'; +import { Steps } from '@rspress/core/theme'; ## 你写下什么,得到什么 -输入是一份配置文件加一棵按约定组织的 `src/` 目录树。输出是每个宿主各一个可直接安装的目录, -各自带有自己的宿主清单、生成的包装脚本,以及用捆绑包真实名称写成的安装说明——它们共同位于一个产物根目录之下, -根目录还保存着整个产物据以校验的 `agent-bundle.manifest.json`。 +输入是一份配置文件加一棵按约定组织的 `src/` 目录树。输出是一个复合插件根目录:每个所选宿主的清单 +各自位于自己的点目录中,覆盖在共享的组件目录之上,外加生成的包装脚本、用捆绑包真实名称写成的 +安装说明,以及整个产物据以校验的 `agent-bundle.manifest.json`。所有宿主安装的都是这同一个目录。
@@ -113,68 +113,47 @@ Skill、MCP 服务器与脚本都按约定被发现。只有钩子需要声明 ### 编译器输出 - - - -```text title="artifact/claude/" -artifact/claude/ +```text title="artifact/" +artifact/ ├── .claude-plugin/ -│ ├── plugin.json +│ ├── plugin.json # Claude Code 清单 │ └── marketplace.json -├── .mcp.json +├── .codex-plugin/ +│ ├── plugin.json # Codex 清单 +│ ├── hooks.json # Codex 钩子文档 +│ └── mcp.json # Codex MCP 文档 +├── .agents/plugins/marketplace.json # Codex marketplace +├── .mcp.json # Claude Code MCP 文档 +├── plugin.json # portable(Agent Plugins)清单 +├── mcp.json # portable MCP 文档 ├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs +│ ├── hooks.json # Claude Code 钩子文档 +│ ├── session-start-….claude.mjs # 钩子到达的每个宿主各一个包装脚本 +│ ├── session-start-….codex.mjs +│ └── hooks-flight.mjs +├── mcp/ +│ ├── mcp-status-….mjs # 只编译一次,所有宿主共用 +│ └── mcp-status-…-flight.mjs ├── scripts/check-service.mjs ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md -└── INSTALL.md +├── INSTALL.md # 每个所选宿主一节 +├── install.mjs # portable 与 Cursor 的安装器 +├── agent-bundle.manifest.json # 每个输出文件及其 SHA-256 +└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 ``` - - +每个所选宿主都把这同一个目录当作自己的插件根目录来读取:Claude Code 找到 `.claude-plugin/`、 +`hooks/hooks.json` 与 `.mcp.json`;Codex 找到 `.codex-plugin/` 与 `.agents/` 下的 marketplace; +Agent Plugins 客户端找到 `plugin.json` 与 `mcp.json`。Skill、编译后的 MCP 入口与脚本只输出一次, +从不按宿主复制。 -```text title="artifact/codex/" -artifact/codex/ -├── .codex-plugin/plugin.json -├── .agents/plugins/marketplace.json -├── .mcp.json -├── hooks/ -│ ├── hooks.json -│ └── session-start-….mjs -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -└── INSTALL.md -``` +Agent Plugins 开放标准没有钩子这一表面。上面的钩子没有声明 `targets`,因此只继承所选 target 中支持钩子的 +那些——`claude` 与 `codex`——并为它到达的每个宿主各编译一次,所以这里出现两个包装脚本。`portable` +会被跳过且不产生诊断;若显式写出 `targets: ['portable']`,构建时则会得到 `AB4204`。 - - - -```text title="artifact/portable/" -artifact/portable/ -├── plugin.json -├── mcp.json -├── mcp/mcp-status-….mjs -├── scripts/check-service.mjs -├── skills/release-review/ -│ ├── SKILL.md -│ └── references/policy.md -├── install.mjs -└── INSTALL.md -``` - -Agent Plugins 开放标准没有钩子这一表面。上面的钩子没有声明 `targets`,因此只继承所选 target 中支持钩子的那些,`portable` -在这里会被跳过且不产生诊断。若显式写出 `targets: ['portable']`,构建时则会得到 `AB4204`。 - - - - -生成的包装脚本文件名以一段短摘要结尾,摘要来自编译它的声明,而非文件内容。`artifact/agent-bundle.manifest.json` +生成的包装脚本文件名带有一段短摘要,摘要来自编译它的声明,而非文件内容。`agent-bundle.manifest.json` 记录每个输出文件及其 SHA-256,因此后续校验比对的是真实字节,而不是检查某个路径是否存在。
@@ -202,24 +181,28 @@ CLI 安装的捆绑包——并运行结果为通过、失败或不确定的[评 ### 交付 -`agent-bundle build` 校验项目并为每个 target 写出一个目录。[校验](/zh/guide/distribution/validation) -依据清单检查产物,[安装](/zh/guide/distribution/installation)则走每个宿主自己的安装路径。 +`agent-bundle build` 校验项目并写出一个复合插件根目录。[校验](/zh/guide/distribution/validation) +依据清单检查产物,[安装](/zh/guide/distribution/installation)则让每个宿主自己的安装路径指向这同一个 +根目录。 ## 一份源码,所有宿主 -| Target | 输出内容 | 安装方式 | +`targets` 选择这一个根目录承载哪些宿主投影;省略它时根目录只承载 `portable`。下表每一行读取的都是 +同一个目录——不同的只是宿主寻找的清单与安装动词。 + +| Target | 为根目录添加的内容 | 安装方式 | | --- | --- | --- | -| `claude` | Claude Code 插件布局,含插件清单与本地 marketplace 清单。 | `claude plugin marketplace add` 与 `claude plugin install`,或 `agent-bundle install claude`。 | -| `codex` | Codex 插件布局,含插件清单与本地 marketplace 清单。 | `codex plugin marketplace add` 与 `codex plugin add`,或 `agent-bundle install codex`。 | -| `cursor` | Cursor 插件布局。 | 生成的 `install.mjs`,或 `agent-bundle install cursor`。 | -| `portable` | [Agent Plugins](https://agent-plugins.org) 开放标准——Skill 与 MCP 服务器——Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 原生读取。 | 生成的 `install.mjs`。 | -| `plugin` | 一个多宿主捆绑包,在共享的组件目录之上同时携带 Claude、Codex 与 Cursor 清单。 | `install.mjs` 或任一宿主 CLI。 | +| `claude` | `.claude-plugin/plugin.json` 与本地 `marketplace.json`,以及 `hooks/hooks.json` 与 `.mcp.json`。 | `claude plugin marketplace add` 与 `claude plugin install`,或 `agent-bundle install claude --from artifact`。 | +| `codex` | `.codex-plugin/plugin.json` 及其旁边的 `hooks.json` 与 `mcp.json`,以及 `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` 与 `codex plugin add`,或 `agent-bundle install codex --from artifact`。 | +| `cursor` | `.cursor-plugin/plugin.json` 及其旁边的 `hooks.json` 与 `mcp.json`。 | 生成的 `install.mjs`,或 `agent-bundle install cursor --from artifact`。 | +| `portable` | [Agent Plugins](https://agent-plugins.org) 开放标准——覆盖 Skill 与 MCP 服务器的 `plugin.json` 与 `mcp.json`——Cursor、Codex、VS Code、GitHub Copilot、Kiro 与 ChatGPT 原生读取。 | 生成的 `install.mjs`。 | 各宿主能加载的内容不同,编译器会在构建时明确指出:你为某个 target 选择了它无法表达的表面,就会得到一条 被报告的诊断,绝不会被悄悄省略。唯一有意为之的例外是没有自己的 `targets` 的钩子:它只继承支持钩子的 -宿主——正如上面的 `portable` 标签页所示——而不是让构建失败。每条诊断都有稳定的 `AB` 代码,记录在 +宿主——正如上面的目录树所示——而不是让构建失败。两个所选宿主若要以不同字节写出同一路径,就无法共用 +一个根目录,构建会以 `AB4103` 明确指出。每条诊断都有稳定的 `AB` 代码,记录在 [诊断参考](/zh/reference/diagnostics)中。 ## 从这里开始 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index dcd10ee17..22c8fb645 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -37,12 +37,12 @@ npx agent-bundle --version | `--root ` | `process.cwd()` | 项目根目录。 | | `--config ` | —— | 相对 `--root` 的配置文件。 | | `--mode ` | `production` | 配置模式。 | -| `--target ` | 全部已配置 | 要选择的 target。可重复。 | +| `--target ` | 配置中的 `targets`,否则为 `portable` | 要选择的宿主投影:`claude`、`codex`、`cursor` 或 `portable`。可重复;本次运行中该选择替换配置里的 `targets`,无论顺序如何,所有所选宿主都落入同一个组合根目录。其他任何名字(包括 `plugin`)都是 `AB4100`。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | **产物命令** —— `mcp list`、`mcp invoke`、`mcp run`、`serve-app`、`hooks list`、`hooks simulate`:同样的 `--root`、`--config`、`--mode` 与 `--json`,外加 `--artifact `(精确使用某一份已构建产物)与 -`--target `(选择产物 target)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` +`--target `(选择宿主投影)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` 来说,`--target` 是**必填**的;`serve-app` 默认取 `portable`,且不接受 `--json`。 ## dev @@ -113,32 +113,33 @@ loopback origin 上。它是本地预览宿主,不是部署目标。面向脚 | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--output ` | 配置中的 `output.distPath`,否则为 `artifact` | 相对 `--root` 的产物输出路径。`dist` 属于包构建。 | +| `--output ` | 配置中的 `output.distPath`,否则为 `artifact` | 相对 `--root` 的组合插件根目录:每个所选宿主都读取的那一个目录,其下没有逐宿主子目录。`dist` 属于包构建。 | -当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。 +当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。根目录的布局见 +[Target 与产物](./targets-artifacts.mdx)。 仅 `build` 还接受: | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--host-validation` / `--no-host-validation` | 开启 | 产物写出后,对每个已构建的 `claude` 与 `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 ] \ +agent-bundle install [--from ] [--scope ] [--mode ] \ [--replace] [--json] ``` | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | target 捆绑包目录或产物根目录。 | +| `--from ` | `process.cwd()` | 产物根目录。宿主清单必须直接位于其下(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`);不存在 `/` 查找,所有宿主都从同一个目录安装(清单缺失时为 `AB7001`)。 | | `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Codex 与 Cursor 是 user 作用域。 | | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | @@ -153,14 +154,14 @@ agent-bundle install [--from ] [--scope ] [--mode [--from ] [--scope ] [--mode ] \ +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` 目录)。这是默认行为;该标志只是显式声明。 | @@ -185,7 +186,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | -| `--from ` | —— | 用于比对的 target 捆绑包目录或产物根目录。 | +| `--from ` | —— | 用于比对的产物根目录;每个被检查宿主的清单都必须直接位于其下。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | 当 Doctor 报告任何 error 级诊断时以 `1` 退出,并且绝不修改任何东西。带 `--from` 时,它按宿主把已安装副本报告为 @@ -210,7 +211,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`)。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | ## eval @@ -232,7 +233,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 标志 | 聚焦于 | | --- | --- | -| `--bundler` | 合成出来的打包器配置,无论 `NODE_ENV` 为何,都按生产构建的方式降级。 | +| `--bundler` | 合成出来的打包器配置,无论 `NODE_ENV` 为何,都按生产构建的方式降级。它的 `distPath.root` 就是产物输出 ``,因为每个所选投影都编译进同一个组合根目录。 | | `--hooks` | 钩子。 | | `--routes` | 编译后的路由图。 | | `--skills` | Skill。 | diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index d68899e14..511e17d01 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -79,6 +79,13 @@ export default defineConfig({ 会报告 `AB4008` 警告,而任何地方都没有版本的发布构建会被 `AB4013` 拒绝,而不是交付 `0.0.0-dev.` 这个开发期回退值。该字段仅为兼容而保留,并将按照正常的破坏性变更策略移除。 +## targets + +`build` 铺进组合插件根目录的宿主投影:`claude`、`codex`、`cursor` 与 `portable` 的任意组合。顺序无关—— +选择会按名称排序,因此 `['codex', 'claude']` 与 `['claude', 'codex']` 构建出相同的字节。省略该键时只输出 +`portable` 投影。命令行上的 `--target` 会在单次运行中替换配置里的列表。其他任何名字(包括 `plugin`)都是 +未知 target(`AB4100`)。每个投影在根目录中放置什么,见 [Target 与产物](./targets-artifacts.mdx)。 + ## hooks 键是七个标准事件:`sessionStart`、`beforeTool`、`afterTool`、`stop`、`agentStart`、`agentStop`、 @@ -92,7 +99,8 @@ export default defineConfig({ ## output 与 runtime -`output.distPath` 是相对项目根目录的产物输出目录。命令行(`build`、`prepack`、`dev`)把它默认为 `artifact`, +`output.distPath` 是相对项目根目录的产物输出目录——每个所选宿主都读取的组合插件根目录,其下没有逐宿主 +子目录。命令行(`build`、`prepack`、`dev`)把它默认为 `artifact`, 因为命令行同时运行包构建,而包构建拥有 `dist/`;编程式 `build()` 除非传入 `packageOutputs: true`,否则默认为 `dist`。逐次调用的 `--output` 标志优先,但同样 受相同的项目根包含性检查约束;绝对或外部输出路径、形态无效的路径,或落入保留编译器命名空间的路径 diff --git a/website/docs/zh/reference/index.mdx b/website/docs/zh/reference/index.mdx index 51472f5fe..484a679a7 100644 --- a/website/docs/zh/reference/index.mdx +++ b/website/docs/zh/reference/index.mdx @@ -14,7 +14,7 @@ description: 'agent-bundle 参考资料:命令行表面、配置字段、targe | --- | --- | | [命令行](./cli.mdx) | 每条命令、参数、选项、默认值与退出码。 | | [配置](./configuration.mdx) | `agent-bundle.config.ts` 的每个字段、类型与校验规则。 | -| [Target 与产物](./targets-artifacts.mdx) | 各 target 输出什么,以及产物清单契约。 | +| [Target 与产物](./targets-artifacts.mdx) | 复合插件根目录、各宿主投影向其中添加什么,以及产物清单契约。 | | [宿主能力矩阵](./hosts.md) | 固定的各宿主能力表:版本、清单、安装方式、路径 token、MCP 传输、插件组件。构建时生成。 | | [事件与钩子矩阵](./events.md) | 各宿主的规范事件到原生事件、工具选择器到原生匹配器、被推迟的原生事件。构建时生成。 | | [通知投递矩阵](./notices.md) | 每个宿主支持哪些通知通道,其余通道为何不可用。构建时生成。 | diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 95f6577e4..f1c227bad 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -12,8 +12,8 @@ description: '生成式 agent-bundle 可执行文件的运行时契约:Node.js ## 宿主路径 token -生成的文档通过各宿主会替换的那个 token 引用插件根目录,编译器按 target 写出正确的拼写,而不是假定只有 -一种: +生成的文档通过各宿主会替换的那个 token 引用插件根目录——即每个所选宿主都加载的组合根目录——编译器把 +正确的拼写写进各宿主自己的文档,而不是假定只有一种: | 宿主 | 插件根 | 插件数据 | | --- | --- | --- | @@ -35,10 +35,9 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | 变量 | 由谁读取 | 含义 | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录与持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录——同时也是持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | -| `AGENT_BUNDLE_HOOK_HOST` | 生成的钩子 wrapper | 显式指定声明的宿主,而不去探测。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的钩子 playground 会设置它。 | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | 贡献者测试套件 | `1` 用于比对已安装宿主 CLI 的契约。 | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | 贡献者测试套件 | `1` 用于运行已登录的 Claude 原生冒烟测试。 | @@ -86,7 +85,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后 -启动」,请传入指向产物 target 根目录的 `--plugin-root `。 +启动」,请传入指向产物根目录本身——组合插件根目录,而不是某个宿主子目录——的 `--plugin-root `。 当服务器名是单个安全路径段时,逐服务器状态目录直接使用该名字;其他任何名字都会变成内容寻址的 `server-` 段,因此像 `../shared` 这样的名字绝不可能穿出状态根目录。 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index ff086ff47..10b52d471 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -1,32 +1,127 @@ --- -description: '各 agent-bundle target 输出什么,以及产物清单契约:文件种类、摘要、源输入、target schema 固定值与已记录的校验状态。' +description: 'agent-bundle build 输出的组合插件根目录:各宿主投影把清单、钩子与 MCP 文档放在何处、钩子 wrapper 的命名规则、AB4103/AB4105/AB4106 组合规则,以及产物清单契约。' --- # Target 与产物 -target 表格——各 target 输出哪种布局,以及 portable 标准为何省略规则、命令与钩子——在 -[配置模型](../guide/authoring/index.mdx)中。目录布局在 -[项目结构](../guide/start/project-structure.mdx)中。本页讲的是这些布局必须满足的产物契约。 +target 表格——各宿主投影携带什么,以及 portable 标准为何省略规则、命令与钩子——在 +[配置模型](../guide/authoring/index.mdx)中。源码树布局在[项目结构](../guide/start/project-structure.mdx)中。 +本页讲的是输出契约:`build` 输出的那一个目录,以及其中的字节必须满足的产物清单。 + +## 组合插件根目录 + +`agent-bundle build` 在产物输出位置写出**一个目录**——默认是 `artifact/`;`output.distPath` 或 `--output` +可以移动它。`targets` 选择铺进该根目录的**宿主投影**:`claude`、`codex`、`cursor` 与 `portable`,可任意 +组合。每个所选宿主都把同一个目录当作自己的插件根目录来读取;没有需要把宿主指向的逐宿主子目录。 + +- 省略 `targets`——配置与命令行都省略——时,构建只输出 `portable` 投影。 +- 顺序无关。`['codex', 'claude']` 与 `['claude', 'codex']` 产出逐字节相同的输出,规范化模型、产物清单与 + `inspect` 都按名称排序列出所选宿主。 +- `plugin` 不是 target。`targets: ['plugin']` 或 `--target plugin` 是未知 target(`AB4100`):组合根目录 + 本来就是每次构建的输出。 + +以全部四个投影构建时,host-test 示例的根目录如下(组件目录只在项目编写了对应组件时才会出现): + +```text +artifact/ +├── .agents/plugins/marketplace.json # Codex 市场 +├── .claude-plugin/plugin.json # Claude Code 清单 +├── .claude-plugin/marketplace.json +├── .codex-plugin/plugin.json # Codex 清单 +├── .codex-plugin/hooks.json # Codex 钩子文档 +├── .codex-plugin/mcp.json # Codex MCP 文档 +├── .cursor-plugin/plugin.json # Cursor 清单 +├── .cursor-plugin/marketplace.json +├── .cursor-plugin/hooks.json # Cursor 钩子文档 +├── .cursor-plugin/mcp.json # Cursor MCP 文档 +├── .mcp.json # Claude Code MCP 文档 +├── plugin.json # portable(Agent Plugins)清单 +├── mcp.json # portable MCP 文档 +├── hooks/hooks.json # Claude Code 钩子文档 +├── hooks/.mjs # wrapper:钩子只被一个所选宿主选中 +├── hooks/..mjs # wrapper:钩子被多个所选宿主共享 +├── hooks/hooks-flight.mjs +├── mcp/mcp--.mjs # 编译后的 MCP 入口(+ -flight.mjs) +├── bin/.mjs, bin/-flight.mjs # 路由式 CLI(存在 src/cli 时) +├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 只输出一次 +├── INSTALL.md # 选中了任一内置宿主时 +├── install.mjs # 选中了 cursor 或 portable 时 +├── agent-bundle.manifest.json # 所选投影 + 来源信息 +└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +``` + +宿主清单位于根目录下各自的点目录中。`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 是共享的, +只输出**一次**——没有逐宿主副本。根目录下不会出现其他任何东西:没有生成的 `AGENTS.md`,也没有 +`hooks/hooks-cursor.json`。 + +### 各宿主从哪里读取文档 + +| 宿主 | 清单 | 钩子文档 | MCP 文档 | 市场 | +| --- | --- | --- | --- | --- | +| Claude Code | `.claude-plugin/plugin.json` | `hooks/hooks.json` | `.mcp.json` | `.claude-plugin/marketplace.json` | +| Codex | `.codex-plugin/plugin.json` | `.codex-plugin/hooks.json` | `.codex-plugin/mcp.json` | `.agents/plugins/marketplace.json` | +| Cursor | `.cursor-plugin/plugin.json` | `.cursor-plugin/hooks.json` | `.cursor-plugin/mcp.json` | `marketplace: true` 时为 `.cursor-plugin/marketplace.json` | +| portable | `plugin.json` | —— | `mcp.json` | —— | + +无论选择了哪些宿主,这些路径都是固定的,因此单宿主根目录与四宿主根目录共用同一种布局。Claude Code 与 +portable 的 Agent Plugins 格式从约定的插件根位置加载文档,无法重定向;Codex 与 Cursor 的清单携带显式的 +`hooks` 与 MCP 指针,因此它们的文档紧挨着各自的清单。这两个宿主在指针缺失时还会回退到对约定路径的目录 +发现,因此当另一个所选宿主占用了约定路径时,没有自己文档的 Codex 或 Cursor 投影仍会指向一份空文档—— +Cursor 绝不会加载 Claude Code 的 `hooks/hooks.json`。 + +### 钩子 wrapper + +钩子 wrapper 会把它所面向的宿主(该宿主的编解码、`target`、宿主契约修订)烤进自身,因此一个 wrapper +无法同时服务两个宿主: + +- 只触达一个所选宿主的钩子保留普通名字 `hooks/.mjs`。 +- 触达多个所选宿主的钩子按宿主各输出一次,即 `hooks/..mjs`,各宿主的钩子文档指向自己的 + 那个 wrapper。 + +钩子触达哪些宿主,由它的 `targets`(默认是全部 target)与所选宿主取交集决定,因此同一个源钩子在仅 +`claude` 的根目录中是 `hooks/audit.mjs`,在 `claude` + `cursor` 的根目录中则是 `hooks/audit.claude.mjs` +加 `hooks/audit.cursor.mjs`。每个所选宿主的原生钩子都会保留。`agent-bundle.hooks.json` 为所选宿主的 +wrapper 建立索引。 + +### 编译产出面 + +MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属于**组合身份**——所选宿主名按名称排序、以 +`+` 连接,例如 `claude+codex`——而不是归属于任何单个宿主。`agent-bundle inspect --bundler` 显示的是同一 +件事:它的 `distPath.root` 就是产物输出 ``,其下没有宿主段。 + +### 一个根目录,一套字节 + +按路径合并投影,只有在各宿主对字节达成一致时才行得通。三条规则守住根目录的诚实,它们都是错误,且 +`validate` 与 `inspect` 会在 `build` 拒绝的同一位置报告它们: + +| 代码 | 规则 | +| --- | --- | +| `AB4103` | 两个所选投影为同一路径规划了不同的字节。投影按宿主名顺序比较、路径按路径顺序比较,因此无论 `targets` 怎么写,同一选择报告的都是同一处冲突。常见原因是 frontmatter 带有宿主扩展(`targets: { claude: … }`)的 Skill:它为 Claude Code 降级出的 `skills//SKILL.md` 字节与其他宿主不同。请让该组件对每个所选宿主都一致,或把冲突的宿主构建进单独的产物。 | +| `AB4105` | 一个只面向部分所选宿主的组件——frontmatter 带 `targets` 的命令或规则——位于另一个所选宿主会扫描的约定目录中(Claude Code 与 Cursor 的 `commands/`、Cursor 的 `rules/`)。在同一个根目录里无法把该文件对那个宿主隐藏起来,因此构建宁可拒绝也不泄漏它。请把 `targets` 扩展到每个会发现该目录的所选宿主,或把这些宿主分开构建。Skill 从不按宿主限定:每个 skill 都会交付给所有所选宿主,会改变其字节的按宿主 frontmatter 扩展属于 `AB4103` 冲突。 | +| `AB4106` | 所选目标把注册在高级 `TargetRegistry` 上的适配器——即任何其适配器不是随框架发布的 `claude`、`codex`、`cursor`、`portable` 适配器的目标,按适配器身份而非名称判断,因此以这些名字注册的自定义适配器同样算作高级适配器——与另一个目标混在一起。只有内建宿主就各自不共享的文档放在何处、各自扫描哪些目录以及同一套安装面达成了一致,因此第三方适配器要单独构建:`targets: ['']` 输出到自己的 `--output`。只选一个目标时永不触发。 | + +三者与 `AB4100` 一起列在[诊断参考](./diagnostics.md)中。 -## 每个 target 目录都可分发 +## 根目录即可分发 -已构建的 target 目录就是分发单位:构建之后没有打包步骤。每个目录都携带宿主要读取的组件、一份使用捆绑包 -真实插件名与市场名写成的生成式 `INSTALL.md`,以及该 target 所需的安装表面。 +组合根目录就是分发单位:构建之后没有打包步骤。它携带所选宿主要读取的组件、一份生成的 `INSTALL.md` +——每个所选宿主一节,以捆绑包真实的插件名与市场名写成——以及这些宿主所需的安装表面,为整个选择只输出 +一次: -| Target | 市场清单 | 安装表面 | +| 所选宿主 | 市场清单 | 安装表面 | | --- | --- | --- | -| `claude` | 本地市场清单。 | `claude plugin marketplace add` + `claude plugin install`。 | -| `codex` | 本地市场清单。 | `codex plugin marketplace add` + `codex plugin add`。 | -| `cursor` | `marketplace: true` 时生成本地市场清单(`.cursor-plugin/marketplace.json`)。 | `install.mjs`。 | +| `claude` | `.claude-plugin/marketplace.json`。 | `claude plugin marketplace add` + `claude plugin install`。 | +| `codex` | `.agents/plugins/marketplace.json`。 | `codex plugin marketplace add` + `codex plugin add`。 | +| `cursor` | `marketplace: true` 时为 `.cursor-plugin/marketplace.json`。 | `install.mjs`。 | | `portable` | —— | `install.mjs`。 | -| `plugin` | Claude、Codex 与 Cursor 三份清单,外加一份生成的 `AGENTS.md`。 | `install.mjs` 与各宿主 CLI。 | -当某个 target 缺少必需的安装表面文件时,产物校验会报错,因此捆绑包不可能在缺少其 `INSTALL.md` 所承诺的 -安装器的情况下发布。npm pack 清单会检查同样的路径(`AB7010`)。 +只要选中了内置宿主就会写出 `INSTALL.md`;其中包含 `cursor` 或 `portable` 时还会写出 `install.mjs`。 +当必需的安装表面文件缺失时,产物校验会报错,因此根目录不可能在缺少其 `INSTALL.md` 所承诺的安装器的 +情况下发布。npm pack 清单会检查同样的路径(`AB7010`)。 ## agent-bundle.manifest.json -每份产物输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 +每个根目录输出一份清单,它是之后每一项完整性检查的输入 —— `validate --artifact`、`prepack`、 `doctor --from`,以及 packed 与 installed-host 证明级别。 | 字段 | 内容 | @@ -35,9 +130,9 @@ target 表格——各 target 输出哪种布局,以及 portable 标准为何 | `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、经过校验的 `packageName` 与 `packageVersion`(未打包的开发项目中不存在),以及 `sourceInputs`。 | | `files` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | | `runtime` | `{ node }` —— 所选的生成式可执行文件下限。 | -| `targets` | 每个 target 的:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | +| `targets` | 每个所选投影一行,按名称排序:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | | `agentSkills` | Agent Skills 规范固定值:`specification`、`sourceRevision`、`schemaSha256`。 | -| `validation` | `source`、`artifact` 与各 target 的已记录状态。 | +| `validation` | `source`、`artifact` 与各所选投影的已记录状态。 | `kind` 取值为 `bundle`、`copy`、`generated` 或 `prebuilt` —— 分别是打包入口、按字节复制、编译器生成的 文档,或预构建 payload 文件。`sourceInputs` 记录每个输入路径及其自身的 SHA-256,并在相关时记录它是否 @@ -68,4 +163,5 @@ target 行还记录 `adapterRevision`(单调递增,仓库自有)与 `obser ## 下一步 - [产物校验](../guide/distribution/validation.mdx) —— 读取这份清单的那些检查。 +- [宿主安装](../guide/distribution/installation.mdx) —— 把根目录安装进各个宿主。 - [运行时环境](./runtime-environment.mdx) —— 输出的可执行文件在运行时假定了什么。