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/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..626eded 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,73 @@ 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. +- 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 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. 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 @@ -543,38 +604,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..90641a1 100644 --- a/skills/authoring-pluginpack-config/SKILL.md +++ b/skills/authoring-pluginpack-config/SKILL.md @@ -93,10 +93,18 @@ 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`; +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 +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..f6acb7d 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(", ")})` + : " (pluginpack doesn't place them for any client 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/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 dcbdccc..d4b00ab 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"], @@ -45,12 +67,17 @@ 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: () => {}, 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 +90,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 +120,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 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; + }, + 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..93c2d5c 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, @@ -169,13 +174,32 @@ 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"); } + // 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, + ]) { + // 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(outputPath, relativePath); + files.set(outputPath, value); + } } if (sourceHooksFile !== undefined) { files.set(toPosix(definition.hooksPath(pluginPath)), sourceHooksFile); @@ -221,7 +245,7 @@ export async function emitFromDefinition( files.set(toPosix(manifestPath), manifestContent); } - const entry = definition.buildMarketplaceEntry({ + const entryContext = { pluginName, pluginPath, pluginConfig, @@ -230,12 +254,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..943c222 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. 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[]; /** 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..2103d39 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,560 @@ 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("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": { + 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 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", + ); + }); + + 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); 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")}";