From 4f74ef5627e3f5f63891b6fb97061d603e53f275 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 13:42:48 -0700 Subject: [PATCH 1/4] refactor: migrate cursor to the target registry (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-checking the originally planned Cursor "fixes" against the vendored plugin.schema.json / marketplace.schema.json (this repo's own conformance oracle, already passing against the current shape) showed they were wrong: - displayName/category/tags ARE valid plugin.json fields per the schema (additionalProperties: false, and they're explicitly listed) — not marketplace-entry-only fields as previously assumed. - Marketplace `owner` is genuinely optional (required: ["name", "plugins"] does not include it) — not required as previously assumed. Moving category/tags to the marketplace entry would have been actively wrong: entries only allow name/source/description (additionalProperties: false). None of that is changed here. What this commit actually does: - Migrates cursor onto PluginTargetDefinition, preserving every existing field and behavior (verified by the vendored-schema conformance test staying green). - Fixes one genuine, low-risk issue: the manifest builder's own hardcoded default-components list could diverge from this target's actual defaultComponents (components.ts). Replaced both with one list of schema-valid pointer fields, checked directly against the plugin's real resolved componentDirs — eliminates the divergence risk with no observable behavior change (confirmed via a new test exercising the one case that could have differed: an explicit `components: [...]` override). - Ports update-check's hook-injection into the new shared engine (src/targets/engine.ts), which previously only existed in the legacy emitCursor/emitClaude path — migrating cursor without this would have silently dropped update-check support. Co-Authored-By: Claude Fable 5 --- src/targets/cursor.ts | 214 ++++++++++++++++++++++++++++++++++++++++ src/targets/engine.ts | 62 +++++++++++- src/targets/registry.ts | 2 + src/targets/types.ts | 4 +- tests/core.test.ts | 91 +++++++++++++++++ 5 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 src/targets/cursor.ts diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts new file mode 100644 index 0000000..786bb49 --- /dev/null +++ b/src/targets/cursor.ts @@ -0,0 +1,214 @@ +import path from "node:path"; +import { stripUndefined, titleCase } from "./shared.js"; +import { + error, + readJson, + validateBareStringSourceEntry, + validateFrontmatter, + validateHooksShape, + validateMarketplaceBasics, + validateReferencedManifestPaths, +} from "./validation-shared.js"; +import type { PluginTargetDefinition } from "./types.js"; + +const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +/** + * The manifest fields that point at a component, per the vendored Cursor + * plugin schema (`tests/fixtures/cursor/plugin.schema.json`) — the single + * source of truth for which componentDirs get a manifest pointer, replacing + * two lists (this target's `defaultComponents` and a separate hardcoded list + * in the old manifest builder) that could independently drift apart. + */ +const POINTABLE_COMPONENTS = ["rules", "agents", "skills", "commands", "hooks"]; + +/** Cursor agent-plugin target. Verified against the vendored schemas in `tests/fixtures/cursor/`. */ +export const cursor: PluginTargetDefinition = { + name: "cursor", + + defaultComponents: [ + "skills", + "agents", + "rules", + "hooks", + "scripts", + "assets", + ], + + resolvePluginPath: (pluginName, pluginConfig) => + pluginConfig.path ?? pluginName, + + buildPluginManifest: ({ + metadata, + version, + pluginName, + pluginConfig, + componentDirs, + mcpServers, + }) => { + const manifest: Record = { + name: pluginName, + displayName: + pluginConfig.displayName ?? + metadata?.displayName ?? + titleCase(pluginName), + version, + description: pluginConfig.description ?? metadata?.description, + author: metadata?.author, + homepage: metadata?.homepage, + repository: metadata?.repository, + license: metadata?.license, + logo: metadata?.logo, + keywords: metadata?.keywords, + category: metadata?.category, + tags: metadata?.tags, + }; + for (const component of POINTABLE_COMPONENTS) { + if (componentDirs.has(component)) { + manifest[component] = `./${component}/`; + } + } + if (mcpServers) { + manifest.mcpServers = "./.mcp.json"; + } + return stripUndefined(manifest); + }, + manifestPaths: (pluginPath, targetConfig) => [ + path.join( + pluginPath, + targetConfig.marketplaceDir ?? ".cursor-plugin", + "plugin.json", + ), + ], + + buildMarketplaceEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => + stripUndefined({ + name: pluginName, + source: pluginPath, + description: + pluginConfig.description ?? + (manifest?.description as string | undefined), + }), + + buildMarketplaceManifest: ({ project, version, plugins }) => + stripUndefined({ + name: project.config.name, + owner: project.config.metadata?.owner ?? project.config.metadata?.author, + metadata: { + description: project.config.metadata?.description, + keywords: project.config.metadata?.keywords, + }, + plugins, + version, + }), + marketplacePaths: (targetConfig) => [ + path.join( + targetConfig.marketplaceDir ?? ".cursor-plugin", + "marketplace.json", + ), + ], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), + + validateManifest: () => { + // Structural validation is delegated to the vendored JSON Schema in + // conformance tests (the stronger oracle); nothing target-specific to + // check here beyond what validateOutput already does below. + }, + validateMarketplaceEntry: (entry, index, root, issues) => + validateBareStringSourceEntry( + entry, + index, + root, + issues, + pluginNamePattern, + ), + + validateOutput: async (root, issues) => { + const marketplacePath = path.join( + root, + ".cursor-plugin", + "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 = cursor.validateMarketplaceEntry( + entry, + index, + root, + issues, + ); + if (!pluginName) { + continue; + } + const pluginDir = path.join(root, entry.source); + const manifest = await readJson( + path.join(pluginDir, ".cursor-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}").`, + ); + } + await validateReferencedManifestPaths( + pluginDir, + pluginName, + manifest, + ["logo", ...POINTABLE_COMPONENTS, "mcpServers"], + issues, + ); + await validateHooksShape( + pluginDir, + pluginName, + "hooks/hooks.json", + issues, + ); + await validateFrontmatter(pluginDir, pluginName, "cursor", issues); + } + }, + + installSnippet: { + userConfigurable: true, + build: ({ repository }) => ({ + kind: "url", + snippet: repository, + note: 'Paste into Cursor\'s Dashboard → Plugins → Team Marketplaces → "Import from Repo."', + }), + citation: { + claim: + "no CLI marketplace-add command exists; Import from Repo is GUI-only", + documentationUrl: "https://cursor.com/docs/plugins", + verifiedAt: "2026-07-25", + }, + }, + + citations: [ + { + claim: "plugin.json and marketplace.json field shapes", + documentationUrl: "https://cursor.com/docs/reference/plugins.md", + verifiedAt: "2026-07-26", + }, + ], +}; diff --git a/src/targets/engine.ts b/src/targets/engine.ts index c2c8f22..76cf3cd 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -2,6 +2,8 @@ import path from "node:path"; import { collectPluginFiles, resolveMcpServers } from "../render.js"; import { json, toPosix } from "../fs.js"; import { deepMerge, stripUndefined } from "./shared.js"; +import { applyUpdateCheck, pluginAllowsUpdateCheck } from "../update-check.js"; +import type { UpdateCheckFormat } from "../update-check.js"; import type { Artifact, EmittedPluginConfig, @@ -26,6 +28,42 @@ function resolveComponents( return new Set(pluginConfig.components ?? definition.defaultComponents); } +/** + * Resolves a target's `updateCheck` config into the options `applyUpdateCheck` + * needs, failing fast when no repository URL can be determined. Only + * claude/cursor ever have `updateCheck` set (enforced at config-schema + * validation), so `target` narrows safely once this returns non-undefined. + */ +function resolveUpdateCheck( + project: ResolvedProject, + target: TargetName, + targetConfig: TargetConfig, + version: string, +): + | { + format: UpdateCheckFormat; + repository: string; + version: (pluginConfig: EmittedPluginConfig) => string; + } + | undefined { + if (!targetConfig.updateCheck) { + return undefined; + } + const repository = + targetConfig.updateCheck.repository ?? project.config.metadata?.repository; + if (!repository) { + throw new Error( + `Target "${target}" updateCheck requires a repository ` + + `(set targets.${target}.updateCheck.repository or metadata.repository).`, + ); + } + return { + format: target as UpdateCheckFormat, + repository, + version: (pluginConfig) => pluginConfig.version ?? version, + }; +} + /** * Emits one target's output using its `PluginTargetDefinition` — the shared * engine every migrated target runs through, in place of a bespoke @@ -41,6 +79,12 @@ export async function emitFromDefinition( const version = targetConfig.version ?? project.config.version; const files = new Map(); const entries: Record[] = []; + const updateCheck = resolveUpdateCheck( + project, + target, + targetConfig, + version, + ); for (const [pluginName, pluginConfig] of Object.entries( targetConfig.plugins, @@ -56,6 +100,17 @@ export async function emitFromDefinition( pluginConfig.from, resolveComponents(definition, pluginConfig), ); + // Applied before componentDirs is derived, so an injected hooks/ dir + // registers as a present component (e.g. for a manifest pointer) even if + // this plugin's own `components` override excludes hooks. + if (updateCheck && pluginAllowsUpdateCheck(pluginConfig)) { + applyUpdateCheck(pluginFiles, target, { + format: updateCheck.format, + pluginName, + version: updateCheck.version(pluginConfig), + repository: updateCheck.repository, + }); + } const componentDirs = new Set( [...pluginFiles.keys()].map((file) => file.split("/")[0]), ); @@ -94,7 +149,10 @@ export async function emitFromDefinition( const manifestContent = json( stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})), ); - for (const manifestPath of definition.manifestPaths(pluginPath)) { + for (const manifestPath of definition.manifestPaths( + pluginPath, + targetConfig, + )) { files.set(toPosix(manifestPath), manifestContent); } @@ -127,7 +185,7 @@ export async function emitFromDefinition( ), ); const marketplaceContent = json(marketplace); - for (const marketplacePath of definition.marketplacePaths()) { + for (const marketplacePath of definition.marketplacePaths(targetConfig)) { files.set(toPosix(marketplacePath), marketplaceContent); } diff --git a/src/targets/registry.ts b/src/targets/registry.ts index fdde0c2..e8c9a61 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -1,5 +1,6 @@ import { antigravity } from "./antigravity.js"; import { copilot } from "./copilot.js"; +import { cursor } from "./cursor.js"; import type { TargetName } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; @@ -13,4 +14,5 @@ import type { PluginTargetDefinition } from "./types.js"; export const targets: Partial> = { copilot, antigravity, + cursor, }; diff --git a/src/targets/types.ts b/src/targets/types.ts index d838561..2a08152 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -98,7 +98,7 @@ export type PluginTargetDefinition = { buildPluginManifest: (ctx: ManifestBuildContext) => Record; /** Output-relative paths the plugin manifest is written to (may be more than one). */ - manifestPaths: (pluginPath: string) => string[]; + manifestPaths: (pluginPath: string, targetConfig: TargetConfig) => string[]; /** Return `undefined` for a target with no marketplace-entry concept. */ buildMarketplaceEntry: ( @@ -108,7 +108,7 @@ export type PluginTargetDefinition = { ctx: MarketplaceManifestContext, ) => Record; /** Output-relative paths the marketplace manifest is written to (may be more than one). */ - marketplacePaths: () => string[]; + marketplacePaths: (targetConfig: TargetConfig) => string[]; /** Return `undefined` for a target with no bundled-MCP-config file convention. */ mcpConfigPath: (pluginPath: string) => string | undefined; diff --git a/tests/core.test.ts b/tests/core.test.ts index c42ee4c..c76b6b8 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -366,6 +366,97 @@ export default defineConfig({ ).resolves.toMatchObject({ ok: true }); }); + it("keeps displayName/category/tags in cursor's plugin.json (valid per the vendored plugin.schema.json, not marketplace-entry-only fields)", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "cursor-manifest-fields", + version: "1.0.0", + metadata: { + description: "Cursor", + author: { name: "X" }, + license: "MIT", + category: "Developer Tools", + tags: ["demo", "example"] + }, + targets: { + cursor: { + outDir: "dist/cursor", + plugins: { demo: { from: ["demo"], displayName: "Demo Plugin" } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "cursor" }); + + const manifest = JSON.parse( + await readFile( + path.join(root, "dist/cursor/demo/.cursor-plugin/plugin.json"), + "utf8", + ), + ) as Record; + expect(manifest).toMatchObject({ + displayName: "Demo Plugin", + category: "Developer Tools", + tags: ["demo", "example"], + }); + + await expect( + validateOutput("cursor", path.join(root, "dist/cursor")), + ).resolves.toMatchObject({ ok: true }); + }); + + it("points cursor's manifest at commands/ when a plugin explicitly opts into that component", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "cursor-commands-plugins", + version: "1.0.0", + metadata: { description: "Cursor", author: { name: "X" }, license: "MIT" }, + targets: { + cursor: { + outDir: "dist/cursor", + plugins: { + demo: { from: ["demo"], components: ["skills", "commands"] } + } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + commands: { "review.md": command("review", "Review command.") }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "cursor" }); + + const manifest = JSON.parse( + await readFile( + path.join(root, "dist/cursor/demo/.cursor-plugin/plugin.json"), + "utf8", + ), + ) as Record; + expect(manifest.commands).toBe("./commands/"); + + await expect( + validateOutput("cursor", path.join(root, "dist/cursor")), + ).resolves.toMatchObject({ ok: true }); + }); + it("uses target-specific file overrides", async () => { const project = await fixture(); const root = project.baseDir; From 6b9be449e5ac7a1e50ccbd6065f299980a87e3a1 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 13:47:36 -0700 Subject: [PATCH 2/4] fix: apply the per-plugin version override in cursor's manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildPluginManifest used the target-level `version` param directly instead of `pluginConfig.version ?? version`, silently dropping a per-plugin version override — a real regression from the pre-migration behavior, caught by porting the equivalent test from the claude migration (no test previously covered this for cursor specifically). Co-Authored-By: Claude Fable 5 --- src/targets/cursor.ts | 2 +- tests/core.test.ts | 45 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts index 786bb49..b9493d9 100644 --- a/src/targets/cursor.ts +++ b/src/targets/cursor.ts @@ -52,7 +52,7 @@ export const cursor: PluginTargetDefinition = { pluginConfig.displayName ?? metadata?.displayName ?? titleCase(pluginName), - version, + version: pluginConfig.version ?? version, description: pluginConfig.description ?? metadata?.description, author: metadata?.author, homepage: metadata?.homepage, diff --git a/tests/core.test.ts b/tests/core.test.ts index c76b6b8..d62b121 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -457,6 +457,51 @@ export default defineConfig({ ).resolves.toMatchObject({ ok: true }); }); + it("applies per-target and per-plugin version overrides for cursor", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "cursor-ver-plugins", + version: "1.0.0", + metadata: { description: "V", author: { name: "V" }, license: "MIT" }, + targets: { + cursor: { + outDir: "dist/cursor", + version: "2.0.0", + plugins: { + a: { from: ["a"] }, + b: { from: ["b"], version: "3.0.0" } + } + } + } +}); +`, + plugins: { + a: { skills: { sa: { "SKILL.md": skill("sa", "SA.") } } }, + b: { skills: { sb: { "SKILL.md": skill("sb", "SB.") } } }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "cursor" }); + + const read = async (p: string) => + JSON.parse(await readFile(path.join(root, p), "utf8")) as Record< + string, + unknown + >; + expect( + (await read("dist/cursor/.cursor-plugin/marketplace.json")).version, + ).toBe("2.0.0"); + expect( + (await read("dist/cursor/a/.cursor-plugin/plugin.json")).version, + ).toBe("2.0.0"); + expect( + (await read("dist/cursor/b/.cursor-plugin/plugin.json")).version, + ).toBe("3.0.0"); + }); + it("uses target-specific file overrides", async () => { const project = await fixture(); const root = project.baseDir; From d0677f1862e7d92893b4855a5c47ee30c372af2d Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 13:54:19 -0700 Subject: [PATCH 3/4] refactor: migrate claude to the target registry (no behavior change) Ports emitClaude/validateClaude into src/targets/claude.ts as a PluginTargetDefinition, verified directly against `claude plugin validate --strict`: a minimal manifest with only `name` fails that check, confirming the existing version/description/author.name requirements already match the real CLI rather than over-constraining it. Applies the per-plugin version override in buildPluginManifest (pluginConfig.version ?? version) up front, matching the identical fix just made to cursor's copy of this pattern. --- src/targets.ts | 6 +- src/targets/claude.ts | 183 ++++++++++++++++++++++++++++++++++++++++ src/targets/registry.ts | 2 + src/validate.ts | 7 +- 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/targets/claude.ts diff --git a/src/targets.ts b/src/targets.ts index e0e727b..9962e33 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -290,7 +290,11 @@ export async function emitCursor( return artifact(target, outDir, files); } -/** Emits the Claude target's plugins and marketplace manifest. */ +/** + * @deprecated Legacy emitter, superseded by `src/targets/claude.ts` via the + * registry in `src/targets/registry.ts`. Kept only until every target has + * migrated (see `src/adapters.ts`). + */ export async function emitClaude( project: ResolvedProject, target: TargetName, diff --git a/src/targets/claude.ts b/src/targets/claude.ts new file mode 100644 index 0000000..39dd207 --- /dev/null +++ b/src/targets/claude.ts @@ -0,0 +1,183 @@ +import path from "node:path"; +import { stripUndefined } from "./shared.js"; +import { + error, + readJson, + validateBareStringSourceEntry, + validateFrontmatter, + validateHooksShape, + validateMarketplaceBasics, +} from "./validation-shared.js"; +import type { PluginTargetDefinition } from "./types.js"; + +const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +/** + * Claude Code agent-plugin target. `validateManifest`'s required fields + * (`version`, `description`, `author.name`) were verified directly against + * `claude plugin validate --strict` — a minimal manifest with only `name` + * fails that check, so pluginpack's existing stricter validation matches the + * real CLI rather than over-constraining it. + */ +export const claude: PluginTargetDefinition = { + name: "claude", + + defaultComponents: ["skills", "agents", "hooks", "scripts", "assets"], + + resolvePluginPath: (pluginName, pluginConfig, targetConfig) => + pluginConfig.path ?? + path.join(targetConfig.pluginRoot ?? "plugins", pluginName), + + buildPluginManifest: ({ metadata, version, pluginName, pluginConfig }) => + stripUndefined({ + 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, + }), + manifestPaths: (pluginPath, targetConfig) => [ + path.join( + pluginPath, + targetConfig.marketplaceDir ?? ".claude-plugin", + "plugin.json", + ), + ], + + buildMarketplaceEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => + stripUndefined({ + name: pluginName, + source: `./${pluginPath}`, + description: + pluginConfig.description ?? + (manifest?.description as string | undefined), + }), + + buildMarketplaceManifest: ({ project, version, plugins }) => + stripUndefined({ + $schema: "https://anthropic.com/claude-code/marketplace.schema.json", + name: project.config.name, + version, + description: project.config.metadata?.description, + owner: project.config.metadata?.owner ?? project.config.metadata?.author, + plugins, + }), + marketplacePaths: (targetConfig) => [ + path.join( + targetConfig.marketplaceDir ?? ".claude-plugin", + "marketplace.json", + ), + ], + + mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), + + validateManifest: (manifest, pluginName, issues) => { + for (const field of ["name", "version", "description"]) { + if (typeof manifest[field] !== "string" || !manifest[field]) { + error( + issues, + `${pluginName}: plugin.json is missing required field "${field}".`, + ); + } + } + const author = manifest.author as Record | undefined; + if (!author || typeof author.name !== "string" || !author.name) { + error(issues, `${pluginName}: plugin.json is missing "author.name".`); + } + }, + validateMarketplaceEntry: (entry, index, root, issues) => + validateBareStringSourceEntry( + entry, + index, + root, + issues, + pluginNamePattern, + ), + + validateOutput: async (root, issues) => { + const marketplacePath = path.join( + root, + ".claude-plugin", + "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 = claude.validateMarketplaceEntry( + entry, + index, + root, + issues, + ); + if (!pluginName) { + continue; + } + const pluginDir = path.join(root, entry.source); + const manifest = await readJson( + path.join(pluginDir, ".claude-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}").`, + ); + } + claude.validateManifest(manifest, pluginName, issues); + await validateFrontmatter(pluginDir, pluginName, "claude", issues); + await validateHooksShape( + pluginDir, + pluginName, + "hooks/hooks.json", + issues, + ); + } + }, + + installSnippet: { + userConfigurable: true, + build: ({ repository, pluginName, marketplaceName }) => ({ + kind: "command", + snippet: `/plugin marketplace add ${repository}\n/plugin install ${pluginName}@${marketplaceName}`, + note: `Once the marketplace is added, "claude plugin install ${pluginName}@${marketplaceName}" also works as a standalone shell command — but marketplace add itself is slash-only, with no shell equivalent.`, + }), + citation: { + claim: + "/plugin marketplace add is slash-only; claude plugin install works as a standalone shell command once a marketplace is already added", + documentationUrl: + "https://code.claude.com/docs/en/plugins-reference#cli-commands-reference", + verifiedAt: "2026-07-25", + }, + }, + + citations: [ + { + claim: + "claude plugin validate --strict treats missing version/description/author.name as errors", + documentationUrl: "https://code.claude.com/docs/en/plugins-reference", + verifiedAt: "2026-07-26", + }, + ], +}; diff --git a/src/targets/registry.ts b/src/targets/registry.ts index e8c9a61..2e98b93 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -1,4 +1,5 @@ import { antigravity } from "./antigravity.js"; +import { claude } from "./claude.js"; import { copilot } from "./copilot.js"; import { cursor } from "./cursor.js"; import type { TargetName } from "../types.js"; @@ -15,4 +16,5 @@ export const targets: Partial> = { copilot, antigravity, cursor, + claude, }; diff --git a/src/validate.ts b/src/validate.ts index 85498f3..45e10bc 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -176,7 +176,12 @@ export async function validateCursor( } } -/** Validates a built Claude target's output directory. */ +/** + * @deprecated Legacy validator, superseded by `src/targets/claude.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 validateClaude( root: string, issues: ValidationIssue[], From afbe182275d696465959ec0cd8e241310abf0f54 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 14:02:31 -0700 Subject: [PATCH 4/4] fix: correct Codex plugin output for the target registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports emitCodex/validateCodex into src/targets/codex.ts as a PluginTargetDefinition, correcting the plugin format's real shape — re-verified directly against developers.openai.com/codex/plugins/build (fetched twice, independently, for consistency) since Codex has no CLI validator or vendored schema to check against: - plugin.json requires only "name"; version/description/author etc. are optional. The previous validator wrongly required version and description. - Every marketplace entry needs policy.installation, policy.authentication, and category — previously unvalidated, so an incomplete entry shipped silently. pluginpack can't infer these, so the base entry stays guess-free and validateOutput now errors clearly when an author never supplies them via the per-plugin `entry` passthrough (already how the existing conformance fixture supplies them). - A marketplace entry's source is a bare string only for local plugins (the only shape pluginpack itself ever emits); url/git-subdir/npm sources are structured objects with an inner "source" discriminator. validateMarketplaceEntry now accepts either shape instead of the previously shared, string-only validator. - plugin.json now declares a `hooks` pointer when hooks/ is present, matching skills/mcpServers (previously only skills/mcpServers were declared, so hooks were emitted but never referenced). Updates CONFORMANCE.md's Codex section, which had pinned a stale, bare-string-only shape from an earlier doc retrieval. --- CONFORMANCE.md | 28 ++-- src/targets.ts | 6 +- src/targets/codex.ts | 308 ++++++++++++++++++++++++++++++++++++++++ src/targets/registry.ts | 2 + src/validate.ts | 7 +- tests/core.test.ts | 180 +++++++++++++++++++++++ 6 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 src/targets/codex.ts 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;