diff --git a/CONFORMANCE.md b/CONFORMANCE.md index ccf930c..a9ff02f 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -8,12 +8,13 @@ format. There is no single, referenceable, upstream JSON Schema for any supported target. Each app's source of truth is something other than a stable schema URL: -| Target | Canonical source of truth | Referenceable schema? | -| ------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `claude` | `claude plugin validate` CLI + [plugins-reference docs](https://code.claude.com/docs/en/plugins-reference) | **No.** The `$schema` URL the manifest declares (`https://anthropic.com/claude-code/marketplace.schema.json`) returns 404. | -| `cursor` | Glean-authored schemas in `gleanwork/cursor-plugins/schemas/` | **No upstream.** The schema `$id` (`https://cursor.com/schemas/cursor-plugin/...`) 500s; no Cursor-published schema found. | -| `antigravity` | Antigravity CLI plugin docs (`plugin.json`, optional `mcp_config.json`) | **No.** Defined by product docs and observed CLI layout, not a published schema. | -| `copilot` | [`github/copilot-plugins`](https://github.com/github/copilot-plugins) — a Claude-marketplace-derived format | **Structural.** Copilot shares the Claude marketplace base but extends entries (`skills[]`, `mcpServers` as a path), which `claude plugin validate` rejects — so conformance is asserted structurally against the official format. | +| Target | Canonical source of truth | Referenceable schema? | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `claude` | `claude plugin validate` CLI + [plugins-reference docs](https://code.claude.com/docs/en/plugins-reference) | **No.** The `$schema` URL the manifest declares (`https://anthropic.com/claude-code/marketplace.schema.json`) returns 404. | +| `cursor` | Glean-authored schemas in `gleanwork/cursor-plugins/schemas/` | **No upstream.** The schema `$id` (`https://cursor.com/schemas/cursor-plugin/...`) 500s; no Cursor-published schema found. | +| `antigravity` | Antigravity CLI plugin docs (`plugin.json`, optional `mcp_config.json`) | **No.** Defined by product docs and observed CLI layout, not a published schema. | +| `copilot` | [`github/copilot-plugins`](https://github.com/github/copilot-plugins) — a Claude-marketplace-derived format | **Structural.** Copilot shares the Claude marketplace base but extends entries (`skills[]`, `mcpServers` as a path), which `claude plugin validate` rejects — so conformance is asserted structurally against the official format. | +| `codex` | [OpenAI Codex CLI plugin docs](https://developers.openai.com/codex/plugins/build) (`.codex-plugin/plugin.json` + `.agents/plugins/marketplace.json`) | **No published schema.** Defined by product docs; conformance is asserted structurally against the documented format (retrieved 2026-06-17). | ## Oracles the harness uses @@ -47,6 +48,14 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic). `tests/core.test.ts` (required `plugin.json` fields present; optional `mcp_config.json` written when MCP servers are present). Antigravity CLI does not expose a published schema to validate against. +- **codex** — asserted structurally in `tests/conformance.test.ts` against the + [documented Codex plugin format](https://developers.openai.com/codex/plugins/build): + a repo-scoped `.agents/plugins/marketplace.json` (`{ name, interface, plugins }`) + plus a per-plugin `.codex-plugin/plugin.json` (`{ name, version, description, +skills }`) and optional `.mcp.json`. No published JSON Schema exists; the test + pins the documented shape and confirms a per-plugin `entry` passthrough lands in + the marketplace entry. Codex shares no marketplace path with the other targets, + so it needs no separate output root. ## Refreshing vendored schemas diff --git a/README.md b/README.md index 6869fd0..1dccf04 100644 --- a/README.md +++ b/README.md @@ -250,8 +250,9 @@ The first adapters are: - `claude` - `antigravity` - `copilot` +- `codex` -`cursor` emits Cursor plugin and marketplace manifests. `claude` emits Claude plugin and marketplace manifests. `antigravity` emits Antigravity CLI plugins with a `plugin.json` manifest and optional `mcp_config.json`. `copilot` emits the GitHub Copilot plugins format (per [`github/copilot-plugins`](https://github.com/github/copilot-plugins)): a `.claude-plugin/marketplace.json` mirrored to `.github/plugin/marketplace.json`, each plugin under `plugins//` with a `skills` array per marketplace entry. +`cursor` emits Cursor plugin and marketplace manifests. `claude` emits Claude plugin and marketplace manifests. `antigravity` emits Antigravity CLI plugins with a `plugin.json` manifest and optional `mcp_config.json`. `copilot` emits the GitHub Copilot plugins format (per [`github/copilot-plugins`](https://github.com/github/copilot-plugins)): a `.claude-plugin/marketplace.json` mirrored to `.github/plugin/marketplace.json`, each plugin under `plugins//` with a `skills` array per marketplace entry. `codex` emits the [OpenAI Codex CLI plugin format](https://developers.openai.com/codex/plugins/build): a repo-scoped `.agents/plugins/marketplace.json` plus a per-plugin `.codex-plugin/plugin.json` manifest and optional `.mcp.json`. Because Copilot reuses the Claude marketplace layout, the `claude` and `copilot` targets both write `.claude-plugin/marketplace.json` and therefore need separate output roots (distinct `outDir`s or separate repos). @@ -360,7 +361,7 @@ To publish a repo-root file (for example a README authored once in the source re | `category` | string | Marketplace category. | | `tags` | string[] | Free-form tags. | -**`targets.`** — `` is one of `cursor`, `claude`, `antigravity`, `copilot`. +**`targets.`** — `` is one of `cursor`, `claude`, `antigravity`, `copilot`, `codex`. | Field | Type | Required | Meaning | | ------------------ | ---------------------- | -------- | ---------------------------------------------------------------------------------------- | @@ -375,15 +376,16 @@ To publish a repo-root file (for example a README authored once in the source re **`targets..plugins.`** -| Field | Type | Required | Meaning | -| ------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | -| `from` | string[] (min 1) | yes | Source plugin ids to merge into this emitted plugin. | -| `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. | -| `components` | string[] | no | Exact component set, overriding the target's smart default. | +| Field | Type | Required | Meaning | +| ------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `from` | string[] (min 1) | yes | Source plugin ids to merge into this emitted plugin. | +| `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[] | no | Exact component set, overriding the target's smart default. | ## Programmatic API @@ -445,7 +447,7 @@ Exit codes: Compile configured source plugins into target-native plugin payloads. ```bash -pluginpack build [--target cursor|claude|antigravity|copilot] [--out-dir ] [--dry-run] +pluginpack build [--target cursor|claude|antigravity|copilot|codex] [--out-dir ] [--dry-run] ``` Options: @@ -470,7 +472,7 @@ Exit codes: Validate an existing target output directory for native manifest, path, and frontmatter requirements. ```bash -pluginpack validate --target cursor|claude|antigravity|copilot [--dir ] +pluginpack validate --target cursor|claude|antigravity|copilot|codex [--dir ] ``` Options: @@ -492,7 +494,7 @@ Exit codes: Build into a temporary directory and compare generated managed files with an existing target repo. ```bash -pluginpack diff --target cursor|claude|antigravity|copilot --against +pluginpack diff --target cursor|claude|antigravity|copilot|codex --against ``` Options: @@ -514,7 +516,7 @@ Exit codes: Remove stale managed files that are no longer emitted by the current config. ```bash -pluginpack prune [--target cursor|claude|antigravity|copilot] [--dry-run] +pluginpack prune [--target cursor|claude|antigravity|copilot|codex] [--dry-run] ``` Options: @@ -538,7 +540,7 @@ Exit codes: Remove all managed files for configured target outputs. ```bash -pluginpack clean [--target cursor|claude|antigravity|copilot] [--dry-run] +pluginpack clean [--target cursor|claude|antigravity|copilot|codex] [--dry-run] ``` Options: diff --git a/src/adapters.ts b/src/adapters.ts index c3d8f8b..0b7618a 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { emitAntigravity, emitClaude, + emitCodex, emitCopilot, emitCursor, withRootFiles, @@ -9,6 +10,7 @@ import { import { validateAntigravity, validateClaude, + validateCodex, validateCopilot, validateCursor, } from "./validate.js"; @@ -47,6 +49,7 @@ export const adapters: Record = { claude: { emit: emitClaude, validate: validateClaude }, antigravity: { emit: emitAntigravity, validate: validateAntigravity }, copilot: { emit: emitCopilot, validate: validateCopilot }, + codex: { emit: emitCodex, validate: validateCodex }, }; export const targetNames = Object.keys(adapters) as TargetName[]; diff --git a/src/cli.ts b/src/cli.ts index 5c59a95..ef481d8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ async function main(): Promise { function createProgram(): Command { const program = new Command(); const pkg = readPackageJson(); + const targetList = targetNames.join("|"); program.name("pluginpack").description(pkg.description).version(pkg.version); @@ -31,9 +32,7 @@ function createProgram(): Command { .description( "Compile configured source plugins into target-native plugin payloads.", ) - .usage( - "[--target cursor|claude|antigravity|copilot] [--out-dir ] [--dry-run]", - ) + .usage(`[--target ${targetList}] [--out-dir ] [--dry-run]`) .addOption( new Option( "--target ", @@ -77,7 +76,7 @@ function createProgram(): Command { .description( "Validate an existing target output directory for native manifest, path, and frontmatter requirements.", ) - .usage("--target cursor|claude|antigravity|copilot [--dir ]") + .usage(`--target ${targetList} [--dir ]`) .requiredOption( "--target ", "Required target validator.", @@ -114,7 +113,7 @@ function createProgram(): Command { .description( "Build into a temporary directory and compare generated managed files with an existing target repo.", ) - .usage("--target cursor|claude|antigravity|copilot --against ") + .usage(`--target ${targetList} --against `) .requiredOption( "--target ", "Required target to build and compare.", @@ -145,7 +144,7 @@ function createProgram(): Command { .description( "Remove stale managed files that are no longer emitted by the current config.", ) - .usage("[--target cursor|claude|antigravity|copilot] [--dry-run]") + .usage(`[--target ${targetList}] [--dry-run]`) .addOption( new Option( "--target ", @@ -171,7 +170,7 @@ function createProgram(): Command { program .command("clean") .description("Remove all managed files for configured target outputs.") - .usage("[--target cursor|claude|antigravity|copilot] [--dry-run]") + .usage(`[--target ${targetList}] [--dry-run]`) .addOption( new Option( "--target ", diff --git a/src/components.ts b/src/components.ts index a9b9ee2..beaeaa5 100644 --- a/src/components.ts +++ b/src/components.ts @@ -19,6 +19,7 @@ export const targetDefaultComponents: Record = { copilot: ["skills", "agents", "hooks", "scripts", "assets"], cursor: ["skills", "agents", "rules", "hooks", "scripts", "assets"], antigravity: ["skills", "agents", "rules", "hooks", "scripts", "assets"], + codex: ["skills", "agents", "hooks", "scripts", "assets"], }; export function resolveTargetComponents( diff --git a/src/schema.ts b/src/schema.ts index 0947c8a..88ce225 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -49,6 +49,10 @@ const emittedPluginSchema = z.object({ description: z.string().optional(), displayName: z.string().optional(), manifest: z.record(z.string(), z.unknown()).optional(), + // Deep-merged into this plugin's generated marketplace entry (the object in + // 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(), components: z.array(z.string()).optional(), }); @@ -77,6 +81,7 @@ const configSchema = z.object({ copilot: targetSchema.optional(), cursor: targetSchema.optional(), antigravity: targetSchema.optional(), + codex: targetSchema.optional(), }), }); diff --git a/src/targets.ts b/src/targets.ts index c4f4833..82e06e6 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -148,7 +148,9 @@ async function emitPlugins( manifest, }); if (entry) { - entries.push(entry); + // Deep-merge the config's per-plugin entry passthrough so a target can + // carry author-supplied fields it can't derive (e.g. Codex policy/category). + entries.push(stripUndefined(deepMerge(entry, pluginConfig.entry ?? {}))); } } return entries; @@ -371,6 +373,65 @@ export async function emitCopilot( return artifact(target, outDir, files); } +export async function emitCodex( + project: ResolvedProject, + target: TargetName, + targetConfig: TargetConfig, + outDir: string, +): Promise { + const pluginRoot = targetConfig.pluginRoot ?? "plugins"; + const version = targetConfig.version ?? project.config.version; + const files = new Map(); + + const plugins = await emitPlugins(project, target, targetConfig, files, { + resolvePluginPath: (pluginName, pluginConfig) => + pluginConfig.path ?? toPosix(path.join(pluginRoot, pluginName)), + pluginManifest: { + path: (pluginPath) => + path.join(pluginPath, ".codex-plugin", "plugin.json"), + build: (metadata, pluginName, pluginConfig, componentDirs, mcpServers) => + codexPluginManifest( + metadata, + pluginConfig.version ?? version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + ), + }, + // Base entry stays guess-free; authors supply policy/category via `entry`. + buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({ + name: pluginName, + source: `./${pluginPath}`, + description: + pluginConfig.description ?? + (manifest?.description as string | undefined), + version: pluginConfig.version ?? version, + }), + mcp: "file", + }); + + const marketplace = stripUndefined( + deepMerge( + { + name: project.config.name, + interface: { + displayName: + project.config.metadata?.displayName ?? project.config.name, + }, + plugins, + }, + targetConfig.manifest ?? {}, + ), + ); + files.set( + toPosix(path.join(".agents", "plugins", "marketplace.json")), + json(marketplace), + ); + + return artifact(target, outDir, files); +} + function emittedPluginMetadata( project: ResolvedProject, pluginConfig: EmittedPluginConfig, @@ -447,6 +508,34 @@ function claudePluginManifest( return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); } +function codexPluginManifest( + metadata: Metadata | undefined, + version: string, + pluginName: string, + pluginConfig: EmittedPluginConfig, + componentDirs: Set, + mcpServers: Record | undefined, +): Record { + const manifest: Record = { + name: pluginName, + version, + description: pluginConfig.description ?? metadata?.description, + author: metadata?.author, + homepage: metadata?.homepage, + repository: metadata?.repository, + license: metadata?.license, + keywords: metadata?.keywords, + }; + // Codex manifests point `skills` at the bundled folder (not a per-skill list). + if (componentDirs.has("skills")) { + manifest.skills = "./skills/"; + } + if (mcpServers) { + manifest.mcpServers = "./.mcp.json"; + } + return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); +} + function antigravityPluginManifest( metadata: Metadata | undefined, version: string, diff --git a/src/types.ts b/src/types.ts index 8dca72b..066eeb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,7 +18,12 @@ export type { SourcePluginManifest, }; -export type TargetName = "claude" | "copilot" | "cursor" | "antigravity"; +export type TargetName = + | "claude" + | "copilot" + | "cursor" + | "antigravity" + | "codex"; export type SourcePlugin = { id: string; diff --git a/src/validate.ts b/src/validate.ts index 2d52e13..3915346 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -220,6 +220,63 @@ export async function validateClaude( } } +export async function validateCodex( + root: string, + issues: ValidationIssue[], +): Promise { + const marketplacePath = path.join( + root, + ".agents", + "plugins", + "marketplace.json", + ); + const marketplace = await readJson( + marketplacePath, + "Marketplace manifest", + issues, + ); + if (!marketplace) { + return; + } + validateMarketplaceBasics(marketplace, issues); + const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; + if (plugins.length === 0) { + error(issues, 'Marketplace "plugins" must be a non-empty array.'); + return; + } + for (const [index, entry] of plugins.entries()) { + const pluginName = validatePluginEntry(entry, index, root, issues); + if (!pluginName) { + continue; + } + const pluginDir = path.join(root, entry.source); + const manifest = await readJson( + path.join(pluginDir, ".codex-plugin", "plugin.json"), + `${pluginName} plugin manifest`, + issues, + ); + if (!manifest) { + continue; + } + if (manifest.name !== pluginName) { + error( + issues, + `${pluginName}: marketplace entry name does not match plugin.json name ("${manifest.name}").`, + ); + } + for (const field of ["name", "version", "description"]) { + if (typeof manifest[field] !== "string" || !manifest[field]) { + error( + issues, + `${pluginName}: plugin.json is missing required field "${field}".`, + ); + } + } + await validateFrontmatter(pluginDir, pluginName, "codex", issues); + await validateHooks(pluginDir, pluginName, issues); + } +} + function validateMarketplaceBasics( marketplace: Record, issues: ValidationIssue[], diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 47dd230..053d83b 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -105,6 +105,18 @@ const CONFIG = `export default { copilot: { outDir: "out-copilot", plugins: { glean: { from: ["glean"] } } + }, + codex: { + outDir: "out-codex", + plugins: { + glean: { + from: ["glean"], + entry: { + policy: { installation: "AVAILABLE", authentication: "NONE" }, + category: "Developer Tools" + } + } + } } } }; @@ -259,4 +271,37 @@ describe("emitted output conforms to external target schemas", () => { mcpServers: ".mcp.json", }); }); + + it("codex emits the documented Codex plugin marketplace layout", async () => { + const result = await runBin("build", "--target", "codex"); + expect(result.exitCode, String(result.stderr)).toBe(0); + + const marketplace = readJson( + project.baseDir, + "out-codex/.agents/plugins/marketplace.json", + ); + expect(marketplace).toMatchObject({ + name: "glean-plugins", + interface: { displayName: "glean-plugins" }, + }); + // Base entry fields + the per-plugin `entry` passthrough (policy/category). + expect((marketplace.plugins as unknown[])[0]).toMatchObject({ + name: "glean", + source: "./plugins/glean", + version: "2.1.1", + policy: { installation: "AVAILABLE", authentication: "NONE" }, + category: "Developer Tools", + }); + + const plugin = readJson( + project.baseDir, + "out-codex/plugins/glean/.codex-plugin/plugin.json", + ); + expect(plugin).toMatchObject({ + name: "glean", + version: "2.1.1", + skills: "./skills/", + mcpServers: "./.mcp.json", + }); + }); });