diff --git a/.prettierignore b/.prettierignore index fa5f803..94e6de5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,5 @@ examples/glean/plugins/ examples/basic/dist/ package-lock.json tests/fixtures/cursor/*.schema.json +tests/fixtures/agent-plugins/*.schema.json CHANGELOG.md diff --git a/CLAUDE.md b/CLAUDE.md index c62009d..e94264c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,10 @@ diff, prune, and validate all derive from it. ## Targets -`copilot`, `antigravity`, `cursor`, `claude`, `codex`. Adding a target means one +`copilot`, `antigravity`, `cursor`, `claude`, `codex`, `agent-plugins`. `codex` +and `agent-plugins` emit Agent Plugins packages through `src/agent-plugins.ts` +(the package module; marketplaces stay on each target). Each target's MCP config +is rendered by `src/mcp.ts` in the dialect its `mcpDialect` names. Adding a target means one new file implementing `PluginTargetDefinition` (`src/targets/.ts`) plus one new entry in `src/targets/registry.ts` — `TargetName` (`types.ts`) is still a separate union to extend, but everything else (CLI `--target` choices, `build()`'s @@ -92,7 +95,8 @@ target set, emit/validate dispatch) derives from the registry automatically. Schema for any target, so the harness uses the strongest available oracle per target: Cursor against vendored published schemas (`tests/fixtures/cursor/`, provenance in `SOURCE.md`); Claude via `claude plugin validate --strict` (when -the CLI is present); Copilot and Antigravity structurally against their real +the CLI is present); Agent Plugins (and the Codex package) against the vendored 1.0 schemas +(`tests/fixtures/agent-plugins/`); Copilot and Antigravity structurally against their real formats (`github/copilot-plugins`, Antigravity CLI plugin docs). Don't fetch schemas at runtime — vendor a pinned copy with recorded provenance. diff --git a/CONFORMANCE.md b/CONFORMANCE.md index d33fca0..b0fa382 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -5,16 +5,17 @@ format. ## Why this is hard -There is no single, referenceable, upstream JSON Schema for any supported target. -Each app's source of truth is something other than a stable schema URL: - -| Target | Canonical source of truth | Referenceable schema? | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `claude` | `claude plugin validate` CLI + [plugins-reference docs](https://code.claude.com/docs/en/plugins-reference) | **No.** The `$schema` URL the manifest declares (`https://anthropic.com/claude-code/marketplace.schema.json`) returns 404. | -| `cursor` | Glean-authored schemas in `gleanwork/cursor-plugins/schemas/` | **No upstream.** The schema `$id` (`https://cursor.com/schemas/cursor-plugin/...`) 500s; no Cursor-published schema found. | -| `antigravity` | Antigravity CLI plugin docs (`plugin.json`, optional `mcp_config.json`) | **No.** Defined by product docs and observed CLI layout, not a published schema. | -| `copilot` | [`github/copilot-plugins`](https://github.com/github/copilot-plugins) — a Claude-marketplace-derived format | **Structural.** Copilot shares the Claude marketplace base but extends entries (`skills[]`, `mcpServers` as a path), which `claude plugin validate` rejects — so conformance is asserted structurally against the official format. | -| `codex` | [OpenAI Codex CLI plugin docs](https://developers.openai.com/codex/plugins/build) (`.codex-plugin/plugin.json` + `.agents/plugins/marketplace.json`) | **No published schema.** Defined by product docs; conformance is asserted structurally against the documented format (retrieved 2026-07-26). | +Agent Plugins is the only format with a referenceable upstream JSON Schema. Every +other target's source of truth is something other than a stable schema URL: + +| Target | Canonical source of truth | Referenceable schema? | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `claude` | `claude plugin validate` CLI + [plugins-reference docs](https://code.claude.com/docs/en/plugins-reference) | **No.** The `$schema` URL the manifest declares (`https://anthropic.com/claude-code/marketplace.schema.json`) returns 404. | +| `cursor` | Glean-authored schemas in `gleanwork/cursor-plugins/schemas/` | **No upstream.** The schema `$id` (`https://cursor.com/schemas/cursor-plugin/...`) 500s; no Cursor-published schema found. | +| `antigravity` | Antigravity CLI plugin docs (`plugin.json`, optional `mcp_config.json`) | **No.** Defined by product docs and observed CLI layout, not a published schema. | +| `copilot` | [`github/copilot-plugins`](https://github.com/github/copilot-plugins) — a Claude-marketplace-derived format | **Structural.** Copilot shares the Claude marketplace base but extends entries (`skills[]`, `mcpServers` as a path), which `claude plugin validate` rejects — so conformance is asserted structurally against the official format. | +| `codex` | [OpenAI plugin packaging docs](https://developers.openai.com/codex/plugins/build): an Agent Plugins package + `.agents/plugins/marketplace.json` | **Package: yes** (Agent Plugins 1.0 schemas, vendored). **Marketplace: no** published schema; asserted structurally against the docs (retrieved 2026-10-02). | +| `agent-plugins` | [Agent Plugins Specification 1.0.0](https://agent-plugins.org/specification) | **Yes.** `schemas/1.0.0/plugin.schema.json` and `mcp.schema.json`, vendored in `tests/fixtures/agent-plugins/` with provenance in `SOURCE.md`. The spec text wins where they disagree. | ## Oracles the harness uses @@ -48,14 +49,23 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic). `tests/core.test.ts` (required `plugin.json` fields present; optional `mcp_config.json` written when MCP servers are present). Antigravity CLI does not expose a published schema to validate against. -- **codex** — asserted structurally in `tests/conformance.test.ts` and +- **agent-plugins** — the emitted `plugin.json` and `mcp.json` validate + against the vendored Agent Plugins 1.0 schemas (draft 2020-12, via ajv's 2020 + build), and `pluginpack validate` must pass on the same output. The shipped + validator (`src/agent-plugins.ts`) re-implements the closed-schema checks + without ajv, plus the semantic rules a JSON Schema can't express: skill names + per the Agent Skills specification and matching their directory, and MCP + server rules from spec §7.2 and §9 (`src/mcp.ts`). +- **codex** — the package half uses the Agent Plugins oracle above. The + marketplace half is asserted structurally in `tests/conformance.test.ts` and `tests/core.test.ts` against the - [documented Codex plugin format](https://developers.openai.com/codex/plugins/build) - (re-verified 2026-07-26 via direct fetch, twice, for consistency): a - repo-scoped `.agents/plugins/marketplace.json` (`{ name, interface, plugins }`, - no `owner` field) plus a per-plugin `.codex-plugin/plugin.json` where only - `name` is required — `version`/`description`/`skills`/`hooks`/`mcpServers` are - optional pointers to bundled components. Every marketplace entry must carry + [OpenAI packaging docs](https://developers.openai.com/codex/plugins/build) + (re-verified 2026-10-02): a repo-scoped `.agents/plugins/marketplace.json` + (`{ name, interface, plugins }`, no `owner` field). The default package is a + root `plugin.json` with OpenAI settings under `extensions.com.openai`. + `format: "legacy"` instead emits `.codex-plugin/plugin.json`, where only + `name` is required and `skills`/`hooks`/`mcpServers` are optional pointers; + `validate` detects either layout per plugin. Every marketplace entry must carry `policy.installation`, `policy.authentication`, and `category`; pluginpack has no way to infer these, so the base entry stays guess-free and `validateOutput` errors clearly if an author never supplies them via the per-plugin `entry` @@ -118,16 +128,20 @@ session, with no shell equivalent — but once a marketplace is already added, `claude plugin install @` does work as a standalone shell command, surfaced as a secondary `note`. -Every target resolves to `userConfigurable: true` today; -`getUnsupportedInstallTargets()` returns `[]`. The `false` branch of the -`InstallSnippet` union exists for forward-compatibility, not because any -target needs it now. +`agent-plugins` is the one target with `userConfigurable: false`. The +specification defines no marketplace or install command, and installation is +left to each client (for example, VS Code installs a plugin directly from a Git +repository URL: +, +verified 2026-10-02). ## Refreshing vendored schemas -The Cursor schemas are pinned copies. To update them, re-fetch from the source -recorded in `tests/fixtures/cursor/SOURCE.md`, then re-run the suite. Do not -hand-edit — they are an oracle. +The Cursor and Agent Plugins schemas are pinned copies. To update them, re-fetch +from the source recorded in `tests/fixtures//SOURCE.md`, then re-run the +suite. Do not hand-edit — they are an oracle. Published Agent Plugins schema +identifiers are never reassigned (spec §10.1), so a new specification version +means a new vendored directory, not an edit. ## Why vendor instead of fetch at runtime? diff --git a/README.md b/README.md index 2f6151c..3b9a5e1 100644 --- a/README.md +++ b/README.md @@ -247,18 +247,54 @@ export default defineConfig({ Each target compiles the same source into one app's native plugin layout: -| Target | Native format | Output it writes | -| ------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `cursor` | Cursor plugin + marketplace | `.cursor-plugin/marketplace.json`; a `.cursor-plugin/plugin.json` per plugin | -| `claude` | Claude plugin + marketplace | `.claude-plugin/marketplace.json`; a `.claude-plugin/plugin.json` per plugin | -| `antigravity` | Antigravity CLI plugin | a `plugin.json` per plugin + optional `mcp_config.json` (no marketplace) | -| `copilot` | [GitHub Copilot plugins](https://github.com/github/copilot-plugins) | `.claude-plugin/marketplace.json` mirrored to `.github/plugin/marketplace.json`; plugins under `plugins//` | -| `codex` | [OpenAI Codex CLI plugins](https://developers.openai.com/codex/plugins/build) | `.agents/plugins/marketplace.json`; a `.codex-plugin/plugin.json` per plugin + optional `.mcp.json` | +| Target | Native format | Output it writes | +| --------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `cursor` | Cursor plugin + marketplace | `.cursor-plugin/marketplace.json`; a `.cursor-plugin/plugin.json` per plugin | +| `claude` | Claude plugin + marketplace | `.claude-plugin/marketplace.json`; a `.claude-plugin/plugin.json` per plugin | +| `antigravity` | Antigravity CLI plugin | a `plugin.json` per plugin + optional `mcp_config.json` (no marketplace) | +| `copilot` | [GitHub Copilot plugins](https://github.com/github/copilot-plugins) | `.claude-plugin/marketplace.json` mirrored to `.github/plugin/marketplace.json`; plugins under `plugins//` | +| `codex` | [OpenAI Codex / ChatGPT plugins](https://developers.openai.com/codex/plugins/build) | `.agents/plugins/marketplace.json`; an [Agent Plugins](#agent-plugins) package per plugin (`plugin.json` + optional `mcp.json`) | +| `agent-plugins` | [Agent Plugins 1.0](https://agent-plugins.org/specification) | a portable package per plugin under `plugins//` (no marketplace) | > **Heads up:** `claude` and `copilot` both write `.claude-plugin/marketplace.json`, so they need distinct `outDir`s (or separate repos). `build` errors on overlapping output paths. New targets are added from official docs or real plugin examples — not guessed abstractions. +## Agent Plugins + +[Agent Plugins 1.0](https://agent-plugins.org/specification) is an open +specification for the plugin package itself: a closed `plugin.json` +manifest, Agent Skills under `skills/`, MCP servers in `mcp.json`, and +reverse-domain extension namespaces for anything client-specific. +Marketplaces, hooks, agents, commands, and rules are deliberately outside it. +Pluginpack emits packages in two ways: + +- **`codex`** emits a package per plugin and keeps Codex's + `.agents/plugins/marketplace.json`. Authored manifest fields that aren't + part of the portable manifest (`interface`, `apps`, `hooks`) move under + `extensions["com.openai"]` automatically, so an existing config needs no + changes. Set `format: "legacy"` on the target to keep the older + `.codex-plugin/plugin.json` layout for Codex builds without Agent Plugins + support, which landed across v0.146 and v0.147 (see + [ADR 0001](./docs/adr/0001-codex-emits-agent-plugins-packages.md)). +- **`agent-plugins`** emits portable packages only. It has no client profile, + so it ships `skills`, `assets`, static files, and `mcp`. Selecting `agents`, + `commands`, `rules`, or `hooks` is a config error, and a manifest field + outside the portable manifest is a build error. It writes no marketplace, + because the specification defines none; install each package with the + client's own flow. + +`validate` checks packages against the specification: the closed manifest, a +valid plugin name, `mcp.json` servers, and skill names that follow the +[Agent Skills specification](https://agentskills.io/specification) and match +their directory. Conforming clients skip a skill that fails these checks. + +**Upgrading from 0.11:** the `codex` target's output layout changes. Run +`pluginpack build`, and either publish the new layout or set +`format: "legacy"`. If `validate` reports skill names such as +`skill_name`, rename them before switching, because Agent Plugins clients +skip them. + ## Legacy 0.10 Source Composition Pluginpack 0.11 can still read `source.plugins`, `source.skills`, `rootPlugin`, @@ -286,7 +322,7 @@ The check follows update-notifier discipline: - Fail-open: offline, missing `git`, odd tags, or any other problem exits silently. - Skipped entirely when `CI` is set or `PLUGINPACK_NO_UPDATE_CHECK=1`. -Disable for a single plugin with `updateCheck: false` on that plugin. Configuring `updateCheck` on `copilot`, `antigravity`, or `codex` is a config error — those hosts don't run plugin hooks. +Disable for a single plugin with `updateCheck: false` on that plugin. Configuring `updateCheck` on `copilot`, `antigravity`, `codex`, or `agent-plugins` is a config error — those hosts don't run plugin hooks. Like MCP config, the generated hook is wired in regardless of a plugin's content selection: on `cursor`, the manifest's `hooks` field is set even if `include` does not name `"hooks"`, since the check itself is a separate opt-in. @@ -352,13 +388,21 @@ form (`type` optional, `${CLAUDE_PLUGIN_ROOT}`). Pluginpack renders it into each target's MCP dialect, so you don't need a per-target `config.json` override just to change a variable name: -| Target | File | Plugin root / data variables | Transport labels | -| ------------- | -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------ | -| `claude` | `.mcp.json` at the plugin root | `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` | `streamable-http` becomes `http` | -| `cursor` | `.mcp.json`, referenced from `plugin.json` | `${CURSOR_PLUGIN_ROOT}` / none (a build error) | `type` dropped for stdio and HTTP (Cursor infers them) | -| `copilot` | `.mcp.json`, referenced from the marketplace | `${PLUGIN_ROOT}` / as authored | `streamable-http` becomes `http` | -| `codex` | `.mcp.json`, referenced from `plugin.json` | as authored | as authored | -| `antigravity` | `mcp_config.json` beside `plugin.json` | as authored | as authored | +| Target | File | Plugin root / data variables | Transport labels | +| --------------- | -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------ | +| `claude` | `.mcp.json` at the plugin root | `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` | `streamable-http` becomes `http` | +| `cursor` | `.mcp.json`, referenced from `plugin.json` | `${CURSOR_PLUGIN_ROOT}` / none (a build error) | `type` dropped for stdio and HTTP (Cursor infers them) | +| `copilot` | `.mcp.json`, referenced from the marketplace | `${PLUGIN_ROOT}` / as authored | `streamable-http` becomes `http` | +| `codex` | `mcp.json` (Agent Plugins, with `$schema`) | `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` | explicit `type`: `stdio`, `streamable-http`, `sse` | +| `agent-plugins` | `mcp.json` (Agent Plugins, with `$schema`) | `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` | explicit `type`: `stdio`, `streamable-http`, `sse` | +| `antigravity` | `mcp_config.json` beside `plugin.json` | as authored | as authored | + +For Agent Plugins output, a `${}/bin/server` command becomes +`./bin/server`. Anything a conforming client would silently skip fails the +build instead: a shell string as `command`, a `${VAR}` other than +`${PLUGIN_ROOT}`/`${PLUGIN_DATA}` (left unexpanded), plain `http` to a +non-loopback host, or a `cwd` outside the plugin. With `format: "legacy"`, +`codex` writes `.mcp.json` as authored. A `./bin/server` command becomes `${}/bin/server` for targets whose clients don't resolve plugin-relative commands. Config already written in @@ -481,21 +525,22 @@ rest of the artifact. | `category` | string | Marketplace category. | | `tags` | string[] | Free-form tags. | -**`targets.`** — `` is one of `cursor`, `claude`, `antigravity`, `copilot`, `codex`. - -| Field | Type | Required | Meaning | -| ------------------ | ---------------------- | -------- | ------------------------------------------------------------------------------------------- | -| `outDir` | string | yes | Output directory for this target, relative to the config root. | -| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets..plugins.`**). | -| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). | -| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). | -| `version` | string | no | Override the version for this target (defaults to top-level `version`). | -| `manifest` | object | no | Deep-merged into the generated marketplace manifest. | -| `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). | -| `repositoryFiles` | string (safe relative) | no | Directory copied recursively into the generated repository root. | -| `rootFiles` | record (safe relative) | no | Legacy 0.10 output path → source path map; migrate to `repositoryFiles`. | -| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). | -| `repository` | string | no | Repo this target's output lives in, for `install-info` (defaults to `metadata.repository`). | +**`targets.`** — `` is one of `cursor`, `claude`, `antigravity`, `copilot`, `codex`, `agent-plugins`. + +| Field | Type | Required | Meaning | +| ------------------ | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------- | +| `outDir` | string | yes | Output directory for this target, relative to the config root. | +| `format` | `"agent-plugins"` \| `"legacy"` | no | `codex` only. `"legacy"` keeps the `.codex-plugin/plugin.json` layout; default `"agent-plugins"`. | +| `plugins` | record | yes | Emitted plugins, keyed by emitted plugin name (see **`targets..plugins.`**). | +| `marketplaceDir` | string (safe relative) | no | Override the marketplace dir (defaults: `.cursor-plugin` / `.claude-plugin`). | +| `pluginRoot` | string (safe relative) | no | Override the plugin root dir (`claude`; defaults to `plugins`). | +| `version` | string | no | Override the version for this target (defaults to top-level `version`). | +| `manifest` | object | no | Deep-merged into the generated marketplace manifest. | +| `ignoredDiffPaths` | string[] | no | Output-relative paths `diff` ignores (a dir entry ignores everything below it). | +| `repositoryFiles` | string (safe relative) | no | Directory copied recursively into the generated repository root. | +| `rootFiles` | record (safe relative) | no | Legacy 0.10 output path → source path map; migrate to `repositoryFiles`. | +| `updateCheck` | `{ repository? }` | no | Generate a session-start update-check hook (`claude`/`cursor` only; see **Update Check**). | +| `repository` | string | no | Repo this target's output lives in, for `install-info` (defaults to `metadata.repository`). | **`targets..plugins.`** @@ -559,7 +604,7 @@ The result and config types (`Artifact`, `DiffResult`/`DiffEntry`, `ValidationRe A few things worth knowing about this surface before depending on it: -- **The target set is closed.** `TargetName` is `"claude" | "cursor" | "antigravity" | "copilot" | "codex"` today, with no public API for registering a sixth target — adding one means a PR to this repo (see `PluginTargetDefinition` in `src/targets/types.ts`, not exported). There is no supported third-party target-extension mechanism. +- **The target set is closed.** `TargetName` is `"claude" | "cursor" | "antigravity" | "copilot" | "codex" | "agent-plugins"` today, with no public API for registering another target — adding one means a PR to this repo (see `PluginTargetDefinition` in `src/targets/types.ts`, not exported). There is no supported third-party target-extension mechanism. - **The package is ESM-only.** `package.json`'s `exports` map has no `require` condition; a CommonJS consumer needs dynamic `import()`. This is a deliberate choice, not a tsup default left unexamined. - **`Artifact.files` and `ResolvedProject.plugins` are `Map`s, not plain objects.** `JSON.stringify()` on either silently produces `{}` — iterate with `for...of`/`Object.fromEntries()` instead of serializing directly if you need to log or transport a result. @@ -589,7 +634,7 @@ Exit codes: Compile configured source plugins into target-native plugin payloads. ```bash -pluginpack build [--target copilot|antigravity|cursor|claude|codex] [--out-dir ] [--dry-run] +pluginpack build [--target copilot|antigravity|cursor|claude|codex|agent-plugins] [--out-dir ] [--dry-run] ``` Options: @@ -614,7 +659,7 @@ Exit codes: Validate an existing target output directory for native manifest, path, and frontmatter requirements. ```bash -pluginpack validate --target copilot|antigravity|cursor|claude|codex [--dir ] +pluginpack validate --target copilot|antigravity|cursor|claude|codex|agent-plugins [--dir ] ``` Options: @@ -636,7 +681,7 @@ Exit codes: Build into a temporary directory and compare generated managed files with an existing target repo. ```bash -pluginpack diff --target copilot|antigravity|cursor|claude|codex --against +pluginpack diff --target copilot|antigravity|cursor|claude|codex|agent-plugins --against ``` Options: @@ -658,7 +703,7 @@ Exit codes: Remove stale managed files that are no longer emitted by the current config. ```bash -pluginpack prune [--target copilot|antigravity|cursor|claude|codex] [--dry-run] +pluginpack prune [--target copilot|antigravity|cursor|claude|codex|agent-plugins] [--dry-run] ``` Options: @@ -682,7 +727,7 @@ Exit codes: Remove all managed files for configured target outputs. ```bash -pluginpack clean [--target copilot|antigravity|cursor|claude|codex] [--dry-run] +pluginpack clean [--target copilot|antigravity|cursor|claude|codex|agent-plugins] [--dry-run] ``` Options: @@ -706,7 +751,7 @@ Exit codes: Print the real install command or URL for a target's built marketplace. ```bash -pluginpack install-info [--target copilot|antigravity|cursor|claude|codex] [--json] +pluginpack install-info [--target copilot|antigravity|cursor|claude|codex|agent-plugins] [--json] ``` Options: diff --git a/docs/adr/0001-codex-emits-agent-plugins-packages.md b/docs/adr/0001-codex-emits-agent-plugins-packages.md index 13167ae..2db8fed 100644 --- a/docs/adr/0001-codex-emits-agent-plugins-packages.md +++ b/docs/adr/0001-codex-emits-agent-plugins-packages.md @@ -8,8 +8,8 @@ OpenAI's packaging docs make the Agent Plugins root `plugin.json` (OpenAI settings under `extensions.com.openai`) the preferred format and keep `.codex-plugin/plugin.json` only as a fallback. From 0.12.0 the `codex` target therefore emits Agent Plugins packages by default. A `format: "legacy"` option -keeps the old layout for one release window, for users on Codex older than -v0.146. +keeps the old layout for one release window, for users on Codex builds that +predate Agent Plugins support (it landed across v0.146 and v0.147). We deliberately do not emit both layouts in one build. A dual layout would need two MCP files in different shapes (`mcp.json` and `.mcp.json`) and a diff --git a/skills/authoring-pluginpack-config/SKILL.md b/skills/authoring-pluginpack-config/SKILL.md index c9b328f..3f8e7a9 100644 --- a/skills/authoring-pluginpack-config/SKILL.md +++ b/skills/authoring-pluginpack-config/SKILL.md @@ -82,8 +82,21 @@ at `plugins/copilot`). `pluginpack build` errors on overlapping output paths. ## MCP servers Add a `.mcp.json` (`{ "mcpServers": { "name": { ... } } }`) at the source plugin -root, or an `mcpServers` key in `plugin.pluginpack.json`. pluginpack wires it -into each target natively. +root, or an `mcpServers` key in `plugin.pluginpack.json`. pluginpack renders it +into each target's MCP dialect (plugin-root variable names and transport labels), +so author it once. Don't add a per-target copy just to change +`${CLAUDE_PLUGIN_ROOT}`. + +## Agent Plugins + +`codex` emits [Agent Plugins](https://agent-plugins.org/specification) packages +(root `plugin.json` + `mcp.json`). Codex-only manifest fields such as +`interface` move under `extensions["com.openai"]` automatically. Set +`format: "legacy"` on the codex target only to keep the old +`.codex-plugin/plugin.json` layout. The standalone `agent-plugins` target emits +portable packages with no marketplace; it can't include `agents`, `commands`, +`rules`, or `hooks`. Skill names must be lowercase letters, digits, and hyphens +and match their directory, or Agent Plugins clients skip them. ## Verify diff --git a/src/agent-plugins.ts b/src/agent-plugins.ts new file mode 100644 index 0000000..f4b47d5 --- /dev/null +++ b/src/agent-plugins.ts @@ -0,0 +1,394 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import matter from "gray-matter"; +import { exists } from "./fs.js"; +import { AGENT_PLUGINS_MCP_SCHEMA, renderMcpConfig } from "./mcp.js"; +import { deepMerge, stripUndefined } from "./targets/shared.js"; +import { error, readJson } from "./targets/validation-shared.js"; +import type { ManifestBuildContext } from "./targets/types.js"; +import type { ValidationIssue } from "./types.js"; + +/** + * The Agent Plugins package module: everything about laying one plugin out + * as an Agent Plugins 1.0 package — `plugin.json`, `skills/`, `mcp.json` — + * plus the client profile a target adds on top. Targets that emit packages + * (`codex`, `agent-plugins`) compose this module instead of building and + * validating the layout themselves. Marketplaces are deliberately not here: + * the specification leaves them to each client. + * + * Spec: https://agent-plugins.org/specification (1.0.0). + */ + +export const AGENT_PLUGINS_SCHEMA = + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + +/** A client's additions to a package. */ +export type ClientProfile = { + /** + * The client's reverse-domain extension namespace, e.g. `com.openai`. + * Authored manifest fields outside the portable manifest move under + * `extensions[namespace]`; with no namespace they are a build error. + */ + namespace?: string; +}; + +/** The only top-level fields the closed Agent Plugins manifest allows (spec §5.2). */ +const MANIFEST_FIELDS = [ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +]; +const STRING_FIELDS = [ + "version", + "description", + "homepage", + "repository", + "license", +]; +const AUTHOR_FIELDS = ["name", "email", "url"]; + +/** The package file layout, relative to the plugin directory. */ +export const packageLayout = { + manifest: "plugin.json", + mcp: "mcp.json", + skills: "skills", +}; + +/** + * Builds the portable manifest — identity and metadata only. Components are + * discovered from fixed locations, so the manifest never points at them. + */ +export function buildPackageManifest({ + metadata, + version, + pluginName, + pluginConfig, +}: ManifestBuildContext): Record { + const problem = packageNameProblem(pluginName); + if (problem) { + throw new Error( + `Plugin "${pluginName}" can't be an Agent Plugins package: ${problem}`, + ); + } + return stripUndefined({ + $schema: AGENT_PLUGINS_SCHEMA, + name: pluginName, + version: pluginConfig.version ?? version, + description: pluginConfig.description ?? metadata?.description, + author: metadata?.author, + homepage: metadata?.homepage, + repository: metadata?.repository, + license: metadata?.license, + keywords: metadata?.keywords, + }); +} + +/** + * Applied after a config's `manifest` override is merged: portable fields + * stay at the top level, and every other authored field moves under the + * profile's extension namespace (merged with any `extensions[namespace]` + * the author wrote directly). The result always conforms to the closed + * manifest schema. + */ +export function placeClientFields( + manifest: Record, + profile: ClientProfile, + pluginName: string, +): Record { + const portable: Record = {}; + const client: Record = {}; + for (const [key, value] of Object.entries(manifest)) { + if (key === "extensions") { + continue; + } + (MANIFEST_FIELDS.includes(key) ? portable : client)[key] = value; + } + const extensions: Record = {}; + if (manifest.extensions !== undefined) { + if (!isObject(manifest.extensions)) { + throw new Error( + `Plugin "${pluginName}": manifest "extensions" must be an object keyed by extension namespace.`, + ); + } + Object.assign(extensions, manifest.extensions); + } + const clientKeys = Object.keys(client); + if (clientKeys.length > 0) { + if (!profile.namespace) { + throw new Error( + `Plugin "${pluginName}": manifest field(s) ${clientKeys.map((key) => `"${key}"`).join(", ")} aren't part of the Agent Plugins manifest. ` + + `Put client-specific data under manifest.extensions[""].`, + ); + } + const existing = extensions[profile.namespace]; + extensions[profile.namespace] = deepMerge( + isObject(existing) ? existing : {}, + client, + ); + } + if (Object.keys(extensions).length > 0) { + portable.extensions = extensions; + } + return portable; +} + +/** Whether a parsed `plugin.json` declares the Agent Plugins schema. */ +export function isPackageManifest(manifest: unknown): boolean { + return isObject(manifest) && manifest.$schema === AGENT_PLUGINS_SCHEMA; +} + +/** Whether a plugin directory holds an Agent Plugins package. */ +export async function hasPackageManifest(pluginDir: string): Promise { + const file = path.join(pluginDir, packageLayout.manifest); + if (!(await exists(file))) { + return false; + } + try { + return isPackageManifest(JSON.parse(await fs.readFile(file, "utf8"))); + } catch { + return false; + } +} + +/** + * Validates one emitted package against the specification: the closed + * manifest, the `mcp.json` shape, and each discovered skill — every check + * here is something a conforming client would reject or silently skip. + * Returns the parsed manifest, or `undefined` when it couldn't be read. + */ +export async function validatePackage( + pluginDir: string, + pluginName: string, + issues: ValidationIssue[], +): Promise | undefined> { + const manifest = await readJson( + path.join(pluginDir, packageLayout.manifest), + `${pluginName} plugin manifest`, + issues, + ); + if (!manifest) { + return undefined; + } + validateManifest(manifest, pluginName, issues); + await validateMcp(pluginDir, pluginName, issues); + await validateSkills(pluginDir, pluginName, issues); + return manifest; +} + +function validateManifest( + manifest: Record, + pluginName: string, + issues: ValidationIssue[], +): void { + if (manifest.$schema !== AGENT_PLUGINS_SCHEMA) { + error( + issues, + `${pluginName}: plugin.json "$schema" must be "${AGENT_PLUGINS_SCHEMA}".`, + ); + } + if (typeof manifest.name !== "string") { + error(issues, `${pluginName}: plugin.json "name" must be a string.`); + } else { + const problem = packageNameProblem(manifest.name); + if (problem) { + error(issues, `${pluginName}: plugin.json "name" ${problem}`); + } + } + for (const key of Object.keys(manifest)) { + if (!MANIFEST_FIELDS.includes(key)) { + error( + issues, + `${pluginName}: plugin.json has field "${key}", which the closed Agent Plugins manifest does not allow — client data belongs under "extensions".`, + ); + } + } + for (const key of STRING_FIELDS) { + if (manifest[key] !== undefined && typeof manifest[key] !== "string") { + error(issues, `${pluginName}: plugin.json "${key}" must be a string.`); + } + } + if ( + manifest.keywords !== undefined && + !( + Array.isArray(manifest.keywords) && + manifest.keywords.every((keyword) => typeof keyword === "string") + ) + ) { + error( + issues, + `${pluginName}: plugin.json "keywords" must be an array of strings.`, + ); + } + if (manifest.author !== undefined) { + const author = manifest.author; + if ( + !isObject(author) || + Object.entries(author).some( + ([key, value]) => + !AUTHOR_FIELDS.includes(key) || typeof value !== "string", + ) + ) { + error( + issues, + `${pluginName}: plugin.json "author" may only contain string "name", "email", and "url".`, + ); + } + } + if (manifest.extensions !== undefined) { + if ( + !isObject(manifest.extensions) || + Object.values(manifest.extensions).some((value) => !isObject(value)) + ) { + error( + issues, + `${pluginName}: plugin.json "extensions" must be an object whose values are objects.`, + ); + } else { + for (const namespace of Object.keys(manifest.extensions)) { + if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i.test(namespace)) { + error( + issues, + `${pluginName}: plugin.json extension namespace "${namespace}" must be a reverse-domain identifier such as "com.example.client".`, + ); + } + } + } + } +} + +async function validateMcp( + pluginDir: string, + pluginName: string, + issues: ValidationIssue[], +): Promise { + const file = path.join(pluginDir, packageLayout.mcp); + if (!(await exists(file))) { + return; + } + const config = await readJson(file, `${pluginName} MCP config`, issues); + if (!config) { + return; + } + if (config.$schema !== AGENT_PLUGINS_MCP_SCHEMA) { + error( + issues, + `${pluginName}: mcp.json "$schema" must be "${AGENT_PLUGINS_MCP_SCHEMA}" (matching plugin.json's Agent Plugins version).`, + ); + } + const extra = Object.keys(config).filter( + (key) => key !== "$schema" && key !== "mcpServers", + ); + if (extra.length > 0) { + error( + issues, + `${pluginName}: mcp.json allows only "$schema" and "mcpServers" (found ${extra.map((key) => `"${key}"`).join(", ")}).`, + ); + } + if (!isObject(config.mcpServers)) { + error(issues, `${pluginName}: mcp.json "mcpServers" must be an object.`); + return; + } + for (const [name, server] of Object.entries(config.mcpServers)) { + try { + renderMcpConfig({ [name]: server }, "agent-plugins", pluginName); + } catch (err) { + error(issues, (err as Error).message); + } + } +} + +/** + * Each immediate child of `skills/` with a `SKILL.md` is one skill; it must + * conform to the Agent Skills specification or clients skip it. + */ +async function validateSkills( + pluginDir: string, + pluginName: string, + issues: ValidationIssue[], +): Promise { + const skillsDir = path.join(pluginDir, packageLayout.skills); + if (!(await exists(skillsDir))) { + return; + } + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const skillFile = path.join(skillsDir, entry.name, "SKILL.md"); + if (!(await exists(skillFile))) { + continue; + } + const where = `${pluginName}: skill "skills/${entry.name}"`; + let frontmatter: Record; + try { + frontmatter = matter(await fs.readFile(skillFile, "utf8")).data; + } catch (err) { + error( + issues, + `${where} has unparseable frontmatter: ${(err as Error).message}`, + ); + continue; + } + const { name, description } = frontmatter; + if (typeof name !== "string" || !name) { + error(issues, `${where} is missing the required "name" field.`); + } else { + const problem = skillNameProblem(name); + if (problem) { + error( + issues, + `${where} has name "${name}", which ${problem} (Agent Skills specification) — conforming clients skip it.`, + ); + } else if (name !== entry.name) { + error( + issues, + `${where} has name "${name}", which must match its directory name "${entry.name}" — conforming clients skip it.`, + ); + } + } + if (typeof description !== "string" || !description) { + error(issues, `${where} is missing the required "description" field.`); + } else if (description.length > 1024) { + error(issues, `${where} "description" exceeds 1024 characters.`); + } + } +} + +/** Spec §5.5. Returns a description of the violation, or `undefined`. */ +function packageNameProblem(name: string): string | undefined { + if (name.length < 1 || name.length > 64) { + return "must be 1-64 characters."; + } + if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(name)) { + return "must use only lowercase letters, digits, hyphens, and periods, and start and end with a letter or digit."; + } + if (name.includes("--") || name.includes("..")) { + return 'must not contain "--" or "..".'; + } + return undefined; +} + +function skillNameProblem(name: string): string | undefined { + if (name.length > 64) { + return "exceeds 64 characters"; + } + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) { + return "must use only lowercase letters, digits, and hyphens, and start and end with a letter or digit"; + } + if (name.includes("--")) { + return 'must not contain "--"'; + } + return undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/schema.ts b/src/schema.ts index f1fe1ac..20e3157 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -94,9 +94,24 @@ const emittedPluginSchema = z } }); +/** + * Content kinds that only mean something inside a client's extension + * namespace, so the standalone `agent-plugins` target (which has no client + * profile) can't ship them. + */ +export const CLIENT_ONLY_CONTENT_KINDS = [ + "agents", + "commands", + "rules", + "hooks", +]; + /** One target's output configuration: where it's written, and which plugins it emits. */ const targetSchema = z.object({ outDir: z.string().min(1), + // codex only: "agent-plugins" (default) emits Agent Plugins packages; + // "legacy" emits the .codex-plugin layout. See docs/adr/0001. + format: z.enum(["agent-plugins", "legacy"]).optional(), marketplaceDir: safeRelativePath.optional(), pluginRoot: safeRelativePath.optional(), version: z.string().optional(), @@ -142,11 +157,17 @@ const configSchema = z cursor: targetSchema.optional(), antigravity: targetSchema.optional(), codex: targetSchema.optional(), + "agent-plugins": targetSchema.optional(), }), }) .superRefine((config, ctx) => { // updateCheck emits a session-start hook; only claude and cursor run hooks. - for (const target of ["copilot", "antigravity", "codex"] as const) { + for (const target of [ + "copilot", + "antigravity", + "codex", + "agent-plugins", + ] as const) { if (config.targets[target]?.updateCheck) { ctx.addIssue({ code: "custom", @@ -156,6 +177,31 @@ const configSchema = z }); } } + for (const [target, targetConfig] of Object.entries(config.targets)) { + if (target !== "codex" && targetConfig?.format) { + ctx.addIssue({ + code: "custom", + path: ["targets", target, "format"], + message: "format is only supported for the codex target", + }); + } + } + const agentPlugins = config.targets["agent-plugins"]; + for (const [pluginName, plugin] of Object.entries( + agentPlugins?.plugins ?? {}, + )) { + const selected = plugin.include ?? plugin.components ?? []; + const clientOnly = selected.filter((kind) => + CLIENT_ONLY_CONTENT_KINDS.includes(kind), + ); + if (clientOnly.length > 0) { + ctx.addIssue({ + code: "custom", + path: ["targets", "agent-plugins", "plugins", pluginName], + message: `${clientOnly.join(", ")} only exist inside a client extension namespace; the agent-plugins target can't include them`, + }); + } + } }); /** A source plugin's own `plugin.pluginpack.json`, if it has one. */ diff --git a/src/targets/agent-plugins.ts b/src/targets/agent-plugins.ts new file mode 100644 index 0000000..ee91a8b --- /dev/null +++ b/src/targets/agent-plugins.ts @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + buildPackageManifest, + isPackageManifest, + packageLayout, + placeClientFields, + validatePackage, +} from "../agent-plugins.js"; +import { toPosix } from "../fs.js"; +import { CLIENT_ONLY_CONTENT_KINDS } from "../schema.js"; +import { error } from "./validation-shared.js"; +import type { ValidationIssue } from "../types.js"; +import type { PluginTargetDefinition } from "./types.js"; + +/** No client profile: every manifest field must be portable. */ +const noProfile = {}; + +/** + * Standalone Agent Plugins target: one portable package per plugin under + * `plugins//`, with no marketplace (the specification defines none) + * and no client profile. For clients pluginpack has no dedicated target for + * — and the strictest check that a source is portable. + */ +export const agentPlugins: PluginTargetDefinition = { + name: "agent-plugins", + + defaultComponents: ["skills", "assets"], + + resolvePluginPath: (pluginName, pluginConfig, targetConfig) => + pluginConfig.path ?? + toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)), + + buildPluginManifest: buildPackageManifest, + finalizeManifest: (manifest, pluginName) => + placeClientFields(manifest, noProfile, pluginName), + manifestPaths: (pluginPath) => [ + path.join(pluginPath, packageLayout.manifest), + ], + + buildMarketplaceEntry: () => undefined, + buildMarketplaceManifest: () => ({}), + marketplacePaths: () => [], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, packageLayout.mcp), + mcpDialect: "agent-plugins", + // Unreachable: hooks are a client-only content kind for this target. + hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), + + validateManifest: () => {}, + validateMarketplaceEntry: () => null, + + validateOutput: async (root, issues) => { + const pluginDirs = await findPackages(root); + if (pluginDirs.length === 0) { + error( + issues, + "Agent Plugins output must contain at least one package (a plugin.json declaring the Agent Plugins schema).", + ); + } + for (const pluginDir of pluginDirs) { + await validatePackage(pluginDir, path.basename(pluginDir), issues); + await rejectClientOnlyContent(pluginDir, issues); + } + return pluginDirs; + }, + + installSnippet: { + userConfigurable: false, + unsupportedReason: + "Agent Plugins defines no marketplace or install command. Install the package directory with each client's own flow — for example, VS Code installs a plugin directly from a Git repository URL.", + citation: { + claim: + "the specification leaves installation and marketplaces to each client; VS Code can install a plugin directly from a Git repository", + documentationUrl: + "https://code.visualstudio.com/docs/agent-customization/agent-plugins", + verifiedAt: "2026-10-02", + }, + }, + + citations: [ + { + claim: + "a package is plugin.json (closed schema, $schema required) + skills/ + mcp.json at fixed locations; client-specific data lives under reverse-domain extension namespaces", + documentationUrl: "https://agent-plugins.org/specification", + verifiedAt: "2026-10-02", + }, + ], +}; + +/** + * Finds every package below `root` — a directory whose `plugin.json` + * declares the Agent Plugins schema — skipping dot-directories and + * `node_modules`, and not descending into a package once found. + */ +async function findPackages(root: string, depth = 0): Promise { + if (depth > 4) { + return []; + } + const manifestFile = path.join(root, packageLayout.manifest); + try { + const manifest: unknown = JSON.parse( + await fs.readFile(manifestFile, "utf8"), + ); + if (isPackageManifest(manifest)) { + return [root]; + } + } catch { + // Not a package; keep looking below. + } + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + return []; + } + const found: string[] = []; + for (const entry of entries) { + if ( + entry.isDirectory() && + !entry.name.startsWith(".") && + entry.name !== "node_modules" + ) { + found.push( + ...(await findPackages(path.join(root, entry.name), depth + 1)), + ); + } + } + return found.sort(); +} + +async function rejectClientOnlyContent( + pluginDir: string, + issues: ValidationIssue[], +): Promise { + for (const kind of CLIENT_ONLY_CONTENT_KINDS) { + try { + await fs.access(path.join(pluginDir, kind)); + } catch { + continue; + } + error( + issues, + `${path.basename(pluginDir)}: "${kind}/" has no meaning outside a client extension namespace; the standalone agent-plugins target can't ship it.`, + ); + } +} diff --git a/src/targets/codex.ts b/src/targets/codex.ts index 0d603ec..3513640 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -1,4 +1,11 @@ import path from "node:path"; +import { + buildPackageManifest, + hasPackageManifest, + packageLayout, + placeClientFields, + validatePackage, +} from "../agent-plugins.js"; import { isSafeRelativePath, toPosix } from "../fs.js"; import { stripUndefined } from "./shared.js"; import { @@ -114,8 +121,55 @@ function validateCodexEntry( return name; } -/** OpenAI Codex CLI plugin target — see `citations` for source facts. */ -export const codex: PluginTargetDefinition = { +/** + * Validates one plugin directory in either Codex layout, detected the way + * Codex itself does: a root `plugin.json` declaring the Agent Plugins schema + * is a package; otherwise `.codex-plugin/plugin.json` is the legacy layout. + * Returns the manifest when it could be read. + */ +async function validateCodexPlugin( + pluginDir: string, + pluginName: string, + issues: ValidationIssue[], +): Promise | undefined> { + if (await hasPackageManifest(pluginDir)) { + return validatePackage(pluginDir, pluginName, issues); + } + const manifest = await readJson( + path.join(pluginDir, ".codex-plugin", "plugin.json"), + `${pluginName} plugin manifest`, + issues, + ); + if (!manifest) { + return undefined; + } + requireName(manifest, pluginName, issues); + await validateReferencedManifestPaths( + pluginDir, + pluginName, + manifest, + ["skills", "hooks", "mcpServers"], + issues, + ); + await validateFrontmatter(pluginDir, pluginName, "codex", issues); + return manifest; +} + +function requireName( + manifest: Record, + pluginName: string, + issues: ValidationIssue[], +): void { + if (typeof manifest.name !== "string" || !manifest.name) { + error( + issues, + `${pluginName}: plugin.json is missing required field "name".`, + ); + } +} + +/** Shared by both Codex layouts: everything outside the plugin directory. */ +const codexMarketplace = { name: "codex", defaultComponents: ["skills", "hooks", "scripts", "assets"], @@ -124,39 +178,6 @@ export const codex: PluginTargetDefinition = { pluginConfig.path ?? toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)), - buildPluginManifest: ({ - metadata, - version, - pluginName, - pluginConfig, - componentDirs, - mcpServers, - }) => { - const manifest: Record = { - name: pluginName, - version: pluginConfig.version ?? version, - description: pluginConfig.description ?? metadata?.description, - author: metadata?.author, - homepage: metadata?.homepage, - repository: metadata?.repository, - license: metadata?.license, - keywords: metadata?.keywords, - }; - if (componentDirs.has("skills")) { - manifest.skills = "./skills/"; - } - if (componentDirs.has("hooks")) { - manifest.hooks = "./hooks/hooks.json"; - } - if (mcpServers) { - manifest.mcpServers = "./.mcp.json"; - } - return stripUndefined(manifest); - }, - manifestPaths: (pluginPath) => [ - path.join(pluginPath, ".codex-plugin", "plugin.json"), - ], - // Author-supplied `policy`/`category` land here via the per-plugin `entry` // passthrough (see engine.ts's deepMerge) — pluginpack has no way to infer // installation/authentication policy on its own, so the base entry stays @@ -183,20 +204,10 @@ export const codex: PluginTargetDefinition = { }), marketplacePaths: () => [path.join(".agents", "plugins", "marketplace.json")], - mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), - // Legacy `.codex-plugin` layout: emitted as authored until the Agent - // Plugins format lands for this target. - mcpDialect: "verbatim", + // Codex discovers hooks/hooks.json by default in both layouts. hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), - validateManifest: (manifest, pluginName, issues) => { - if (typeof manifest.name !== "string" || !manifest.name) { - error( - issues, - `${pluginName}: plugin.json is missing required field "name".`, - ); - } - }, + validateManifest: requireName, validateMarketplaceEntry: validateCodexEntry, validateOutput: async (root, issues) => { @@ -224,12 +235,7 @@ export const codex: PluginTargetDefinition = { return ownedPaths; } for (const [index, entry] of plugins.entries()) { - const pluginName = codex.validateMarketplaceEntry( - entry, - index, - root, - issues, - ); + const pluginName = validateCodexEntry(entry, index, root, issues); if (!pluginName) { continue; } @@ -238,11 +244,7 @@ export const codex: PluginTargetDefinition = { continue; } ownedPaths.push(pluginDir); - const manifest = await readJson( - path.join(pluginDir, ".codex-plugin", "plugin.json"), - `${pluginName} plugin manifest`, - issues, - ); + const manifest = await validateCodexPlugin(pluginDir, pluginName, issues); if (!manifest) { continue; } @@ -252,21 +254,12 @@ export const codex: PluginTargetDefinition = { `${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`, ); } - codex.validateManifest(manifest, pluginName, issues); - await validateReferencedManifestPaths( - pluginDir, - pluginName, - manifest, - ["skills", "hooks", "mcpServers"], - issues, - ); await validateHooksShape( pluginDir, pluginName, "hooks/hooks.json", issues, ); - await validateFrontmatter(pluginDir, pluginName, "codex", issues); } return ownedPaths; }, @@ -288,7 +281,19 @@ export const codex: PluginTargetDefinition = { citations: [ { claim: - 'plugin.json requires only "name"; version/description/author etc. are optional', + "a root plugin.json declaring the Agent Plugins schema is the preferred package format; OpenAI-specific settings go under extensions.com.openai; .codex-plugin/plugin.json remains a compatibility fallback", + documentationUrl: "https://developers.openai.com/codex/plugins/build", + verifiedAt: "2026-10-02", + }, + { + claim: + "when extensions.com.openai is an object it replaces the .codex-plugin/plugin.json overlay (they aren't merged); hooks/hooks.json is discovered by default", + documentationUrl: "https://developers.openai.com/codex/plugins/build", + verifiedAt: "2026-10-02", + }, + { + claim: + 'legacy .codex-plugin/plugin.json requires only "name"; version/description/author etc. are optional', documentationUrl: "https://developers.openai.com/codex/plugins/build", verifiedAt: "2026-07-26", }, @@ -296,19 +301,95 @@ export const codex: PluginTargetDefinition = { claim: "marketplace entries require policy.installation, policy.authentication, and category", documentationUrl: "https://developers.openai.com/codex/plugins/build", - verifiedAt: "2026-07-26", + verifiedAt: "2026-10-02", }, { claim: 'a marketplace entry\'s source is a bare string only for local plugins; url/git-subdir/npm sources are structured objects with an inner "source" discriminator', documentationUrl: "https://developers.openai.com/codex/plugins/build", - verifiedAt: "2026-07-26", + verifiedAt: "2026-10-02", }, { claim: "marketplace.json's top level is { name, interface, plugins }, with no owner field", documentationUrl: "https://developers.openai.com/codex/plugins/build", - verifiedAt: "2026-07-26", + verifiedAt: "2026-10-02", }, ], +} satisfies Omit< + PluginTargetDefinition, + "buildPluginManifest" | "manifestPaths" | "mcpConfigPath" | "mcpDialect" +>; + +/** The OpenAI client profile: settings live under `extensions.com.openai`. */ +const openaiProfile = { namespace: "com.openai" }; + +/** + * The pre-Agent Plugins Codex layout (`format: "legacy"`): + * `.codex-plugin/plugin.json` pointing at `skills/`, `hooks/`, and + * `.mcp.json`. Kept for Codex builds that predate Agent Plugins support + * (v0.146–v0.147) — see + * docs/adr/0001-codex-emits-agent-plugins-packages.md. + */ +const codexLegacy: PluginTargetDefinition = { + ...codexMarketplace, + + buildPluginManifest: ({ + metadata, + version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + }) => { + const manifest: Record = { + name: pluginName, + version: pluginConfig.version ?? version, + description: pluginConfig.description ?? metadata?.description, + author: metadata?.author, + homepage: metadata?.homepage, + repository: metadata?.repository, + license: metadata?.license, + keywords: metadata?.keywords, + }; + if (componentDirs.has("skills")) { + manifest.skills = "./skills/"; + } + if (componentDirs.has("hooks")) { + manifest.hooks = "./hooks/hooks.json"; + } + if (mcpServers) { + manifest.mcpServers = "./.mcp.json"; + } + return stripUndefined(manifest); + }, + manifestPaths: (pluginPath) => [ + path.join(pluginPath, ".codex-plugin", "plugin.json"), + ], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + mcpDialect: "verbatim", +}; + +/** + * OpenAI Codex / ChatGPT target. Emits Agent Plugins packages by default — + * root `plugin.json` with OpenAI settings under `extensions.com.openai`, and + * `mcp.json` — listed in `.agents/plugins/marketplace.json`. `format: + * "legacy"` selects the `.codex-plugin` layout instead. See `citations`. + */ +export const codex: PluginTargetDefinition = { + ...codexMarketplace, + + forConfig: (targetConfig) => + targetConfig.format === "legacy" ? codexLegacy : codex, + + buildPluginManifest: buildPackageManifest, + finalizeManifest: (manifest, pluginName) => + placeClientFields(manifest, openaiProfile, pluginName), + manifestPaths: (pluginPath) => [ + path.join(pluginPath, packageLayout.manifest), + ], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, packageLayout.mcp), + mcpDialect: "agent-plugins", }; diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 379c241..c4f82be 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -96,8 +96,10 @@ export async function emitFromDefinition( target: TargetName, targetConfig: TargetConfig, outDir: string, - definition: PluginTargetDefinition, + targetDefinition: PluginTargetDefinition, ): Promise { + const definition = + targetDefinition.forConfig?.(targetConfig) ?? targetDefinition; const version = targetConfig.version ?? project.config.version; const files = new Map(); const entries: Record[] = []; @@ -206,8 +208,11 @@ export async function emitFromDefinition( componentDirs, mcpServers, }); + const merged = stripUndefined( + deepMerge(manifest, pluginConfig.manifest ?? {}), + ); const manifestContent = json( - stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})), + definition.finalizeManifest?.(merged, pluginName) ?? merged, ); for (const manifestPath of definition.manifestPaths( pluginPath, diff --git a/src/targets/registry.ts b/src/targets/registry.ts index aa8dab6..0e1cd4f 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -1,3 +1,4 @@ +import { agentPlugins } from "./agent-plugins.js"; import { antigravity } from "./antigravity.js"; import { claude } from "./claude.js"; import { codex } from "./codex.js"; @@ -18,4 +19,5 @@ export const targets: Record = { cursor, claude, codex, + "agent-plugins": agentPlugins, }; diff --git a/src/targets/types.ts b/src/targets/types.ts index 77619da..c34e5d2 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -97,7 +97,24 @@ export type PluginTargetDefinition = { targetConfig: TargetConfig, ) => string; + /** + * Selects the definition a given target config builds with, for a target + * offering more than one output layout (e.g. `codex`'s `format`). Validation + * still runs through this definition, so its `validateOutput` must accept + * every layout it can select. + */ + forConfig?: (targetConfig: TargetConfig) => PluginTargetDefinition; + buildPluginManifest: (ctx: ManifestBuildContext) => Record; + /** + * Runs after the config's per-plugin `manifest` override is deep-merged + * into the built manifest — for a layout whose manifest shape constrains + * where author-supplied fields may live. + */ + finalizeManifest?: ( + manifest: Record, + pluginName: string, + ) => Record; /** Output-relative paths the plugin manifest is written to (may be more than one). */ manifestPaths: (pluginPath: string, targetConfig: TargetConfig) => string[]; diff --git a/src/types.ts b/src/types.ts index c8e2777..384339f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,7 +23,7 @@ export type { }; export type TargetName = - "claude" | "copilot" | "cursor" | "antigravity" | "codex"; + "claude" | "copilot" | "cursor" | "antigravity" | "codex" | "agent-plugins"; /** A discovered source plugin, before it's emitted into any target. */ export type SourcePlugin = { diff --git a/tests/agent-plugins.test.ts b/tests/agent-plugins.test.ts new file mode 100644 index 0000000..aed00d9 --- /dev/null +++ b/tests/agent-plugins.test.ts @@ -0,0 +1,289 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Project, type ProjectArgs } from "fixturify-project"; +import { afterEach, describe, expect, it } from "vitest"; +import { build } from "../src/build.js"; +import { validateOutput } from "../src/adapters.js"; +import { loadConfig } from "../src/config.js"; + +type DirJSON = NonNullable; + +let project: Project | undefined; + +afterEach(async () => { + await project?.dispose(); + project = undefined; +}); + +const PLUGIN_SCHEMA = + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + +const CODEX_ENTRY = `entry: { + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Productivity" + }`; + +function skill(name: string): string { + return `---\nname: ${name}\ndescription: The ${name} skill.\n---\n\n# ${name}\n`; +} + +/** A project with one shared source and a config whose `targets` block is supplied. */ +async function setup(targets: string, source: DirJSON = {}): Promise { + project = new Project("agent-plugins-fixture", "1.0.0", { + files: { + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "acme-plugins", + version: "1.2.0", + metadata: { + description: "Acme plugins.", + author: { name: "Acme", url: "https://acme.example" }, + license: "MIT", + keywords: ["acme"] + }, + targets: { +${targets} + } +}); +`, + shared: { + acme: { + skills: { search: { "SKILL.md": skill("search") } }, + mcp: { + "config.json": `${JSON.stringify( + { + mcpServers: { + local: { + command: "node", + args: ["${CLAUDE_PLUGIN_ROOT}/mcp/start.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + }, + ...source, + }, + }, + }, + }); + await project.write(); + return project.baseDir; +} + +async function readJsonFile(root: string, file: string) { + return JSON.parse(await readFile(path.join(root, file), "utf8")) as Record< + string, + unknown + >; +} + +describe("codex emits Agent Plugins packages", () => { + it("writes root plugin.json and mcp.json, moving OpenAI fields under extensions.com.openai", async () => { + const root = await setup(` + codex: { + outDir: "out", + plugins: { + acme: { + source: "shared/acme", + manifest: { + homepage: "https://acme.example/docs", + interface: { displayName: "Acme", composerIcon: "./assets/icon.png" } + }, + ${CODEX_ENTRY} + } + } + }`); + + await build({ cwd: root }); + + expect(await readJsonFile(root, "out/plugins/acme/plugin.json")).toEqual({ + $schema: PLUGIN_SCHEMA, + name: "acme", + version: "1.2.0", + description: "Acme plugins.", + author: { name: "Acme", url: "https://acme.example" }, + homepage: "https://acme.example/docs", + license: "MIT", + keywords: ["acme"], + extensions: { + "com.openai": { + interface: { displayName: "Acme", composerIcon: "./assets/icon.png" }, + }, + }, + }); + expect(await readJsonFile(root, "out/plugins/acme/mcp.json")).toEqual({ + $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + mcpServers: { + local: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/mcp/start.mjs"], + }, + }, + }); + expect( + await readJsonFile(root, "out/.agents/plugins/marketplace.json"), + ).toMatchObject({ + plugins: [{ name: "acme", source: "./plugins/acme", version: "1.2.0" }], + }); + await expect( + validateOutput("codex", path.join(root, "out")), + ).resolves.toMatchObject({ ok: true, issues: [] }); + }); + + it("merges authored extensions.com.openai with relocated fields", async () => { + const root = await setup(` + codex: { + outDir: "out", + plugins: { + acme: { + source: "shared/acme", + manifest: { + interface: { displayName: "Acme" }, + extensions: { "com.openai": { apps: "./.app.json" }, "com.example.other": { x: true } } + }, + ${CODEX_ENTRY} + } + } + }`); + + await build({ cwd: root }); + + const manifest = await readJsonFile(root, "out/plugins/acme/plugin.json"); + expect(manifest.extensions).toEqual({ + "com.openai": { apps: "./.app.json", interface: { displayName: "Acme" } }, + "com.example.other": { x: true }, + }); + }); + + it("rejects a plugin name Agent Plugins doesn't allow", async () => { + const root = await setup(` + codex: { + outDir: "out", + plugins: { "acme--tools": { source: "shared/acme", ${CODEX_ENTRY} } } + }`); + + await expect(build({ cwd: root })).rejects.toThrow( + `Plugin "acme--tools" can't be an Agent Plugins package: must not contain "--" or "..".`, + ); + }); + + it("reports skills that conforming clients would skip", async () => { + const root = await setup( + ` + codex: { + outDir: "out", + plugins: { acme: { source: "shared/acme", ${CODEX_ENTRY} } } + }`, + { + skills: { + search: { "SKILL.md": skill("search") }, + glean_run: { "SKILL.md": skill("glean_run") }, + renamed: { "SKILL.md": skill("other-name") }, + }, + }, + ); + await build({ cwd: root }); + + const result = await validateOutput("codex", path.join(root, "out")); + + expect(result.ok).toBe(false); + expect(result.issues.map((issue) => issue.message)).toEqual([ + 'acme: skill "skills/glean_run" has name "glean_run", which must use only lowercase letters, digits, and hyphens, and start and end with a letter or digit (Agent Skills specification) — conforming clients skip it.', + 'acme: skill "skills/renamed" has name "other-name", which must match its directory name "renamed" — conforming clients skip it.', + ]); + }); + + it("reports a hand-edited manifest field outside the closed schema", async () => { + const root = await setup(` + codex: { + outDir: "out", + plugins: { acme: { source: "shared/acme", ${CODEX_ENTRY} } } + }`); + await build({ cwd: root }); + const file = path.join(root, "out/plugins/acme/plugin.json"); + const manifest = JSON.parse(await readFile(file, "utf8")) as Record< + string, + unknown + >; + await writeFile(file, JSON.stringify({ ...manifest, skills: "./skills/" })); + + const result = await validateOutput("codex", path.join(root, "out")); + + expect(result.issues).toContainEqual({ + level: "error", + message: + 'acme: plugin.json has field "skills", which the closed Agent Plugins manifest does not allow — client data belongs under "extensions".', + }); + }); +}); + +describe("standalone agent-plugins target", () => { + it("emits portable packages with no marketplace and validates them", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + plugins: { acme: { source: "shared/acme" } } + }`); + + const [artifact] = await build({ cwd: root }); + + expect(artifact.managedPaths).toEqual([ + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/skills/search/SKILL.md", + ]); + expect(await readJsonFile(root, "out/plugins/acme/plugin.json")).toEqual({ + $schema: PLUGIN_SCHEMA, + name: "acme", + version: "1.2.0", + description: "Acme plugins.", + author: { name: "Acme", url: "https://acme.example" }, + license: "MIT", + keywords: ["acme"], + }); + await expect( + validateOutput("agent-plugins", path.join(root, "out")), + ).resolves.toMatchObject({ ok: true, issues: [] }); + }); + + it("fails the build on a manifest field with no extension namespace to hold it", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + plugins: { acme: { source: "shared/acme", manifest: { interface: {} } } } + }`); + + await expect(build({ cwd: root })).rejects.toThrow( + `Plugin "acme": manifest field(s) "interface" aren't part of the Agent Plugins manifest.`, + ); + }); + + it("rejects client-only content kinds at config load", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + plugins: { acme: { source: "shared/acme", include: ["skills", "agents", "hooks"] } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + "agents, hooks only exist inside a client extension namespace; the agent-plugins target can't include them", + ); + }); + + it("allows format only on codex", async () => { + const root = await setup(` + claude: { + outDir: "out", + format: "legacy", + plugins: { acme: { source: "shared/acme" } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + "targets.claude.format: format is only supported for the codex target", + ); + }); +}); diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 6045a0e..8a3d5af 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import AjvImport from "ajv"; +import Ajv2020Import from "ajv/dist/2020.js"; import addFormatsImport from "ajv-formats"; import { createBintastic, type BintasticProject } from "bintastic"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -10,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; // ajv 8 ships CJS with a default export; under NodeNext tsc widens the default // import to the module namespace, so re-bind to the real default-export types. const Ajv = AjvImport as unknown as typeof import("ajv").default; +const Ajv2020 = + Ajv2020Import as unknown as typeof import("ajv/dist/2020.js").default; const addFormats = addFormatsImport as unknown as typeof import("ajv-formats").default; @@ -21,6 +24,16 @@ addFormats(ajv); const cursorMarketplaceSchema = readSchema("marketplace.schema.json"); const cursorPluginSchema = readSchema("plugin.schema.json"); +// Agent Plugins publishes versioned JSON Schemas (draft 2020-12); vendored +// copies with provenance live in tests/fixtures/agent-plugins/. +const ajv2020 = new Ajv2020({ allErrors: true, strict: false }); +addFormats(ajv2020); +const agentPluginsPluginSchema = readSchema( + "plugin.schema.json", + "agent-plugins", +); +const agentPluginsMcpSchema = readSchema("mcp.schema.json", "agent-plugins"); + // Claude's canonical oracle is its own CLI, not a published schema. Run it only // when present (skips in CI without claude installed). function commandExists(command: string): boolean { @@ -39,9 +52,9 @@ if (!hasClaude) { ); } -function readSchema(name: string): object { +function readSchema(name: string, dir = "cursor"): object { return JSON.parse( - fs.readFileSync(path.join(here, "fixtures", "cursor", name), "utf8"), + fs.readFileSync(path.join(here, "fixtures", dir, name), "utf8"), ) as object; } @@ -60,7 +73,11 @@ function schemaErrors( options: { allowExtra?: string[] } = {}, ): string[] { const allowExtra = new Set(options.allowExtra ?? []); - const validate = ajv.compile(schema); + const validate = ( + (schema as { $schema?: string }).$schema?.includes("2020-12") + ? ajv2020 + : ajv + ).compile(schema); if (validate(data)) { return []; } @@ -350,6 +367,42 @@ describe("emitted output conforms to external target schemas", () => { category: "Developer Tools", }); + // Agent Plugins package: identity only, components at fixed locations. + const plugin = readJson( + project.baseDir, + "out-codex/plugins/glean/plugin.json", + ); + expect(schemaErrors(agentPluginsPluginSchema, plugin)).toEqual([]); + expect(plugin).toMatchObject({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + name: "glean", + version: "2.1.1", + }); + const mcp = readJson(project.baseDir, "out-codex/plugins/glean/mcp.json"); + expect(schemaErrors(agentPluginsMcpSchema, mcp)).toEqual([]); + expect(mcp.mcpServers).toEqual({ + glean: { type: "stdio", command: "glean-mcp" }, + }); + expect( + fs.existsSync( + path.join(project.baseDir, "out-codex/plugins/glean/.codex-plugin"), + ), + ).toBe(false); + + const validated = await runBin("validate", "--target", "codex"); + expect(validated.exitCode, String(validated.stdout)).toBe(0); + }); + + it("codex format legacy keeps the .codex-plugin layout", async () => { + await project.write({ + "pluginpack.config.ts": CONFIG.replace( + 'outDir: "out-codex",', + 'outDir: "out-codex", format: "legacy",', + ), + }); + const result = await runBin("build", "--target", "codex"); + expect(result.exitCode, String(result.stderr)).toBe(0); + const plugin = readJson( project.baseDir, "out-codex/plugins/glean/.codex-plugin/plugin.json", @@ -360,6 +413,35 @@ describe("emitted output conforms to external target schemas", () => { skills: "./skills/", mcpServers: "./.mcp.json", }); + const validated = await runBin("validate", "--target", "codex"); + expect(validated.exitCode, String(validated.stdout)).toBe(0); + }); + + it("agent-plugins packages validate against the published Agent Plugins schemas", async () => { + await project.write({ + "pluginpack.config.ts": CONFIG.replace( + " targets: {", + ' targets: {\n "agent-plugins": { outDir: "out-ap", plugins: { glean: { from: ["glean"] } } },', + ), + }); + const result = await runBin("build", "--target", "agent-plugins"); + expect(result.exitCode, String(result.stderr)).toBe(0); + + const plugin = readJson( + project.baseDir, + "out-ap/plugins/glean/plugin.json", + ); + expect(schemaErrors(agentPluginsPluginSchema, plugin)).toEqual([]); + const mcp = readJson(project.baseDir, "out-ap/plugins/glean/mcp.json"); + expect(schemaErrors(agentPluginsMcpSchema, mcp)).toEqual([]); + expect( + fs.existsSync( + path.join(project.baseDir, "out-ap/.pluginpack/agent-plugins.json"), + ), + ).toBe(true); + + const validated = await runBin("validate", "--target", "agent-plugins"); + expect(validated.exitCode, String(validated.stdout)).toBe(0); }); it("install-info prints every configured target's snippet by default", async () => { diff --git a/tests/core.test.ts b/tests/core.test.ts index 514f992..bfbb134 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -933,7 +933,7 @@ export default defineConfig({ ).toBe(true); }); - it("points a codex plugin.json's hooks field at the bundled hooks file when hooks/ is present", async () => { + it("points a legacy codex plugin.json's hooks field at the bundled hooks file when hooks/ is present", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -943,6 +943,7 @@ export default defineConfig({ targets: { codex: { outDir: "dist/codex", + format: "legacy", plugins: { demo: { from: ["demo"], @@ -1842,9 +1843,7 @@ export default defineConfig({ path.join(root, "plugins/claude/acme/.claude-plugin/plugin.json"), ); await access(path.join(root, ".agents/plugins/marketplace.json")); - await access( - path.join(root, "plugins/codex/acme/.codex-plugin/plugin.json"), - ); + await access(path.join(root, "plugins/codex/acme/plugin.json")); await access( path.join(root, "plugins/copilot/.claude-plugin/marketplace.json"), ); diff --git a/tests/fixtures/agent-plugins/SOURCE.md b/tests/fixtures/agent-plugins/SOURCE.md new file mode 100644 index 0000000..3bf8d78 --- /dev/null +++ b/tests/fixtures/agent-plugins/SOURCE.md @@ -0,0 +1,26 @@ +# Vendored Agent Plugins schemas + +These are the **external oracle** for the `codex` and `agent-plugins` +conformance tests. They are the official machine-readable schemas published +with the Agent Plugins Specification 1.0.0, maintained by the Agent Plugins +TSC (not by this repo). + +- Source: `agentplugins/agent-plugins-spec` → `schemas/1.0.0/` +- Commit: `ff8ab5e392cc87bd88d87c060815a87490e51003` (fetched 2026-10-02) +- SHA-256: + - `plugin.schema.json` — `0a4aad95ce337878ad38802ebf0daa3fde76abe3f65400c86bcbb1ec0b3ab883` + - `mcp.schema.json` — `6539175bfcdf43085855183e86da40ea94b166547a72b47ae9a0a390516d3acb` + +The specification text is authoritative where it and these schemas disagree +(spec §5.2, §7.2.1). Published schema identifiers are never reassigned to +different contents (spec §10.1), so the 1.0.0 copies shouldn't change. A new +specification version publishes new schemas under a new directory. + +Do not hand-edit. Re-fetch to update: + +```bash +for f in plugin mcp; do + gh api "repos/agentplugins/agent-plugins-spec/contents/schemas/1.0.0/$f.schema.json" \ + -H "Accept: application/vnd.github.raw" > "$f.schema.json" +done +``` diff --git a/tests/fixtures/agent-plugins/mcp.schema.json b/tests/fixtures/agent-plugins/mcp.schema.json new file mode 100644 index 0000000..a9139a4 --- /dev/null +++ b/tests/fixtures/agent-plugins/mcp.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "title": "Agent Plugins MCP Configuration", + "description": "Machine-readable schema for mcp.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "description": "Canonical identifier of the MCP configuration schema for the Agent Plugins version targeted by this document." + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server" + } + } + }, + "required": ["$schema", "mcpServers"], + "additionalProperties": false, + "$defs": { + "server": { + "title": "MCP server", + "oneOf": [ + { + "$ref": "#/$defs/stdioServer" + }, + { + "$ref": "#/$defs/streamableHttpServer" + }, + { + "$ref": "#/$defs/sseServer" + } + ] + }, + "stdioServer": { + "title": "stdio MCP server", + "type": "object", + "properties": { + "type": { + "const": "stdio" + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable token. Resolution rules are defined by the Agent Plugins specification." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "propertyNames": { + "not": { + "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] + } + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "type": "string", + "pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))", + "description": "Plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted working directory. Filesystem containment is validated separately." + } + }, + "required": ["type", "command"], + "additionalProperties": false + }, + "streamableHttpServer": { + "title": "Streamable HTTP MCP server", + "type": "object", + "properties": { + "type": { + "const": "streamable-http" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "sseServer": { + "title": "Legacy HTTP+SSE MCP server", + "type": "object", + "properties": { + "type": { + "const": "sse" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification." + }, + "headers": { + "$ref": "#/$defs/headers" + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + "headers": { + "title": "HTTP headers", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/tests/fixtures/agent-plugins/plugin.schema.json b/tests/fixtures/agent-plugins/plugin.schema.json new file mode 100644 index 0000000..8fed0e1 --- /dev/null +++ b/tests/fixtures/agent-plugins/plugin.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "title": "Agent Plugins Manifest", + "description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.", + "type": "object", + "properties": { + "$schema": { + "const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", + "description": "Human-readable plugin name." + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + "homepage": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "license": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "type": "object", + "description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.", + "additionalProperties": { + "type": "object" + } + } + }, + "required": ["$schema", "name"], + "additionalProperties": false +} diff --git a/tests/install-snippet.test.ts b/tests/install-snippet.test.ts index 701a718..2f78fba 100644 --- a/tests/install-snippet.test.ts +++ b/tests/install-snippet.test.ts @@ -70,26 +70,25 @@ describe("install snippet", () => { ); }); - it("every target is user-configurable today", () => { + it("every marketplace target is user-configurable; agent-plugins has no install surface", () => { expect(getSupportedInstallTargets().sort()).toEqual( ["antigravity", "claude", "codex", "copilot", "cursor"].sort(), ); - expect(getUnsupportedInstallTargets()).toEqual([]); + expect(getUnsupportedInstallTargets()).toEqual(["agent-plugins"]); + expect(buildInstallSnippet("agent-plugins", params)).toMatchObject({ + userConfigurable: false, + reason: expect.stringContaining("defines no marketplace"), + }); }); it("every target carries a dated documentation citation", () => { - for (const target of getSupportedInstallTargets()) { + for (const target of [ + ...getSupportedInstallTargets(), + ...getUnsupportedInstallTargets(), + ]) { const citation = getInstallSnippetCitation(target); expect(citation.documentationUrl).toMatch(/^https:\/\//); expect(citation.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); } }); - - it("represents the userConfigurable:false shape (no target hits it today, but the type is exercised)", () => { - const unsupported: ReturnType = { - userConfigurable: false, - reason: "No CLI or URL install path exists for this target.", - }; - expect(unsupported.userConfigurable).toBe(false); - }); });