diff --git a/CONFORMANCE.md b/CONFORMANCE.md index e94b9dd..80f1a66 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -14,7 +14,7 @@ Each app's source of truth is something other than a stable schema URL: | `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). | +| `codex` | [OpenAI Codex CLI plugin docs](https://developers.openai.com/codex/plugins/build) (`.codex-plugin/plugin.json` + `.agents/plugins/marketplace.json`) | **No published schema.** Defined by product docs; conformance is asserted structurally against the documented format (retrieved 2026-07-26). | ## Oracles the harness uses @@ -48,14 +48,24 @@ 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. +- **codex** — asserted structurally in `tests/conformance.test.ts` and + `tests/core.test.ts` against the + [documented Codex plugin format](https://developers.openai.com/codex/plugins/build) + (re-verified 2026-07-26 via direct fetch, twice, for consistency): a + repo-scoped `.agents/plugins/marketplace.json` (`{ name, interface, plugins }`, + no `owner` field) plus a per-plugin `.codex-plugin/plugin.json` where only + `name` is required — `version`/`description`/`skills`/`hooks`/`mcpServers` are + optional pointers to bundled components. Every marketplace entry must carry + `policy.installation`, `policy.authentication`, and `category`; pluginpack has + no way to infer these, so the base entry stays guess-free and `validateOutput` + errors clearly if an author never supplies them via the per-plugin `entry` + passthrough. An entry's `source` is a bare string only for local plugins (the + only shape pluginpack itself ever emits); a `url`/`git-subdir`/`npm` source + 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. ## Update-check hook facts diff --git a/src/targets.ts b/src/targets.ts index 9962e33..9b490cf 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -469,7 +469,11 @@ export async function emitCopilot( return artifact(target, outDir, files); } -/** Emits the Codex target's plugins and marketplace manifest. */ +/** + * @deprecated Legacy emitter, superseded by `src/targets/codex.ts` via the + * registry in `src/targets/registry.ts`. Kept only until every target has + * migrated (see `src/adapters.ts`). + */ export async function emitCodex( project: ResolvedProject, target: TargetName, diff --git a/src/targets/codex.ts b/src/targets/codex.ts new file mode 100644 index 0000000..2ad055a --- /dev/null +++ b/src/targets/codex.ts @@ -0,0 +1,308 @@ +import path from "node:path"; +import { isSafeRelativePath, toPosix } from "../fs.js"; +import { stripUndefined } from "./shared.js"; +import { + error, + pathExistsSync, + readJson, + validateFrontmatter, + validateHooksShape, + validateMarketplaceBasics, + validateReferencedManifestPaths, +} from "./validation-shared.js"; +import type { ValidationIssue } from "../types.js"; +import type { PluginTargetDefinition } from "./types.js"; + +const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +const sourceKinds = new Set(["local", "url", "git-subdir", "npm"]); + +/** + * Resolves a marketplace entry's local plugin directory, or `null` when the + * entry points somewhere pluginpack's own output doesn't contain (a remote + * `url`/`git-subdir`/`npm` source, added via the `entry` passthrough for a + * plugin this build doesn't itself emit). `null` means "nothing local left + * to validate," not "invalid" — shape validation already happened in + * `validateCodexEntry`. + */ +function resolveLocalPluginDir(root: string, source: unknown): string | null { + if (typeof source === "string") { + return path.join(root, source); + } + if ( + source && + typeof source === "object" && + (source as Record).source === "local" && + typeof (source as Record).path === "string" + ) { + return path.join(root, (source as Record).path as string); + } + return null; +} + +/** + * Validates one Codex marketplace entry. `source` may be a bare string (a + * local relative path — the only shape pluginpack itself ever emits) or a + * structured object with an inner `source` discriminator (`"local"`, + * `"url"`, `"git-subdir"`, `"npm"`) for entries an author adds via the + * `entry` passthrough to describe a plugin hosted elsewhere. Every entry, + * regardless of source shape, must carry `policy.installation`, + * `policy.authentication`, and `category` — see `citations`. + */ +function validateCodexEntry( + entry: Record, + index: number, + root: string, + issues: ValidationIssue[], +): string | null { + if (!entry || typeof entry !== "object") { + error(issues, `plugins[${index}] must be an object.`); + return null; + } + if (typeof entry.name !== "string" || !pluginNamePattern.test(entry.name)) { + error( + issues, + `plugins[${index}].name must be lowercase and use only alphanumerics, hyphens, and periods.`, + ); + return null; + } + const { name } = entry; + if (typeof entry.source === "string") { + if (!isSafeRelativePath(entry.source)) { + error(issues, `${name}: source must be a safe relative path.`); + } else if ( + !entry.source.startsWith("http") && + !pathExistsSync(path.join(root, entry.source)) + ) { + error(issues, `${name}: source directory is missing: ${entry.source}`); + } + } else if (entry.source && typeof entry.source === "object") { + const source = entry.source as Record; + if (typeof source.source !== "string" || !sourceKinds.has(source.source)) { + error( + issues, + `${name}: source.source must be one of ${[...sourceKinds].join(", ")}.`, + ); + } else if (source.source === "local" && typeof source.path !== "string") { + error(issues, `${name}: a "local" source requires a "path".`); + } else if ( + (source.source === "url" || source.source === "git-subdir") && + typeof source.url !== "string" + ) { + error(issues, `${name}: a "${source.source}" source requires a "url".`); + } else if (source.source === "npm" && typeof source.package !== "string") { + error(issues, `${name}: an "npm" source requires a "package".`); + } + } else { + error(issues, `${name}: source must be a string or a structured object.`); + } + const policy = entry.policy as Record | undefined; + if (!policy || typeof policy.installation !== "string") { + error( + issues, + `${name}: entry is missing required field "policy.installation".`, + ); + } + if (!policy || typeof policy.authentication !== "string") { + error( + issues, + `${name}: entry is missing required field "policy.authentication".`, + ); + } + if (typeof entry.category !== "string" || !entry.category) { + error(issues, `${name}: entry is missing required field "category".`); + } + return name; +} + +/** OpenAI Codex CLI plugin target — see `citations` for source facts. */ +export const codex: PluginTargetDefinition = { + name: "codex", + + defaultComponents: ["skills", "hooks", "scripts", "assets"], + + resolvePluginPath: (pluginName, pluginConfig, targetConfig) => + pluginConfig.path ?? + toPosix(path.join(targetConfig.pluginRoot ?? "plugins", pluginName)), + + buildPluginManifest: ({ + metadata, + version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + }) => { + const manifest: Record = { + name: pluginName, + version: pluginConfig.version ?? version, + description: pluginConfig.description ?? metadata?.description, + author: metadata?.author, + homepage: metadata?.homepage, + repository: metadata?.repository, + license: metadata?.license, + keywords: metadata?.keywords, + }; + if (componentDirs.has("skills")) { + manifest.skills = "./skills/"; + } + if (componentDirs.has("hooks")) { + manifest.hooks = "./hooks/hooks.json"; + } + if (mcpServers) { + manifest.mcpServers = "./.mcp.json"; + } + return stripUndefined(manifest); + }, + manifestPaths: (pluginPath) => [ + path.join(pluginPath, ".codex-plugin", "plugin.json"), + ], + + // Author-supplied `policy`/`category` land here via the per-plugin `entry` + // passthrough (see engine.ts's deepMerge) — pluginpack has no way to infer + // installation/authentication policy on its own, so the base entry stays + // guess-free and validateOutput errors clearly if they're never supplied. + 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, plugins }) => + stripUndefined({ + name: project.config.name, + interface: { + displayName: + project.config.metadata?.displayName ?? project.config.name, + }, + plugins, + }), + marketplacePaths: () => [path.join(".agents", "plugins", "marketplace.json")], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), + + validateManifest: (manifest, pluginName, issues) => { + if (typeof manifest.name !== "string" || !manifest.name) { + error( + issues, + `${pluginName}: plugin.json is missing required field "name".`, + ); + } + }, + validateMarketplaceEntry: validateCodexEntry, + + validateOutput: async (root, issues) => { + 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 = codex.validateMarketplaceEntry( + entry, + index, + root, + issues, + ); + if (!pluginName) { + continue; + } + const pluginDir = resolveLocalPluginDir(root, entry.source); + if (!pluginDir) { + continue; + } + 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}").`, + ); + } + codex.validateManifest(manifest, pluginName, issues); + await validateReferencedManifestPaths( + pluginDir, + pluginName, + manifest, + ["skills", "hooks", "mcpServers"], + issues, + ); + await validateHooksShape( + pluginDir, + pluginName, + "hooks/hooks.json", + issues, + ); + await validateFrontmatter(pluginDir, pluginName, "codex", issues); + } + }, + + installSnippet: { + userConfigurable: true, + build: ({ repository }) => ({ + kind: "command", + snippet: `codex plugin marketplace add ${repository}`, + note: "Installs the marketplace; individual plugins are then installed from Codex's plugin picker.", + }), + citation: { + claim: "codex plugin marketplace add syntax", + documentationUrl: "https://learn.chatgpt.com/codex/developer-commands", + verifiedAt: "2026-07-25", + }, + }, + + citations: [ + { + claim: + 'plugin.json requires only "name"; version/description/author etc. are optional', + documentationUrl: "https://developers.openai.com/codex/plugins/build", + verifiedAt: "2026-07-26", + }, + { + claim: + "marketplace entries require policy.installation, policy.authentication, and category", + documentationUrl: "https://developers.openai.com/codex/plugins/build", + verifiedAt: "2026-07-26", + }, + { + claim: + 'a marketplace entry\'s source is a bare string only for local plugins; url/git-subdir/npm sources are structured objects with an inner "source" discriminator', + documentationUrl: "https://developers.openai.com/codex/plugins/build", + verifiedAt: "2026-07-26", + }, + { + claim: + "marketplace.json's top level is { name, interface, plugins }, with no owner field", + documentationUrl: "https://developers.openai.com/codex/plugins/build", + verifiedAt: "2026-07-26", + }, + ], +}; diff --git a/src/targets/registry.ts b/src/targets/registry.ts index 2e98b93..3d884fe 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -1,5 +1,6 @@ import { antigravity } from "./antigravity.js"; import { claude } from "./claude.js"; +import { codex } from "./codex.js"; import { copilot } from "./copilot.js"; import { cursor } from "./cursor.js"; import type { TargetName } from "../types.js"; @@ -17,4 +18,5 @@ export const targets: Partial> = { antigravity, cursor, claude, + codex, }; diff --git a/src/validate.ts b/src/validate.ts index 45e10bc..64875af 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -241,7 +241,12 @@ export async function validateClaude( } } -/** Validates a built Codex target's output directory. */ +/** + * @deprecated Legacy validator, superseded by `src/targets/codex.ts`'s + * `PluginTargetDefinition.validateOutput` via the registry in + * `src/targets/registry.ts`. Kept only until every target has migrated (see + * `src/adapters.ts`). + */ export async function validateCodex( root: string, issues: ValidationIssue[], diff --git a/tests/core.test.ts b/tests/core.test.ts index d62b121..0e93962 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -502,6 +502,186 @@ export default defineConfig({ ).toBe("3.0.0"); }); + it('only requires "name" in a codex plugin.json (developers.openai.com/codex/plugins/build: ".codex-plugin/plugin.json is the required entry point. The other manifest fields are optional.")', async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "codex-plugins", + version: "1.0.0", + targets: { + codex: { + outDir: "dist/codex", + plugins: { + demo: { + from: ["demo"], + entry: { + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + } + } + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "codex" }); + + const result = await validateOutput("codex", path.join(root, "dist/codex")); + expect(result.ok).toBe(true); + }); + + it('catches a codex marketplace entry missing policy/category at validate time (developers.openai.com/codex/plugins/build: "Always include policy.installation, policy.authentication, and category on each plugin entry.")', async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "codex-plugins", + version: "1.0.0", + targets: { + codex: { + outDir: "dist/codex", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "codex" }); + + const result = await validateOutput("codex", path.join(root, "dist/codex")); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes('"policy.installation"'), + ), + ).toBe(true); + expect( + result.issues.some((issue) => + issue.message.includes('"policy.authentication"'), + ), + ).toBe(true); + expect( + result.issues.some((issue) => issue.message.includes('"category"')), + ).toBe(true); + }); + + it("accepts a structured, non-local codex marketplace source without requiring a local directory", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "codex-plugins", + version: "1.0.0", + targets: { + codex: { + outDir: "dist/codex", + plugins: { demo: { from: ["demo"] } }, + manifest: { + plugins: [ + { + name: "remote-helper", + source: { + source: "git-subdir", + url: "https://github.com/example/codex-plugins.git", + path: "./plugins/remote-helper" + }, + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + } + ] + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "codex" }); + + const result = await validateOutput("codex", path.join(root, "dist/codex")); + expect(result.ok).toBe(true); + }); + + it("points a codex plugin.json's hooks field at the bundled hooks file when hooks/ is present", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "codex-plugins", + version: "1.0.0", + targets: { + codex: { + outDir: "dist/codex", + plugins: { + demo: { + from: ["demo"], + entry: { + policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, + category: "Developer Tools" + } + } + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + hooks: { + "hooks.json": `${JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo hi" }] }, + ], + }, + }, + null, + 2, + )}\n`, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "codex" }); + + const manifest = JSON.parse( + await readFile( + path.join(root, "dist/codex/plugins/demo/.codex-plugin/plugin.json"), + "utf8", + ), + ) as Record; + expect(manifest.hooks).toBe("./hooks/hooks.json"); + + const result = await validateOutput("codex", path.join(root, "dist/codex")); + expect(result.ok).toBe(true); + }); + it("uses target-specific file overrides", async () => { const project = await fixture(); const root = project.baseDir;