From 6f9a02fa680825b6216c0bdcd6619abcc416245b Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Tue, 20 Oct 2026 18:07:59 -0700 Subject: [PATCH 1/3] feat: build one Agent Plugins package for several clients The codex target builds an Agent Plugins package with one client profile and one marketplace, and the agent-plugins target builds packages with none. Codex, Copilot, VS Code and Cursor all read the format, so a plugin built once can serve them all, given each client's marketplace file and client-only files. Add `clients` to the agent-plugins target (ADR 0002): - `clients: { codex: {}, copilot: {} }` writes each client's marketplace: Codex .agents/plugins/marketplace.json, Copilot .github/plugin/marketplace.json. `clients..manifest` and `plugins..clients..entry` are deep-merged into them. The shared `entry` and target `manifest` are config errors on agent-plugins that name the client-scoped field instead. - Client-only kinds a listed client loads are selected by default and written to its extension directory: Copilot agents go to com.github.copilot/agents/, with .md, .markdown and .mdc renamed to NAME.agent.md. Any other file in agents/, or two files that land on the same path, fail the build. - A client-only kind that no listed client loads stays a config error, and the message names the client that would load it. A file of such a kind that arrives another way, such as additionalFiles, fails the build. Client names are checked strictly, a client set to undefined counts as not listed, and `clients` is rejected on other targets. - validate checks each client marketplace present: entries, including structured local Codex sources, must point at a package whose plugin.json has the same name. Copilot needs a marketplace owner, and its agents need NAME.agent.md names and valid frontmatter. - src/package-clients.ts holds each client's namespace, marketplace, file placement and validation, with citations to the client docs. - The engine gains two optional hooks, clientMarketplaces and placeFile. Other targets don't set them, and Glean's claude, cursor and codex output is byte-identical (191 files). Verified end to end with the Glean plugin: Copilot CLI 1.0.100 and Codex 0.160.1 both install it from their marketplace in the same output. --- CONFORMANCE.md | 17 +- CONTEXT.md | 4 + README.md | 144 ++++-- .../0002-one-package-for-several-clients.md | 34 ++ skills/authoring-pluginpack-config/SKILL.md | 14 +- src/package-clients.ts | 358 +++++++++++++ src/schema.ts | 144 +++++- src/targets/agent-plugins.ts | 87 +++- src/targets/codex.ts | 9 +- src/targets/engine.ts | 62 ++- src/targets/types.ts | 25 + tests/agent-plugins.test.ts | 483 +++++++++++++++++- tests/conformance.test.ts | 104 ++++ 13 files changed, 1407 insertions(+), 78 deletions(-) create mode 100644 docs/adr/0002-one-package-for-several-clients.md create mode 100644 src/package-clients.ts diff --git a/CONFORMANCE.md b/CONFORMANCE.md index b0fa382..06375bf 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -44,7 +44,9 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic). deliberately **not** used here: Copilot derives from the Claude marketplace but extends entries (e.g. `mcpServers` as a `.mcp.json` path), which Claude's stricter schema rejects. Because it shares the Claude marketplace path, the `claude` and - `copilot` targets need separate output roots. + `copilot` targets need separate output roots. An `agent-plugins` target with + `clients.copilot` also writes `.github/plugin/marketplace.json`, so it needs an + output root separate from the `copilot` target's. - **antigravity** — covered structurally by the cross-target build test in `tests/core.test.ts` (required `plugin.json` fields present; optional `mcp_config.json` written when MCP servers are present). Antigravity CLI does @@ -55,7 +57,13 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic). 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`). + server rules from spec §7.2 and §9 (`src/mcp.ts`). With `clients`, the same + package oracle applies. The Codex marketplace uses the codex checks below, + and the Copilot marketplace and `com.github.copilot/agents/` layout are + asserted structurally against the + [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) + (retrieved 2026-10-20), which publishes no schema. Client facts and their + citations live in `src/package-clients.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 @@ -74,8 +82,9 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic). added via `entry` is a structured object with an inner `source` discriminator (e.g. `{ source: "git-subdir", url, path, ref }`), validated by shape rather than requiring a local directory to exist. No published JSON Schema exists. - Codex shares no marketplace path with the other targets, so it needs no - separate output root. + Among the native targets, Codex shares no marketplace path, but an + `agent-plugins` target with `clients.codex` writes the same + `.agents/plugins/marketplace.json`, so the two need separate output roots. ## Update-check hook facts diff --git a/CONTEXT.md b/CONTEXT.md index 2abaf5c..a82b7dd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,6 +33,10 @@ _Avoid_: portable plugin, AP plugin A client's additions to a package: its extension namespace, the manifest data under it, and the files in its extension directory. _Avoid_: overlay, flavor +**Package client**: +A client listed in the `agent-plugins` target's `clients`. One package serves every package client; each also gets its own marketplace file. +_Avoid_: sub-target, flavor + **Extension namespace**: The reverse-domain key a client owns inside a package, such as `com.openai`. _Avoid_: vendor key, client key diff --git a/README.md b/README.md index 0d88d81..e540545 100644 --- a/README.md +++ b/README.md @@ -247,16 +247,16 @@ 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 / 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) | +| 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 unless `clients` is set ([one package for several clients](#one-package-for-several-clients)) | -> **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. +> **Heads up:** targets that write the same marketplace file need distinct `outDir`s (or separate repos): `claude` and `copilot` both write `.claude-plugin/marketplace.json`; `agent-plugins` with `clients.codex` writes the `codex` target's `.agents/plugins/marketplace.json`; and with `clients.copilot` it writes the `copilot` target's `.github/plugin/marketplace.json`. `build` errors on overlapping output paths. New targets are added from official docs or real plugin examples — not guessed abstractions. @@ -278,12 +278,66 @@ Pluginpack emits packages in two ways: `.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`, `scripts`, `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. +- **`agent-plugins`** emits portable packages. On its own it has no client + profile, so it ships `skills`, `scripts`, `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. List `clients` to + build one package for several clients (see below). + +### One package for several clients + +Set `clients` on the `agent-plugins` target to build each plugin once and +publish it to every listed client from one output repository. Each package +keeps one `plugin.json`, `mcp.json` and `skills/`. Each client gets its own +marketplace file, and its client-only files go in its extension directory +([ADR 0002](./docs/adr/0002-one-package-for-several-clients.md)): + +| Client | Marketplace | Client-only files | +| --------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `codex` | `.agents/plugins/marketplace.json` | none yet | +| `copilot` | `.github/plugin/marketplace.json` | `agents/NAME.md` (or `.markdown`, `.mdc`) → `com.github.copilot/agents/NAME.agent.md`; any other file in `agents/` fails the build | + +```ts +targets: { + "agent-plugins": { + outDir: "dist/agent-plugins", + clients: { + codex: { manifest: { interface: { displayName: "Acme" } } }, + copilot: {}, + }, + plugins: { + acme: { + source: "shared/acme", + manifest: { extensions: { "com.openai": { interface: { displayName: "Acme" } } } }, + clients: { + codex: { + entry: { + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Productivity", + }, + }, + }, + }, + }, + }, +}, +``` + +- `clients..manifest` is deep-merged into that client's marketplace + file. `plugins..clients..entry` is deep-merged into the + plugin's entry in it. +- Client manifest data goes under `manifest.extensions[""]` + explicitly. A package for several clients can't guess which client a + top-level field belongs to, so a non-portable top-level field is still a + build error. +- A client-only content kind is selected by default, and allowed in + `include`, only when a listed client loads it. +- Copilot's marketplace requires an owner, from `metadata.owner` or + `metadata.author`. +- `validate` checks every package, then each client marketplace that's + present: every entry must point at a package whose `plugin.json` has the + same name. `validate` checks packages against the specification: the closed manifest, a valid plugin name, `mcp.json` servers, and skill names that follow the @@ -543,38 +597,40 @@ rest of the artifact. **`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`). | +| 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. An error on `agent-plugins`; use `clients..manifest`. | +| `clients` | `{ codex?, copilot? }` | no | `agent-plugins` only. Clients each package serves; each gets a marketplace. `clients..manifest` is deep-merged into that client's marketplace. | +| `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.`** -| Field | Type | Required | Meaning | -| ------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `source` | string (safe relative) | yes | Direct path to the plugin's shared authored source. | -| `overrides` | string (safe relative) | no | Target-specific directory applied after `source`; may add or replace files. | -| `include` | string[] | no | Exact content kinds to include (`skills`, `agents`, `rules`, `assets`, `static`, `mcp`, etc.). | -| `exclude` | string[] | no | Content kinds removed from the target defaults. | -| `from` | string[] (min 1) | legacy | 0.10 source-plugin composition; cannot be combined with `source`. | -| `path` | string (safe relative) | no | Output path for the plugin, relative to `outDir`. Defaults to the plugin name (or `pluginRoot/` for `claude`). | -| `version` | string | no | Per-plugin version override. | -| `displayName` | string | no | Per-plugin display name. | -| `description` | string | no | Per-plugin description override. | -| `manifest` | object | no | Deep-merged into the generated plugin manifest. | -| `entry` | object | no | Deep-merged into the generated marketplace entry (the object in the marketplace `plugins` array). Use for target-specific entry fields pluginpack can't derive — e.g. Codex `policy`/`category`. | -| `components` | string[] | legacy | 0.10 include-only name; migrate to `include`. | -| `updateCheck` | `false` | no | Opt this plugin out of the target's update-check hook. | +| Field | Type | Required | Meaning | +| ------------- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `source` | string (safe relative) | yes | Direct path to the plugin's shared authored source. | +| `overrides` | string (safe relative) | no | Target-specific directory applied after `source`; may add or replace files. | +| `include` | string[] | no | Exact content kinds to include (`skills`, `agents`, `rules`, `assets`, `static`, `mcp`, etc.). | +| `exclude` | string[] | no | Content kinds removed from the target defaults. | +| `from` | string[] (min 1) | legacy | 0.10 source-plugin composition; cannot be combined with `source`. | +| `path` | string (safe relative) | no | Output path for the plugin, relative to `outDir`. Defaults to the plugin name (or `pluginRoot/` for `claude`). | +| `version` | string | no | Per-plugin version override. | +| `displayName` | string | no | Per-plugin display name. | +| `description` | string | no | Per-plugin description override. | +| `manifest` | object | no | Deep-merged into the generated plugin manifest. | +| `entry` | object | no | Deep-merged into the generated marketplace entry (the object in the marketplace `plugins` array). Use for target-specific entry fields pluginpack can't derive — e.g. Codex `policy`/`category`. An error on `agent-plugins`; use `clients..entry`. | +| `clients` | `{ codex?, copilot? }` | no | `agent-plugins` only. `clients..entry` is deep-merged into this plugin's entry in that client's marketplace (e.g. `clients.codex.entry` for Codex `policy`/`category`). Each key must be listed in the target's `clients`. | +| `components` | string[] | legacy | 0.10 include-only name; migrate to `include`. | +| `updateCheck` | `false` | no | Opt this plugin out of the target's update-check hook. | ## Programmatic API diff --git a/docs/adr/0002-one-package-for-several-clients.md b/docs/adr/0002-one-package-for-several-clients.md new file mode 100644 index 0000000..7e37d40 --- /dev/null +++ b/docs/adr/0002-one-package-for-several-clients.md @@ -0,0 +1,34 @@ +--- +status: proposed +--- + +# One package for several clients + +Codex, GitHub Copilot, VS Code and Cursor all read Agent Plugins packages. A +plugin built once as a package can serve all of them, if each client also gets +its marketplace file and any client-only files. Before this change, the +`codex` target built a package with one client's profile and marketplace, and +the `agent-plugins` target built packages with none. + +We add `clients` to the `agent-plugins` target instead of adding a target per +client or a list of clients to `codex`. Each listed client contributes: + +- a marketplace file at the path it reads (Codex + `.agents/plugins/marketplace.json`, Copilot `.github/plugin/marketplace.json`), +- its client-only content kinds, written to its extension directory (Copilot + agents in `com.github.copilot/agents/`, renamed to `NAME.agent.md`). + +The package itself (`plugin.json`, `mcp.json`, `skills/`) is identical for +every client, and the spec requires clients to ignore other clients' +namespaces. So the output has no per-client copies of the package that could +drift. + +Manifest data for a client goes under `manifest.extensions[""]` +explicitly. The `codex` target moves non-portable top-level fields into +`com.openai` automatically, because it serves one client. A package that +serves several clients can't know which one a field is for, so that stays a +build error. + +The `codex` target stays for builds that serve only Codex, including +`format: "legacy"`. Claude Code doesn't read Agent Plugins, so it isn't a +package client; adding its manifest to the same directory is separate work. diff --git a/skills/authoring-pluginpack-config/SKILL.md b/skills/authoring-pluginpack-config/SKILL.md index 3f8e7a9..267619f 100644 --- a/skills/authoring-pluginpack-config/SKILL.md +++ b/skills/authoring-pluginpack-config/SKILL.md @@ -93,10 +93,16 @@ so author it once. Don't add a per-target copy just to change (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. +`.codex-plugin/plugin.json` layout. The `agent-plugins` target emits +portable packages. Without `clients` it writes no marketplace and can't include +`agents`, `commands`, `rules`, or `hooks`. With `clients: { codex: {}, copilot: +{} }` it builds each package once and writes each client's marketplace. Listing +`copilot` allows `agents`, which go to `com.github.copilot/agents/NAME.agent.md`; +no client allows `commands`, `rules`, or `hooks`. Put client manifest data +under `manifest.extensions[""]`, and Codex `policy`/`category` under +`plugins..clients.codex.entry`. Skill names must be lowercase letters, +digits, and hyphens and match their directory, or Agent Plugins clients skip +them. ## Verify diff --git a/src/package-clients.ts b/src/package-clients.ts new file mode 100644 index 0000000..6cee1d7 --- /dev/null +++ b/src/package-clients.ts @@ -0,0 +1,358 @@ +import path from "node:path"; +import { exists, toPosix, walkFiles } from "./fs.js"; +import { + PACKAGE_CLIENT_CONTENT_KINDS, + PACKAGE_CLIENT_NAMES, + type PackageClientName, +} from "./schema.js"; +import { codexMarketplace, validateCodexEntry } from "./targets/codex.js"; +import { stripUndefined } from "./targets/shared.js"; +import { + error, + readJson, + validateBareStringSourceEntry, + validateFrontmatter, + validateMarketplaceBasics, +} from "./targets/validation-shared.js"; +import type { ValidationIssue } from "./types.js"; +import type { + Citation, + MarketplaceEntryContext, + MarketplaceManifestContext, +} from "./targets/types.js"; + +/** + * A client an `agent-plugins` package carries a profile and a marketplace + * for: its extension namespace, where its client-only files go inside the + * package, and its marketplace file at the repository root. One package can + * list several clients, so the same `plugin.json`, `mcp.json` and `skills/` + * serve all of them. See docs/adr/0002. + */ +export type PackageClient = { + name: PackageClientName; + /** The client's reverse-domain extension namespace and directory. */ + namespace: string; + /** + * Plugin-relative path for one source file of a content kind this client + * loads from its extension directory, or `undefined` when the file isn't + * one of this client's kinds. Throws for a file of one of its kinds that + * the client can't load. + */ + placeContent: ( + relativePath: string, + pluginName: string, + ) => string | undefined; + /** Output-relative path of the client's marketplace file. */ + marketplacePath: string; + buildMarketplaceEntry: ( + ctx: MarketplaceEntryContext, + ) => Record; + buildMarketplaceManifest: ( + ctx: MarketplaceManifestContext, + ) => Record; + /** + * Validates the client's marketplace (when present) and its extension + * directory in each package. Returns the paths it owns. + */ + validate: ( + root: string, + packageDirs: string[], + issues: ValidationIssue[], + ) => Promise; + citations: Citation[]; +}; + +/** Lowercase, digits, hyphens and periods (Agent Plugins plugin names). */ +const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +const codexClient: PackageClient = { + name: "codex", + namespace: "com.openai", + placeContent: () => undefined, + marketplacePath: path.join(".agents", "plugins", "marketplace.json"), + buildMarketplaceEntry: (ctx) => + codexMarketplace.buildMarketplaceEntry(ctx) as Record, + buildMarketplaceManifest: (ctx) => + codexMarketplace.buildMarketplaceManifest(ctx) as Record, + validate: async (root, packageDirs, issues) => { + const marketplacePath = path.join(root, codexClient.marketplacePath); + if (!(await exists(marketplacePath))) { + return []; + } + const marketplace = await readJson( + marketplacePath, + "Codex marketplace", + issues, + ); + if (!marketplace) { + return [marketplacePath]; + } + validateMarketplaceBasics(marketplace, issues); + for (const [index, entry] of marketplaceEntries( + marketplace, + "Codex", + issues, + )) { + const name = validateCodexEntry(entry, index, root, issues); + if (!name) { + continue; + } + const source = localSourcePath(entry.source); + if (source !== undefined) { + await requireListedPackage( + root, + source, + name, + "Codex", + packageDirs, + issues, + ); + } + } + return [marketplacePath]; + }, + // The Codex layout facts are the codex target's; the same docs back them. + citations: codexMarketplace.citations, +}; + +const copilotKinds = PACKAGE_CLIENT_CONTENT_KINDS.copilot; + +const copilotClient: PackageClient = { + name: "copilot", + namespace: "com.github.copilot", + placeContent: (relativePath, pluginName) => { + const [kind, ...rest] = relativePath.split("/"); + if (!copilotKinds.includes(kind) || rest.length === 0) { + return undefined; + } + let file = rest.join("/"); + // Copilot derives an agent's ID from NAME.agent.md and ignores other + // files, so an agent in any extension the frontmatter checks treat as + // an agent (.md, .markdown, .mdc) is renamed on the way in, and any + // other file fails rather than ship where Copilot skips it. + if (kind === "agents" && !file.endsWith(".agent.md")) { + const agentExtension = /\.(md|markdown|mdc)$/.exec(file); + if (!agentExtension) { + throw new Error( + `Target "agent-plugins" plugin "${pluginName}": "${relativePath}" isn't an agent file Copilot can load. Copilot loads only agents/NAME.agent.md; use a .md, .markdown or .mdc agent, or move the file out of agents/.`, + ); + } + file = `${file.slice(0, agentExtension.index)}.agent.md`; + } + return toPosix(path.join("com.github.copilot", kind, file)); + }, + marketplacePath: path.join(".github", "plugin", "marketplace.json"), + // An Agent Plugins entry needs only name and source: Copilot finds the + // components at the package's fixed locations, so no component paths. + buildMarketplaceEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => + stripUndefined({ + name: pluginName, + source: `./${pluginPath}`, + description: + pluginConfig.description ?? + (manifest?.description as string | undefined), + version: + pluginConfig.version ?? (manifest?.version as string | undefined), + }), + buildMarketplaceManifest: ({ project, targetConfig, version, plugins }) => { + // clients.copilot.manifest is merged after this builder runs, so an + // owner set there counts too. + const overridden = targetConfig.clients?.copilot?.manifest?.owner; + const owner = + project.config.metadata?.owner ?? project.config.metadata?.author; + if (!owner && !overridden) { + throw new Error( + 'Target "agent-plugins" client "copilot": Copilot\'s marketplace requires an owner. Set metadata.owner (or metadata.author), or targets["agent-plugins"].clients.copilot.manifest.owner, in pluginpack.config.ts.', + ); + } + return stripUndefined({ + name: project.config.name, + owner: owner + ? stripUndefined({ name: owner.name, email: owner.email }) + : undefined, + metadata: stripUndefined({ + description: project.config.metadata?.description, + version, + }), + plugins, + }); + }, + validate: async (root, packageDirs, issues) => { + const owned: string[] = []; + const marketplacePath = path.join(root, copilotClient.marketplacePath); + if (await exists(marketplacePath)) { + owned.push(marketplacePath); + const marketplace = await readJson( + marketplacePath, + "Copilot marketplace", + issues, + ); + if (marketplace) { + validateMarketplaceBasics(marketplace, issues, { requireOwner: true }); + for (const [index, entry] of marketplaceEntries( + marketplace, + "Copilot", + issues, + )) { + const name = validateBareStringSourceEntry( + entry, + index, + root, + issues, + pluginNamePattern, + ); + if (name) { + await requireListedPackage( + root, + entry.source as string, + name, + "Copilot", + packageDirs, + issues, + ); + } + } + } + } + for (const packageDir of packageDirs) { + await validateCopilotAgents(packageDir, issues); + } + return owned; + }, + citations: [ + { + claim: + "for Agent Plugins 1.0 plugins, Copilot CLI reads client-specific components from com.github.copilot/ (agents/, commands/, rules/, hooks/hooks.json, lsp.json)", + documentationUrl: + "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", + verifiedAt: "2026-10-20", + }, + { + claim: + "agent files are NAME.agent.md; the agent ID comes from the file name", + documentationUrl: + "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", + verifiedAt: "2026-10-20", + }, + { + claim: + "the marketplace is read from marketplace.json, .plugin/marketplace.json, .github/plugin/marketplace.json, or .claude-plugin/marketplace.json, in that order; name, owner, and plugins are required, and each entry requires only name and source", + documentationUrl: + "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", + verifiedAt: "2026-10-20", + }, + { + claim: + "a root plugin.json declaring the Agent Plugins schema takes precedence over legacy manifests; skills/ and mcp.json are fixed locations", + documentationUrl: + "https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference", + verifiedAt: "2026-10-20", + }, + ], +}; + +export const PACKAGE_CLIENTS: Record = { + codex: codexClient, + copilot: copilotClient, +}; + +/** The configured package clients, in the stable `PACKAGE_CLIENT_NAMES` order. */ +export function configuredClients( + clients: Partial> | undefined, +): PackageClient[] { + return PACKAGE_CLIENT_NAMES.filter((name) => clients?.[name]).map( + (name) => PACKAGE_CLIENTS[name], + ); +} + +/** + * The local path a Codex marketplace `source` points at: a bare string, or a + * structured `{ source: "local", path }`. `undefined` for a remote source. + */ +function localSourcePath(source: unknown): string | undefined { + if (typeof source === "string") { + return source; + } + if ( + source && + typeof source === "object" && + (source as Record).source === "local" && + typeof (source as Record).path === "string" + ) { + return (source as Record).path as string; + } + return undefined; +} + +function marketplaceEntries( + marketplace: Record, + client: string, + issues: ValidationIssue[], +): [number, Record][] { + const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; + if (plugins.length === 0) { + error(issues, `${client} marketplace "plugins" must be a non-empty array.`); + } + return [...(plugins as Record[]).entries()]; +} + +/** + * A marketplace entry must point at a package in this output whose + * `plugin.json` carries the same name. + */ +async function requireListedPackage( + root: string, + source: string, + name: string, + client: string, + packageDirs: string[], + issues: ValidationIssue[], +): Promise { + const dir = path.resolve(root, source); + if (!packageDirs.some((packageDir) => path.resolve(packageDir) === dir)) { + error( + issues, + `${name}: the ${client} marketplace entry's source "${source}" is not an Agent Plugins package in this output.`, + ); + return; + } + const manifest = await readJson( + path.join(dir, "plugin.json"), + `${name} plugin manifest`, + [], + ); + if (manifest && manifest.name !== name) { + error( + issues, + `${name}: the ${client} marketplace entry name does not match plugin.json name ("${String(manifest.name)}").`, + ); + } +} + +/** + * Copilot agents in a package's extension directory: named NAME.agent.md, + * with the frontmatter the copilot target requires (name, description). + */ +async function validateCopilotAgents( + packageDir: string, + issues: ValidationIssue[], +): Promise { + const extensionDir = path.join(packageDir, "com.github.copilot"); + if (await exists(extensionDir)) { + await validateFrontmatter( + extensionDir, + `${path.basename(packageDir)} (com.github.copilot)`, + "copilot", + issues, + ); + } + const agentsDir = path.join(extensionDir, "agents"); + for (const file of await walkFiles(agentsDir)) { + if (!file.endsWith(".agent.md")) { + error( + issues, + `${path.basename(packageDir)}: agent file "${toPosix(path.relative(packageDir, file))}" must be named NAME.agent.md for Copilot to load it.`, + ); + } + } +} diff --git a/src/schema.ts b/src/schema.ts index 20e3157..01234e2 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -54,6 +54,56 @@ const updateCheckSchema = z.object({ repository: z.string().min(1).optional(), }); +/** + * Clients an `agent-plugins` package can carry a profile and a marketplace + * for. The full definitions (namespace, marketplace, file placement) live in + * `src/package-clients.ts`; this module keeps only the data the config + * schema checks. + */ +export const PACKAGE_CLIENT_NAMES = ["codex", "copilot"] as const; +export type PackageClientName = (typeof PACKAGE_CLIENT_NAMES)[number]; + +/** + * The client-only content kinds each package client loads from its + * extension directory. A kind not listed for any configured client is a + * config error, as it is for a package with no clients. + */ +export const PACKAGE_CLIENT_CONTENT_KINDS: Record< + PackageClientName, + readonly string[] +> = { + codex: [], + copilot: ["agents"], +}; + +/** `targets["agent-plugins"].clients.`: client-wide settings. */ +const packageClientSchema = z.object({ + // Deep-merged into this client's generated marketplace file. + manifest: z.record(z.string(), z.unknown()).optional(), +}); + +/** `plugins..clients.`: one plugin's settings for one client. */ +const pluginClientSchema = z.object({ + // Deep-merged into this plugin's entry in the client's marketplace, for + // fields pluginpack can't derive (e.g. Codex `policy`/`category`). + entry: z.record(z.string(), z.unknown()).optional(), +}); + +// Strict, unlike the rest of the config: a misspelled client name would +// otherwise drop that client's whole marketplace without a word. +const packageClientsSchema = z + .object({ + codex: packageClientSchema.optional(), + copilot: packageClientSchema.optional(), + }) + .strict(); +const pluginClientsSchema = z + .object({ + codex: pluginClientSchema.optional(), + copilot: pluginClientSchema.optional(), + }) + .strict(); + /** One authored plugin source mapped to an emitted plugin for a target. */ const emittedPluginSchema = z .object({ @@ -70,6 +120,8 @@ const emittedPluginSchema = z // the marketplace `plugins` array), letting a config supply target-specific // entry fields a target can't derive — e.g. Codex `policy`/`category`. entry: z.record(z.string(), z.unknown()).optional(), + // agent-plugins only: per-client settings for this plugin. + clients: pluginClientsSchema.optional(), // Canonical selection names. `components` is the legacy include-only name. include: z.array(z.string().min(1)).optional(), exclude: z.array(z.string().min(1)).optional(), @@ -96,8 +148,8 @@ 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. + * namespace. The `agent-plugins` target ships one only when a configured + * client implements it (see `PACKAGE_CLIENT_CONTENT_KINDS`). */ export const CLIENT_ONLY_CONTENT_KINDS = [ "agents", @@ -120,6 +172,9 @@ const targetSchema = z.object({ // updateCheck.repository answers, asked by a different feature. repository: z.string().min(1).optional(), plugins: z.record(z.string(), emittedPluginSchema), + // agent-plugins only: the clients each package carries a profile and a + // marketplace for. See docs/adr/0002. + clients: packageClientsSchema.optional(), manifest: z.record(z.string(), z.unknown()).optional(), ignoredDiffPaths: z.array(z.string()).optional(), updateCheck: updateCheckSchema.optional(), @@ -186,24 +241,101 @@ const configSchema = z }); } } + for (const [target, targetConfig] of Object.entries(config.targets)) { + if (target === "agent-plugins" || !targetConfig) { + continue; + } + if (targetConfig.clients) { + ctx.addIssue({ + code: "custom", + path: ["targets", target, "clients"], + message: "clients is only supported for the agent-plugins target", + }); + } + for (const [pluginName, plugin] of Object.entries(targetConfig.plugins)) { + if (plugin.clients) { + ctx.addIssue({ + code: "custom", + path: ["targets", target, "plugins", pluginName, "clients"], + message: "clients is only supported for the agent-plugins target", + }); + } + } + } const agentPlugins = config.targets["agent-plugins"]; + // A key set to undefined (e.g. `copilot: flag ? {} : undefined`) isn't a + // listed client; the build skips it the same way (configuredClients). + const clients = listedKeys(agentPlugins?.clients) as PackageClientName[]; + const supportedKinds = new Set( + clients.flatMap((client) => PACKAGE_CLIENT_CONTENT_KINDS[client]), + ); + // agent-plugins writes only client marketplaces, so the shared + // marketplace passthroughs would be dropped; point at the client ones. + if (agentPlugins?.manifest !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["targets", "agent-plugins", "manifest"], + message: + 'manifest isn\'t used by the agent-plugins target; set it per client in targets["agent-plugins"].clients..manifest', + }); + } for (const [pluginName, plugin] of Object.entries( agentPlugins?.plugins ?? {}, )) { + if (plugin.entry !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["targets", "agent-plugins", "plugins", pluginName, "entry"], + message: `entry isn't used by the agent-plugins target; set it per client in targets["agent-plugins"].plugins.${pluginName}.clients..entry`, + }); + } const selected = plugin.include ?? plugin.components ?? []; - const clientOnly = selected.filter((kind) => - CLIENT_ONLY_CONTENT_KINDS.includes(kind), + const unsupported = selected.filter( + (kind) => + CLIENT_ONLY_CONTENT_KINDS.includes(kind) && !supportedKinds.has(kind), ); - if (clientOnly.length > 0) { + if (unsupported.length > 0) { + const implementers = PACKAGE_CLIENT_NAMES.filter((client) => + unsupported.some((kind) => + PACKAGE_CLIENT_CONTENT_KINDS[client].includes(kind), + ), + ); 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`, + message: + `${unsupported.join(", ")} only exist inside a client extension namespace; the agent-plugins target can't include them` + + (implementers.length > 0 + ? ` unless targets["agent-plugins"].clients lists a client that loads them (${implementers.join(", ")})` + : " (no supported client loads them yet)"), }); } + for (const client of listedKeys(plugin.clients)) { + if (!clients.includes(client as PackageClientName)) { + ctx.addIssue({ + code: "custom", + path: [ + "targets", + "agent-plugins", + "plugins", + pluginName, + "clients", + client, + ], + message: `client "${client}" isn't listed in targets["agent-plugins"].clients`, + }); + } + } } }); +/** The keys of `record` whose values are set. */ +function listedKeys(record: Record | undefined): string[] { + return Object.entries(record ?? {}) + .filter(([, value]) => value !== undefined) + .map(([key]) => key); +} + /** A source plugin's own `plugin.pluginpack.json`, if it has one. */ const sourcePluginManifestSchema = metadataSchema.extend({ name: z.string().optional(), diff --git a/src/targets/agent-plugins.ts b/src/targets/agent-plugins.ts index dcbdccc..23d9714 100644 --- a/src/targets/agent-plugins.ts +++ b/src/targets/agent-plugins.ts @@ -7,23 +7,45 @@ import { validatePackage, } from "../agent-plugins.js"; import { exists, toPosix } from "../fs.js"; -import { CLIENT_ONLY_CONTENT_KINDS } from "../schema.js"; +import { + configuredClients, + PACKAGE_CLIENTS, + type PackageClient, +} from "../package-clients.js"; +import { + CLIENT_ONLY_CONTENT_KINDS, + PACKAGE_CLIENT_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. */ +/** + * No single client profile: every top-level manifest field must be portable. + * Client data goes under `manifest.extensions[""]` explicitly, + * because a package listing several clients can't guess which one a field + * belongs to. + */ 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. + * Agent Plugins target: one portable package per plugin under + * `plugins//`. With no `clients`, it writes packages only — no + * marketplace (the specification defines none) and no client files. With + * `clients`, each package also carries those clients' files in their + * extension directories, and each client gets its own marketplace file, so + * one build serves every listed client. See docs/adr/0002. */ export const agentPlugins: PluginTargetDefinition = { name: "agent-plugins", + forConfig: (targetConfig) => { + const clients = configuredClients(targetConfig.clients); + return clients.length === 0 + ? agentPlugins + : withClients(agentPlugins, clients); + }, + // scripts/ ships by default: an MCP command such as ./scripts/server.sh // points into it. defaultComponents: ["skills", "scripts", "assets"], @@ -51,6 +73,9 @@ export const agentPlugins: PluginTargetDefinition = { validateManifest: () => {}, validateMarketplaceEntry: () => null, + // Validates every package, then each client's marketplace and extension + // directory. Output carries no config, so a client is validated when its + // marketplace or extension directory is present. validateOutput: async (root, issues) => { const pluginDirs = await findPackages(root); if (pluginDirs.length === 0) { @@ -63,7 +88,11 @@ export const agentPlugins: PluginTargetDefinition = { await validatePackage(pluginDir, path.basename(pluginDir), issues); await rejectClientOnlyContent(pluginDir, issues); } - return pluginDirs; + const owned = [...pluginDirs]; + for (const client of Object.values(PACKAGE_CLIENTS)) { + owned.push(...(await client.validate(root, pluginDirs, issues))); + } + return owned; }, installSnippet: { @@ -89,6 +118,50 @@ export const agentPlugins: PluginTargetDefinition = { ], }; +/** + * The definition for a config that lists clients: client-only content kinds + * the clients load become defaults and are placed in each client's extension + * directory, and each client gets a marketplace. + */ +function withClients( + base: PluginTargetDefinition, + clients: PackageClient[], +): PluginTargetDefinition { + const clientKinds = clients.flatMap( + (client) => PACKAGE_CLIENT_CONTENT_KINDS[client.name], + ); + return { + ...base, + defaultComponents: [ + ...new Set([...base.defaultComponents, ...clientKinds]), + ], + placeFile: (relativePath, pluginName) => { + const kind = relativePath.split("/")[0]; + if (!CLIENT_ONLY_CONTENT_KINDS.includes(kind)) { + return [relativePath]; + } + const placed = clients + .map((client) => client.placeContent(relativePath, pluginName)) + .filter((path): path is string => path !== undefined); + // The schema rejects selecting such a kind, but a file can still get + // here (e.g. through plugin.pluginpack.json additionalFiles). + if (placed.length === 0) { + throw new Error( + `Target "agent-plugins" plugin "${pluginName}": "${relativePath}" is ${kind} content, and none of the listed clients (${clients.map((client) => client.name).join(", ")}) loads ${kind}. Remove it, or list a client that does.`, + ); + } + return placed; + }, + clientMarketplaces: () => + clients.map((client) => ({ + client: client.name, + path: client.marketplacePath, + buildEntry: client.buildMarketplaceEntry, + buildManifest: client.buildMarketplaceManifest, + })), + }; +} + /** * Finds every package below `root` — a directory with a `plugin.json` — * skipping dot-directories and `node_modules`, and not descending into a diff --git a/src/targets/codex.ts b/src/targets/codex.ts index e0c04f0..ddba33a 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -55,7 +55,7 @@ function resolveLocalPluginDir(root: string, source: unknown): string | null { * regardless of source shape, must carry `policy.installation`, * `policy.authentication`, and `category` — see `citations`. */ -function validateCodexEntry( +export function validateCodexEntry( entry: Record, index: number, root: string, @@ -174,8 +174,11 @@ function requireName( } } -/** Shared by both Codex layouts: everything outside the plugin directory. */ -const codexMarketplace = { +/** + * Shared by both Codex layouts, and by the `agent-plugins` target's Codex + * client: everything outside the plugin directory. + */ +export const codexMarketplace = { name: "codex", defaultComponents: ["skills", "hooks", "scripts", "assets"], diff --git a/src/targets/engine.ts b/src/targets/engine.ts index c4f82be..3a5bff8 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -103,6 +103,11 @@ export async function emitFromDefinition( const version = targetConfig.version ?? project.config.version; const files = new Map(); const entries: Record[] = []; + const clientMarketplaces = + definition.clientMarketplaces?.(targetConfig) ?? []; + const clientEntries = new Map[]>( + clientMarketplaces.map((marketplace) => [marketplace.client, []]), + ); const updateCheck = resolveUpdateCheck( project, target, @@ -174,8 +179,22 @@ export async function emitFromDefinition( if (sourceHooksFile !== undefined) { pluginFiles.delete("hooks/hooks.json"); } + // Placement can map two source files to one path (e.g. agents/x.md and + // agents/x.agent.md for Copilot); fail instead of keeping either. + const placedFrom = new Map(); for (const [relativePath, value] of pluginFiles) { - files.set(toPosix(path.join(pluginPath, relativePath)), value); + for (const placed of definition.placeFile?.(relativePath, pluginName) ?? [ + relativePath, + ]) { + const earlier = placedFrom.get(placed); + if (earlier !== undefined && earlier !== relativePath) { + throw new Error( + `Target "${target}" plugin "${pluginName}": "${earlier}" and "${relativePath}" both write "${placed}". Rename or remove one of them.`, + ); + } + placedFrom.set(placed, relativePath); + files.set(toPosix(path.join(pluginPath, placed)), value); + } } if (sourceHooksFile !== undefined) { files.set(toPosix(definition.hooksPath(pluginPath)), sourceHooksFile); @@ -221,7 +240,7 @@ export async function emitFromDefinition( files.set(toPosix(manifestPath), manifestContent); } - const entry = definition.buildMarketplaceEntry({ + const entryContext = { pluginName, pluginPath, pluginConfig, @@ -230,12 +249,49 @@ export async function emitFromDefinition( mcpServers, pluginFiles, manifest, - }); + }; + const entry = definition.buildMarketplaceEntry(entryContext); if (entry) { // Deep-merge the config's per-plugin entry passthrough, so a target // can carry author-supplied fields it can't derive on its own. entries.push(stripUndefined(deepMerge(entry, pluginConfig.entry ?? {}))); } + for (const marketplace of clientMarketplaces) { + const clientEntry = ( + pluginConfig.clients as + Record }> | undefined + )?.[marketplace.client]?.entry; + clientEntries + .get(marketplace.client) + ?.push( + stripUndefined( + deepMerge(marketplace.buildEntry(entryContext), clientEntry ?? {}), + ), + ); + } + } + + for (const marketplace of clientMarketplaces) { + const clientManifest = ( + targetConfig.clients as + Record }> | undefined + )?.[marketplace.client]?.manifest; + files.set( + toPosix(marketplace.path), + json( + stripUndefined( + deepMerge( + marketplace.buildManifest({ + project, + targetConfig, + version, + plugins: clientEntries.get(marketplace.client) ?? [], + }), + clientManifest ?? {}, + ), + ), + ), + ); } const marketplace = stripUndefined( diff --git a/src/targets/types.ts b/src/targets/types.ts index c34e5d2..11239cd 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -39,6 +39,20 @@ export type MarketplaceManifestContext = { plugins: Record[]; }; +/** + * A marketplace written for one client in addition to a target's own, with + * its own entry and manifest builders. Each plugin's entry is deep-merged + * with `plugins..clients..entry`, and the manifest with + * `clients..manifest`. + */ +export type ClientMarketplace = { + client: string; + /** Output-relative path of the marketplace file. */ + path: string; + buildEntry: (ctx: MarketplaceEntryContext) => Record; + buildManifest: (ctx: MarketplaceManifestContext) => Record; +}; + export type InstallSnippetKind = "command" | "url"; export type InstallParams = { @@ -127,6 +141,17 @@ export type PluginTargetDefinition = { ) => Record; /** Output-relative paths the marketplace manifest is written to (may be more than one). */ marketplacePaths: (targetConfig: TargetConfig) => string[]; + /** Per-client marketplaces written alongside the target's own. */ + clientMarketplaces?: (targetConfig: TargetConfig) => ClientMarketplace[]; + + /** + * Maps one plugin-relative source file to the plugin-relative paths it is + * written to — none to drop it, more than one to give several clients + * their own copy. Defaults to the same path. Not applied to + * `hooks/hooks.json`, which `hooksPath` places. Throws for a file the + * target can't place. + */ + placeFile?: (relativePath: string, pluginName: string) => string[]; /** Return `undefined` for a target with no bundled-MCP-config file convention. */ mcpConfigPath: (pluginPath: string) => string | undefined; diff --git a/tests/agent-plugins.test.ts b/tests/agent-plugins.test.ts index 40a238f..204de4c 100644 --- a/tests/agent-plugins.test.ts +++ b/tests/agent-plugins.test.ts @@ -27,8 +27,19 @@ function skill(name: string): string { return `---\nname: ${name}\ndescription: The ${name} skill.\n---\n\n# ${name}\n`; } +const METADATA = `{ + description: "Acme plugins.", + author: { name: "Acme", url: "https://acme.example" }, + license: "MIT", + keywords: ["acme"] + }`; + /** A project with one shared source and a config whose `targets` block is supplied. */ -async function setup(targets: string, source: DirJSON = {}): Promise { +async function setup( + targets: string, + source: DirJSON = {}, + metadata: string = METADATA, +): Promise { project = new Project("agent-plugins-fixture", "1.0.0", { files: { "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -36,12 +47,7 @@ async function setup(targets: string, source: DirJSON = {}): Promise { 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"] - }, + metadata: ${metadata}, targets: { ${targets} } @@ -495,3 +501,466 @@ describe("standalone agent-plugins target", () => { ); }); }); + +const AGENT = `--- +name: reviewer +description: Reviews code. +--- + +Review the change. +`; + +const CLIENTS_TARGET = ` + "agent-plugins": { + outDir: "out", + clients: { + codex: { manifest: { interface: { displayName: "Acme for Codex" } } }, + copilot: {} + }, + plugins: { + acme: { + source: "shared/acme", + manifest: { extensions: { "com.openai": { interface: { displayName: "Acme" } } } }, + clients: { codex: { ${CODEX_ENTRY} } } + } + } + }`; + +describe("agent-plugins target with clients", () => { + it("builds one package with each client's files and a marketplace per client", async () => { + const root = await setup(CLIENTS_TARGET, { + agents: { "reviewer.md": AGENT }, + }); + + const [artifact] = await build({ cwd: root }); + + expect(artifact.managedPaths).toEqual([ + ".agents/plugins/marketplace.json", + ".github/plugin/marketplace.json", + "plugins/acme/com.github.copilot/agents/reviewer.agent.md", + "plugins/acme/mcp.json", + "plugins/acme/plugin.json", + "plugins/acme/skills/search/SKILL.md", + ]); + expect( + await readFile( + path.join( + root, + "out/plugins/acme/com.github.copilot/agents/reviewer.agent.md", + ), + "utf8", + ), + ).toBe(AGENT); + expect( + (await readJsonFile(root, "out/plugins/acme/plugin.json")).extensions, + ).toEqual({ "com.openai": { interface: { displayName: "Acme" } } }); + expect( + await readJsonFile(root, "out/.agents/plugins/marketplace.json"), + ).toEqual({ + name: "acme-plugins", + interface: { displayName: "Acme for Codex" }, + plugins: [ + { + name: "acme", + source: "./plugins/acme", + description: "Acme plugins.", + version: "1.2.0", + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Productivity", + }, + ], + }); + expect( + await readJsonFile(root, "out/.github/plugin/marketplace.json"), + ).toEqual({ + name: "acme-plugins", + owner: { name: "Acme" }, + metadata: { description: "Acme plugins.", version: "1.2.0" }, + plugins: [ + { + name: "acme", + source: "./plugins/acme", + description: "Acme plugins.", + version: "1.2.0", + }, + ], + }); + await expect( + validateOutput("agent-plugins", path.join(root, "out")), + ).resolves.toMatchObject({ ok: true, issues: [] }); + }); + + it("keeps an agent file already named NAME.agent.md", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { agents: { "reviewer.agent.md": AGENT } }, + ); + + const [artifact] = await build({ cwd: root }); + + expect(artifact.managedPaths).toContain( + "plugins/acme/com.github.copilot/agents/reviewer.agent.md", + ); + expect(artifact.managedPaths).not.toContain( + ".agents/plugins/marketplace.json", + ); + }); + + it("fails the build when Copilot's marketplace has no owner", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + {}, + `{ description: "Acme plugins." }`, + ); + + await expect(build({ cwd: root })).rejects.toThrow( + "Copilot's marketplace requires an owner. Set metadata.owner (or metadata.author)", + ); + }); + + it("rejects a client-only kind that no listed client loads, naming the client that does", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + clients: { codex: {} }, + plugins: { acme: { source: "shared/acme", include: ["skills", "agents"] } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + `agents only exist inside a client extension namespace; the agent-plugins target can't include them unless targets["agent-plugins"].clients lists a client that loads them (copilot)`, + ); + }); + + it("rejects per-plugin settings for a client the target doesn't list", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + clients: { codex: {} }, + plugins: { acme: { source: "shared/acme", clients: { copilot: {} } } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + `client "copilot" isn't listed in targets["agent-plugins"].clients`, + ); + }); + + it("accepts agents in include when copilot is listed", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme", include: ["skills", "agents"] } } + }`, + { agents: { "reviewer.md": AGENT } }, + ); + + const [artifact] = await build({ cwd: root }); + + expect(artifact.managedPaths).toContain( + "plugins/acme/com.github.copilot/agents/reviewer.agent.md", + ); + }); + + it("treats a client set to undefined as not listed", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + clients: { codex: {}, copilot: undefined }, + plugins: { + acme: { source: "shared/acme", include: ["skills", "agents"], clients: { copilot: {} } } + } + }`); + + const rejection = loadConfig(root); + + await expect(rejection).rejects.toThrow( + "agents only exist inside a client extension namespace", + ); + await expect(rejection).rejects.toThrow( + `client "copilot" isn't listed in targets["agent-plugins"].clients`, + ); + }); + + it("fails the build when two agent files would land on the same Copilot path", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { agents: { "reviewer.md": AGENT, "reviewer.agent.md": AGENT } }, + ); + + await expect(build({ cwd: root })).rejects.toThrow( + 'Target "agent-plugins" plugin "acme": "agents/reviewer.agent.md" and "agents/reviewer.md" both write "com.github.copilot/agents/reviewer.agent.md". Rename or remove one of them.', + ); + }); + + it("takes Copilot's marketplace owner from clients.copilot.manifest", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: { manifest: { owner: { name: "Acme Platform" } } } }, + plugins: { acme: { source: "shared/acme" } } + }`, + {}, + `{ description: "Acme plugins." }`, + ); + + await build({ cwd: root }); + + expect( + (await readJsonFile(root, "out/.github/plugin/marketplace.json")).owner, + ).toEqual({ name: "Acme Platform" }); + }); + + it("rejects a misspelled client name", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + clients: { copliot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`); + + await expect(loadConfig(root)).rejects.toThrow(/copliot/); + }); + + it("rejects per-plugin clients on another target", async () => { + const root = await setup(` + codex: { + outDir: "out", + plugins: { acme: { source: "shared/acme", ${CODEX_ENTRY}, clients: { codex: {} } } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + "targets.codex.plugins.acme.clients: clients is only supported for the agent-plugins target", + ); + }); + + it("renames .markdown and .mdc agents to NAME.agent.md for Copilot", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { agents: { "one.markdown": AGENT, "two.mdc": AGENT } }, + ); + + const [artifact] = await build({ cwd: root }); + + expect( + artifact.managedPaths.filter((file) => file.includes("/agents/")), + ).toEqual([ + "plugins/acme/com.github.copilot/agents/one.agent.md", + "plugins/acme/com.github.copilot/agents/two.agent.md", + ]); + }); + + it("fails the build for a file in agents/ that Copilot can't load", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { agents: { "reviewer.md": AGENT, "notes.txt": "notes\n" } }, + ); + + await expect(build({ cwd: root })).rejects.toThrow( + 'Target "agent-plugins" plugin "acme": "agents/notes.txt" isn\'t an agent file Copilot can load. Copilot loads only agents/NAME.agent.md; use a .md, .markdown or .mdc agent, or move the file out of agents/.', + ); + }); + + it("rejects entry and the target manifest, which agent-plugins doesn't use", async () => { + const root = await setup(` + "agent-plugins": { + outDir: "out", + clients: { codex: {} }, + manifest: { owner: { name: "Acme" } }, + plugins: { acme: { source: "shared/acme", ${CODEX_ENTRY} } } + }`); + + const rejection = loadConfig(root); + + await expect(rejection).rejects.toThrow( + `manifest isn't used by the agent-plugins target; set it per client in targets["agent-plugins"].clients..manifest`, + ); + await expect(rejection).rejects.toThrow( + `entry isn't used by the agent-plugins target; set it per client in targets["agent-plugins"].plugins.acme.clients..entry`, + ); + }); + + it("builds only the listed clients when a client is set to undefined", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { codex: {}, copilot: undefined }, + plugins: { acme: { source: "shared/acme", clients: { codex: { ${CODEX_ENTRY} } } } } + }`, + { agents: { "reviewer.md": AGENT } }, + ); + + const [artifact] = await build({ cwd: root }); + + expect(artifact.managedPaths).toContain(".agents/plugins/marketplace.json"); + expect(artifact.managedPaths).not.toContain( + ".github/plugin/marketplace.json", + ); + expect( + artifact.managedPaths.filter((file) => + file.includes("com.github.copilot"), + ), + ).toEqual([]); + }); + + it("fails the build for a client-only file that no listed client loads", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "commands/run.md": "extra.md" }, + })}\n`, + "extra.md": "# run\n", + }, + ); + + await expect(build({ cwd: root })).rejects.toThrow( + 'Target "agent-plugins" plugin "acme": "commands/run.md" is commands content, and none of the listed clients (copilot) loads commands. Remove it, or list a client that does.', + ); + }); + + it("reports a malformed Codex entry and accepts a remote Codex source", async () => { + const root = await setup(CLIENTS_TARGET); + await build({ cwd: root }); + const out = path.join(root, "out"); + const codexFile = path.join(out, ".agents/plugins/marketplace.json"); + const codex = JSON.parse(await readFile(codexFile, "utf8")) as { + plugins: unknown[]; + }; + const [entry] = codex.plugins as Record[]; + codex.plugins = [ + null, + { + ...entry, + source: { source: "url", url: "https://example.com/acme.git" }, + }, + ]; + await writeFile(codexFile, JSON.stringify(codex)); + + const result = await validateOutput("agent-plugins", out); + + expect(result.issues.map((issue) => issue.message)).toEqual([ + "plugins[0] must be an object.", + ]); + }); + + it("reports a missing Copilot owner, a structured Codex source that isn't a package, and agent frontmatter Copilot rejects", async () => { + const root = await setup(CLIENTS_TARGET, { + agents: { "reviewer.md": AGENT }, + }); + await build({ cwd: root }); + const out = path.join(root, "out"); + const codexFile = path.join(out, ".agents/plugins/marketplace.json"); + const codex = JSON.parse(await readFile(codexFile, "utf8")) as { + plugins: Record[]; + }; + codex.plugins[0].source = { source: "local", path: "./plugins/missing" }; + await writeFile(codexFile, JSON.stringify(codex)); + const copilotFile = path.join(out, ".github/plugin/marketplace.json"); + const copilot = JSON.parse(await readFile(copilotFile, "utf8")) as Record< + string, + unknown + >; + delete copilot.owner; + await writeFile(copilotFile, JSON.stringify(copilot)); + await writeFile( + path.join( + out, + "plugins/acme/com.github.copilot/agents/reviewer.agent.md", + ), + "---\nname: reviewer\n---\n\nReview.\n", + ); + await writeFile( + path.join(out, "plugins/acme/com.github.copilot/agents/notes.txt"), + "notes\n", + ); + + const result = await validateOutput("agent-plugins", out); + + expect(result.ok).toBe(false); + expect(result.issues.map((issue) => issue.message)).toEqual([ + 'acme: the Codex marketplace entry\'s source "./plugins/missing" is not an Agent Plugins package in this output.', + 'Marketplace is missing required field "owner".', + 'acme (com.github.copilot): agent frontmatter error in agents/reviewer.agent.md: Missing required "description" field.', + 'acme: agent file "com.github.copilot/agents/notes.txt" must be named NAME.agent.md for Copilot to load it.', + ]); + }); + + it("allows clients only on the agent-plugins target", async () => { + const root = await setup(` + codex: { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme", ${CODEX_ENTRY} } } + }`); + + await expect(loadConfig(root)).rejects.toThrow( + "targets.codex.clients: clients is only supported for the agent-plugins target", + ); + }); + + it("reports marketplace entries that don't match a package, and misnamed Copilot agents", async () => { + const root = await setup(CLIENTS_TARGET, { + agents: { "reviewer.md": AGENT }, + }); + await build({ cwd: root }); + const out = path.join(root, "out"); + const codexFile = path.join(out, ".agents/plugins/marketplace.json"); + const codex = JSON.parse(await readFile(codexFile, "utf8")) as { + plugins: Record[]; + }; + codex.plugins[0].name = "other"; + await writeFile(codexFile, JSON.stringify(codex)); + const copilotFile = path.join(out, ".github/plugin/marketplace.json"); + const copilot = JSON.parse(await readFile(copilotFile, "utf8")) as { + plugins: Record[]; + }; + copilot.plugins[0].source = "./plugins"; + await writeFile(copilotFile, JSON.stringify(copilot)); + await writeFile( + path.join(out, "plugins/acme/com.github.copilot/agents/stray.md"), + AGENT, + ); + + const result = await validateOutput("agent-plugins", out); + + expect(result.ok).toBe(false); + expect(result.issues.map((issue) => issue.message)).toEqual([ + 'other: the Codex marketplace entry name does not match plugin.json name ("acme").', + 'acme: the Copilot marketplace entry\'s source "./plugins" is not an Agent Plugins package in this output.', + 'acme: agent file "com.github.copilot/agents/stray.md" must be named NAME.agent.md for Copilot to load it.', + ]); + }); +}); diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 8a3d5af..cf608f5 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -444,6 +444,110 @@ describe("emitted output conforms to external target schemas", () => { expect(validated.exitCode, String(validated.stdout)).toBe(0); }); + it("agent-plugins with clients emits one schema-valid package plus the Codex and Copilot marketplaces", async () => { + await project.write({ + "pluginpack.config.ts": CONFIG.replace( + " targets: {", + ` targets: { + "agent-plugins": { + outDir: "out-ap", + clients: { codex: {}, copilot: {} }, + plugins: { + glean: { + from: ["glean"], + clients: { + codex: { + entry: { + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + } + } + } + } + } + },`, + ), + plugins: { + glean: { + agents: { + "reviewer.md": + "---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview.\n", + }, + }, + }, + }); + const result = await runBin("build", "--target", "agent-plugins"); + expect(result.exitCode, String(result.stderr)).toBe(0); + + // One package serves both clients. + 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([]); + // Copilot's client-only files live in its extension directory, named + // NAME.agent.md; nothing client-only is left at the package root. + expect( + fs.existsSync( + path.join( + project.baseDir, + "out-ap/plugins/glean/com.github.copilot/agents/reviewer.agent.md", + ), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(project.baseDir, "out-ap/plugins/glean/agents")), + ).toBe(false); + + // Codex: { name, interface, plugins }, entries with policy and category. + const codexMarketplace = readJson( + project.baseDir, + "out-ap/.agents/plugins/marketplace.json", + ); + expect(Object.keys(codexMarketplace).sort()).toEqual([ + "interface", + "name", + "plugins", + ]); + expect(codexMarketplace.plugins).toEqual([ + expect.objectContaining({ + name: "glean", + source: "./plugins/glean", + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools", + }), + ]); + + // Copilot: name, owner and plugins required; an Agent Plugins entry needs + // only name and source, with no component paths. + const copilotMarketplace = readJson( + project.baseDir, + "out-ap/.github/plugin/marketplace.json", + ); + expect(copilotMarketplace).toMatchObject({ + name: "glean-plugins", + owner: { name: "Glean" }, + }); + const [copilotEntry] = copilotMarketplace.plugins as Record< + string, + unknown + >[]; + expect(copilotEntry).toMatchObject({ + name: "glean", + source: "./plugins/glean", + }); + expect( + Object.keys(copilotEntry).filter( + (key) => !["name", "source", "description", "version"].includes(key), + ), + ).toEqual([]); + + 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 () => { const result = await runBin("install-info"); expect(result.exitCode, String(result.stderr)).toBe(0); From 62d3084855a527469b08b7beeb0dd4b6dd5110ac Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 12:47:13 -0700 Subject: [PATCH 2/3] fix: route hooks through client placement and normalize additionalFiles paths With clients set, hooks/hooks.json from additionalFiles skipped the client-only guard and shipped at the package root. A target that defines placeFile now places hooks too. An additionalFiles destination starting with ./ could silently replace a placed file. Destinations are now normalized, and the collision check keys on the output path. Also: reword client-only errors to say pluginpack doesn't place the kind yet, test that metadata.owner wins over metadata.author for Copilot, and document the manifest and entry errors, Copilot agent checks, and new marketplace collisions in README, CLAUDE.md and the config skill. --- CLAUDE.md | 11 ++- README.md | 11 ++- skills/authoring-pluginpack-config/SKILL.md | 4 +- src/schema.ts | 2 +- src/source.ts | 5 +- src/targets/agent-plugins.ts | 6 +- src/targets/engine.ts | 15 ++-- src/targets/types.ts | 4 +- tests/agent-plugins.test.ts | 96 ++++++++++++++++++++- 9 files changed, 135 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96c43c8..3afa8ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,9 @@ diff, prune, and validate all derive from it. `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 +(the package module). The `codex` target owns its marketplace. With `clients`, +`agent-plugins` takes each client's marketplace, extension-directory placement +and validation from `src/package-clients.ts`. 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 @@ -105,8 +107,11 @@ schemas at runtime — vendor a pinned copy with recorded provenance. - **Recommended shape:** `shared//` canonical sources, `overrides///` target overrides, and `repositories//` generated-repository files. -- **claude + copilot collide:** both write `.claude-plugin/marketplace.json`, so - they need distinct `outDir`s. `build()` errors on overlapping output paths. +- **Shared marketplace paths collide:** `claude` and `copilot` both write + `.claude-plugin/marketplace.json`. `agent-plugins` with `clients.codex` + writes the `codex` target's `.agents/plugins/marketplace.json`, and with + `clients.copilot` it writes `.github/plugin/marketplace.json`. Such targets + need distinct `outDir`s. `build()` errors on overlapping output paths. - **MCP:** a canonical source keeps `mcp/config.json`, its implementation, tests, and `mcp/pluginpack.json` shipping-file map together. Target adapters translate the config into the native output. Legacy sources still read `.mcp.json`. diff --git a/README.md b/README.md index e540545..626eded 100644 --- a/README.md +++ b/README.md @@ -327,17 +327,24 @@ targets: { - `clients..manifest` is deep-merged into that client's marketplace file. `plugins..clients..entry` is deep-merged into the plugin's entry in it. +- The target-level `manifest` and per-plugin `entry` are config errors on + `agent-plugins`, with or without `clients`, because the target writes no + shared marketplace for them to go into. Move them to + `clients..manifest` and `plugins..clients..entry`. - Client manifest data goes under `manifest.extensions[""]` explicitly. A package for several clients can't guess which client a top-level field belongs to, so a non-portable top-level field is still a build error. - A client-only content kind is selected by default, and allowed in - `include`, only when a listed client loads it. + `include`, only when pluginpack places it for a listed client. Today that + is `agents` for `copilot`. pluginpack doesn't place `commands`, `rules`, or + `hooks` for any client yet. - Copilot's marketplace requires an owner, from `metadata.owner` or `metadata.author`. - `validate` checks every package, then each client marketplace that's present: every entry must point at a package whose `plugin.json` has the - same name. + same name. Each Copilot agent must be named `NAME.agent.md` and have the + `name` and `description` frontmatter Copilot requires. `validate` checks packages against the specification: the closed manifest, a valid plugin name, `mcp.json` servers, and skill names that follow the diff --git a/skills/authoring-pluginpack-config/SKILL.md b/skills/authoring-pluginpack-config/SKILL.md index 267619f..90641a1 100644 --- a/skills/authoring-pluginpack-config/SKILL.md +++ b/skills/authoring-pluginpack-config/SKILL.md @@ -98,7 +98,9 @@ portable packages. Without `clients` it writes no marketplace and can't include `agents`, `commands`, `rules`, or `hooks`. With `clients: { codex: {}, copilot: {} }` it builds each package once and writes each client's marketplace. Listing `copilot` allows `agents`, which go to `com.github.copilot/agents/NAME.agent.md`; -no client allows `commands`, `rules`, or `hooks`. Put client manifest data +pluginpack doesn't place `commands`, `rules`, or `hooks` for any client yet. +The target-level `manifest` and per-plugin `entry` are errors on this target; +use `clients..manifest` and `plugins..clients..entry`. Put client manifest data under `manifest.extensions[""]`, and Codex `policy`/`category` under `plugins..clients.codex.entry`. Skill names must be lowercase letters, digits, and hyphens and match their directory, or Agent Plugins clients skip diff --git a/src/schema.ts b/src/schema.ts index 01234e2..f6acb7d 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -307,7 +307,7 @@ const configSchema = z `${unsupported.join(", ")} only exist inside a client extension namespace; the agent-plugins target can't include them` + (implementers.length > 0 ? ` unless targets["agent-plugins"].clients lists a client that loads them (${implementers.join(", ")})` - : " (no supported client loads them yet)"), + : " (pluginpack doesn't place them for any client yet)"), }); } for (const client of listedKeys(plugin.clients)) { diff --git a/src/source.ts b/src/source.ts index cea3da4..b2f139f 100644 --- a/src/source.ts +++ b/src/source.ts @@ -149,7 +149,8 @@ async function addDeclaredFiles( declared: Record | undefined, ): Promise { for (const [dest, source] of Object.entries(declared ?? {})) { - const destPath = toPosix(dest); + // Normalized, so "./agents/x.md" is placed and checked as "agents/x.md". + const destPath = path.posix.normalize(toPosix(dest)); if (files.has(destPath)) { throw new Error( `Authored plugin additionalFiles destination "${dest}" collides with another emitted file.`, @@ -299,7 +300,7 @@ async function readPluginFiles( const additionalFiles = plugin.manifest.additionalFiles; if (additionalFiles) { for (const [dest, source] of Object.entries(additionalFiles)) { - const destPath = toPosix(dest); + const destPath = path.posix.normalize(toPosix(dest)); if (!isSafeRelativePath(destPath)) { throw new Error( `Source plugin "${plugin.id}" additionalFiles destination "${dest}" must be a safe relative path.`, diff --git a/src/targets/agent-plugins.ts b/src/targets/agent-plugins.ts index 23d9714..d4b00ab 100644 --- a/src/targets/agent-plugins.ts +++ b/src/targets/agent-plugins.ts @@ -67,7 +67,9 @@ export const agentPlugins: PluginTargetDefinition = { mcpConfigPath: (pluginPath) => path.join(pluginPath, packageLayout.mcp), mcpDialect: "agent-plugins", - // Unreachable: hooks are a client-only content kind for this target. + // Hooks are a client-only content kind for this target. The schema rejects + // selecting them; a hooks file from additionalFiles lands here without + // clients (validate reports it) and goes through placeFile with them. hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), validateManifest: () => {}, @@ -147,7 +149,7 @@ function withClients( // here (e.g. through plugin.pluginpack.json additionalFiles). if (placed.length === 0) { throw new Error( - `Target "agent-plugins" plugin "${pluginName}": "${relativePath}" is ${kind} content, and none of the listed clients (${clients.map((client) => client.name).join(", ")}) loads ${kind}. Remove it, or list a client that does.`, + `Target "agent-plugins" plugin "${pluginName}": "${relativePath}" is ${kind} content, and pluginpack doesn't place ${kind} for any of the listed clients (${clients.map((client) => client.name).join(", ")}). Remove it, or list a client pluginpack places ${kind} for.`, ); } return placed; diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 3a5bff8..93c2d5c 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -174,8 +174,11 @@ export async function emitFromDefinition( // verbatim from the source's conventional hooks/hooks.json, so a target // whose real hooks convention differs (e.g. a root-level file, not a // directory) gets it right by construction. A same-path target is a - // same-path no-op. - const sourceHooksFile = pluginFiles.get("hooks/hooks.json"); + // same-path no-op. A target that places files itself places hooks too, + // so it can move them into a client's directory or reject them. + const sourceHooksFile = definition.placeFile + ? undefined + : pluginFiles.get("hooks/hooks.json"); if (sourceHooksFile !== undefined) { pluginFiles.delete("hooks/hooks.json"); } @@ -186,14 +189,16 @@ export async function emitFromDefinition( for (const placed of definition.placeFile?.(relativePath, pluginName) ?? [ relativePath, ]) { - const earlier = placedFrom.get(placed); + // Key on the joined output path, so "./a" and "a" collide. + const outputPath = toPosix(path.join(pluginPath, placed)); + const earlier = placedFrom.get(outputPath); if (earlier !== undefined && earlier !== relativePath) { throw new Error( `Target "${target}" plugin "${pluginName}": "${earlier}" and "${relativePath}" both write "${placed}". Rename or remove one of them.`, ); } - placedFrom.set(placed, relativePath); - files.set(toPosix(path.join(pluginPath, placed)), value); + placedFrom.set(outputPath, relativePath); + files.set(outputPath, value); } } if (sourceHooksFile !== undefined) { diff --git a/src/targets/types.ts b/src/targets/types.ts index 11239cd..943c222 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -147,8 +147,8 @@ export type PluginTargetDefinition = { /** * Maps one plugin-relative source file to the plugin-relative paths it is * written to — none to drop it, more than one to give several clients - * their own copy. Defaults to the same path. Not applied to - * `hooks/hooks.json`, which `hooksPath` places. Throws for a file the + * their own copy. Defaults to the same path. When set, it also places + * `hooks/hooks.json` and `hooksPath` is not used. Throws for a file the * target can't place. */ placeFile?: (relativePath: string, pluginName: string) => string[]; diff --git a/tests/agent-plugins.test.ts b/tests/agent-plugins.test.ts index 204de4c..2103d39 100644 --- a/tests/agent-plugins.test.ts +++ b/tests/agent-plugins.test.ts @@ -727,6 +727,29 @@ describe("agent-plugins target with clients", () => { ).toEqual({ name: "Acme Platform" }); }); + it("takes Copilot's marketplace owner from metadata.owner before metadata.author", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + {}, + `{ + description: "Acme plugins.", + owner: { name: "Acme Platform", email: "platform@acme.example" }, + author: { name: "Jane Doe" } + }`, + ); + + await build({ cwd: root }); + + expect( + (await readJsonFile(root, "out/.github/plugin/marketplace.json")).owner, + ).toEqual({ name: "Acme Platform", email: "platform@acme.example" }); + }); + it("rejects a misspelled client name", async () => { const root = await setup(` "agent-plugins": { @@ -847,7 +870,78 @@ describe("agent-plugins target with clients", () => { ); await expect(build({ cwd: root })).rejects.toThrow( - 'Target "agent-plugins" plugin "acme": "commands/run.md" is commands content, and none of the listed clients (copilot) loads commands. Remove it, or list a client that does.', + 'Target "agent-plugins" plugin "acme": "commands/run.md" is commands content, and pluginpack doesn\'t place commands for any of the listed clients (copilot). Remove it, or list a client pluginpack places commands for.', + ); + }); + + it("fails the build for a hooks file that no listed client loads", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "hooks/hooks.json": "extra.json" }, + })}\n`, + "extra.json": "{}\n", + }, + ); + + await expect(build({ cwd: root })).rejects.toThrow( + 'Target "agent-plugins" plugin "acme": "hooks/hooks.json" is hooks content, and pluginpack doesn\'t place hooks for any of the listed clients (copilot).', + ); + }); + + it("fails the build when a ./ destination would replace a placed agent", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { + agents: { "reviewer.md": AGENT }, + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { + "./com.github.copilot/agents/reviewer.agent.md": "extra.md", + }, + })}\n`, + "extra.md": "# declared\n", + }, + ); + + await expect(build({ cwd: root })).rejects.toThrow( + 'both write "com.github.copilot/agents/reviewer.agent.md"', + ); + }); + + it("places a ./ agents destination for Copilot", async () => { + const root = await setup( + ` + "agent-plugins": { + outDir: "out", + clients: { copilot: {} }, + plugins: { acme: { source: "shared/acme" } } + }`, + { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "./agents/reviewer.md": "extra.md" }, + })}\n`, + "extra.md": AGENT, + }, + ); + + const [artifact] = await build({ cwd: root }); + + expect(artifact.managedPaths).toContain( + "plugins/acme/com.github.copilot/agents/reviewer.agent.md", + ); + expect(artifact.managedPaths).not.toContain( + "plugins/acme/agents/reviewer.md", ); }); From 9414804293f34a7911dd3968a1c348b21f0336b0 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Wed, 21 Oct 2026 12:54:56 -0700 Subject: [PATCH 3/3] test: cover path normalization in the engine and legacy sources --- tests/core.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/core.test.ts b/tests/core.test.ts index bfbb134..c0a4ad9 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1117,6 +1117,41 @@ export default defineConfig({ ); }); + it("rejects two file paths that write the same output file", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "mcp-collision-plugins", + version: "1.0.0", + metadata: { description: "MCP collision", author: { name: "M" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { source: "shared/demo" } } + } + } +}); +`, + shared: { + demo: { + "README.md": "# Shared README\n", + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + mcp: { + "pluginpack.json": `${JSON.stringify({ + files: { "./README.md": "start.mjs" }, + })}\n`, + "start.mjs": "console.log('server');\n", + }, + }, + }, + }); + + await expect( + build({ cwd: project.baseDir, target: "claude" }), + ).rejects.toThrow(/"README\.md" and "\.\/README\.md" both write/); + }); + it("excludes an authored plugin's complete MCP capability", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; @@ -2331,6 +2366,36 @@ export default defineConfig({ ); }); + it("normalizes a plugin-root files destination before the collision check", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-collide-plugins", + version: "1.0.0", + metadata: { description: "Collide", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + additionalFiles: { "./skills/s1/SKILL.md": "extra.md" }, + })}\n`, + "extra.md": "# extra\n", + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /additionalFiles destination "\.\/skills\/s1\/SKILL.md" collides with another emitted file/, + ); + }); + it("rejects a plugin-root files source that cannot be read", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}";