From 4f74ef5627e3f5f63891b6fb97061d603e53f275 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 13:42:48 -0700 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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; From 5c63d1ed4d36a81b80b886272abdbed25a428766 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 14:06:30 -0700 Subject: [PATCH 5/7] fix: restore deeper hooks.json validation dropped during target migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every migrated target's validateOutput calls validateHooksShape (added in the registry scaffold), which only checked that hooks.json has a "hooks" object — narrower than the legacy per-target validateHooks it replaced, which also required each event's entries to be an array, rejected empty "command" strings, and errored if a command referenced the generated update-check script without that script actually being present. None of that depth had a regression test, so the narrowing was silent. Ports the full check into validateHooksShape once, so every target that already calls it (all 5, post-migration) regains it for free instead of needing the fix repeated per target file. --- src/targets/validation-shared.ts | 61 ++++++++++++++++++++++++++++++-- tests/core.test.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index a07f1ed..75b6754 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.ts @@ -8,6 +8,7 @@ import { toPosix, walkFiles, } from "../fs.js"; +import { UPDATE_CHECK_SCRIPT_PATH } from "../update-check.js"; import type { TargetName, ValidationIssue } from "../types.js"; export const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; @@ -120,7 +121,10 @@ export function validateMarketplaceBasics( /** * Validates a hooks file's shape at `hooksRelativePath` — parameterized by * path rather than a hardcoded location, since a target may relocate hooks - * to somewhere other than `hooks/hooks.json` (e.g. a root-level file). + * to somewhere other than `hooks/hooks.json` (e.g. a root-level file). Also + * checks that any command referencing the generated update-check script + * (`UPDATE_CHECK_SCRIPT_PATH`) actually ships it, since that hook is + * injected separately from the plugin's own authored hooks. */ export async function validateHooksShape( pluginDir: string, @@ -133,14 +137,67 @@ export async function validateHooksShape( return; } const hooks = await readJson(hooksFile, `${pluginName} hooks`, issues); - if (hooks && (!hooks.hooks || typeof hooks.hooks !== "object")) { + if (!hooks) { + return; + } + if ( + !hooks.hooks || + typeof hooks.hooks !== "object" || + Array.isArray(hooks.hooks) + ) { error( issues, `${pluginName}: ${hooksRelativePath} must have a "hooks" object.`, ); + return; + } + for (const [event, entries] of Object.entries( + hooks.hooks as Record, + )) { + if (!Array.isArray(entries)) { + error( + issues, + `${pluginName}: ${hooksRelativePath} "hooks.${event}" must be an array.`, + ); + } + } + for (const command of extractCommandStrings(hooks.hooks)) { + if (!command) { + error( + issues, + `${pluginName}: ${hooksRelativePath} has an empty "command" string.`, + ); + continue; + } + if ( + command.includes(UPDATE_CHECK_SCRIPT_PATH) && + !(await exists(path.join(pluginDir, UPDATE_CHECK_SCRIPT_PATH))) + ) { + error( + issues, + `${pluginName}: ${hooksRelativePath} references ${UPDATE_CHECK_SCRIPT_PATH} but the script is missing.`, + ); + } } } +/** + * All string values under a "command" key, at any depth (Claude nests + * command entries under matcher groups; Cursor keeps them at the event + * level). + */ +function extractCommandStrings(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap(extractCommandStrings); + } + if (value && typeof value === "object") { + const object = value as Record; + const own = typeof object.command === "string" ? [object.command] : []; + return [...own, ...Object.values(object).flatMap(extractCommandStrings)]; + } + return []; +} + /** Validates that manifest fields referencing paths point at files that exist. */ export function validateReferencedManifestPaths( pluginDir: string, diff --git a/tests/core.test.ts b/tests/core.test.ts index 0e93962..c892e5a 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1468,6 +1468,59 @@ export default defineConfig({ ).resolves.toMatchObject({ ok: true }); }); + it("catches a hooks.json command that references the generated update-check script once that script is removed", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "uc-missing-script", + version: "1.0.0", + metadata: { + description: "UC", + author: { name: "UC" }, + license: "MIT", + repository: "https://github.com/example/uc-plugins" + }, + targets: { + cursor: { + outDir: "dist/cursor", + updateCheck: {}, + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "cursor" }); + await expect( + validateOutput("cursor", path.join(root, "dist/cursor")), + ).resolves.toMatchObject({ ok: true }); + + await rm( + path.join(root, "dist/cursor/demo/scripts/pluginpack-update-check.sh"), + ); + + const result = await validateOutput( + "cursor", + path.join(root, "dist/cursor"), + ); + expect(result.ok).toBe(false); + expect( + result.issues.some((issue) => + issue.message.includes( + "references scripts/pluginpack-update-check.sh but the script is missing", + ), + ), + ).toBe(true); + }); + it("merges the update-check hook into a source-authored hooks.json", async () => { const existingHooks = { description: "Source hooks", From ee4ab2b1fb10fd2ee0924bf342d3e700337c11a4 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 14:10:35 -0700 Subject: [PATCH 6/7] refactor: delete legacy per-target emitters/validators now that all targets are migrated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 5 targets (copilot, antigravity, cursor, claude, codex) now have a PluginTargetDefinition in src/targets/registry.ts, so the legacy fallback path adapters.ts existed for is dead: - Deletes src/targets.ts and src/validate.ts entirely (their only consumer was adapters.ts's legacyAdapters map). - Moves withRootFiles into src/targets/engine.ts, next to the artifact helper it depends on. - Tightens the registry's type from Partial> to Record now that every target has an entry — a new TargetName won't build until it has a registry entry, the same exhaustiveness guarantee the deleted legacyAdapters map used to provide. - Simplifies adapters.ts to a thin emitTarget/validateOutput wrapper around the registry + engine, dropping the now-pointless TargetAdapter/adapters indirection that existed only to switch between legacy and registry per target. - Removes targetDefaultComponents/resolveTargetComponents from components.ts (superseded by each target's own defaultComponents). - Updates CLAUDE.md's Architecture/Targets sections and a couple of stale doc-comment references to match. --- CLAUDE.md | 33 +- README.md | 10 +- src/adapters.ts | 88 +---- src/components.ts | 19 -- src/targets.ts | 707 ---------------------------------------- src/targets/engine.ts | 49 ++- src/targets/registry.ts | 11 +- src/update-check.ts | 9 +- src/validate.ts | 659 ------------------------------------- 9 files changed, 88 insertions(+), 1497 deletions(-) delete mode 100644 src/targets.ts delete mode 100644 src/validate.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2d23daa..e8e7879 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,25 +37,35 @@ diff, prune, and validate all derive from it. generated output is never misread as source), and the root-skills plugin. - `src/render.ts` — `collectPluginFiles` (component dirs + static files, with `targets//` override resolution) and `resolveMcpServers`. -- `src/targets.ts` — `emitTarget` + per-target emitters. `cursor`/`claude`/ - `antigravity` share the `emitPlugins` engine via callbacks; `emitCopilot` is - bespoke (no per-plugin manifest, dual marketplace). Manifest builders live here - too. +- `src/targets/registry.ts` — `targets: Record`, + one file per target (`src/targets/.ts`). Everything that varies by + target — default components, manifest/marketplace builders, output paths, + validation, install snippet — lives on that target's own + `PluginTargetDefinition` (`src/targets/types.ts`). +- `src/targets/engine.ts` — `emitFromDefinition`/`validateFromDefinition`: the + one emit/validate engine every target runs through, driven by its + `PluginTargetDefinition`. Also `withRootFiles` (injects per-target + repo-root files into the artifact). +- `src/targets/validation-shared.ts` — validators shared across targets whose + shape actually matches (bare-string marketplace `source`, hooks.json shape, + frontmatter conventions); a target with a genuinely different shape (e.g. + Codex's structured `source`) writes its own instead of forcing a fit. +- `src/adapters.ts` — `emitTarget`/`validateOutput`/`targetNames`, thin + wrappers around the registry + engine. - `src/build.ts` — `build()`: emit all targets → `assertNoCrossTargetCollisions` → write/prune/manifest. Holds the delete guard. - `src/managed.ts` — the managed-file manifest (`.pluginpack/.json`), `prune`/`clean`, the delete guard, and path-safety checks. - `src/diff.ts` — `diffTarget`: build to a temp dir and compare against an existing target repo (the CI staleness gate). -- `src/validate.ts` — per-target output validation. ## Targets -`cursor`, `claude`, `antigravity`, `copilot`. Adding a target currently touches ~5 -places: the `TargetName` union (`types.ts`), the `targets` array + `parseTarget` -(`cli.ts`), `allTargets` (`build.ts`), the `emitters` map + a new `emitFoo` -(`targets.ts`), and a branch + `validateFoo` (`validate.ts`). If you are adding a -target, consider introducing a single target registry first to localize this. +`copilot`, `antigravity`, `cursor`, `claude`, `codex`. 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 +separate union to extend, but everything else (CLI `--target` choices, `build()`'s +target set, emit/validate dispatch) derives from the registry automatically. ## Conformance @@ -80,6 +90,7 @@ schemas at runtime — vendor a pinned copy with recorded provenance. ## Conventions -- Strict TypeScript, no `any` (the one exception is `readJson` in `validate.ts`). +- Strict TypeScript, no `any` (the one exception is `readJson` in + `src/targets/validation-shared.ts`). - Prettier + eslint enforced by the gate. - Conventional commits. Keep the README CLI reference regenerated. diff --git a/README.md b/README.md index b0fe174..576e6dd 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ Exit codes: Compile configured source plugins into target-native plugin payloads. ```bash -pluginpack build [--target cursor|claude|antigravity|copilot|codex] [--out-dir ] [--dry-run] +pluginpack build [--target copilot|antigravity|cursor|claude|codex] [--out-dir ] [--dry-run] ``` Options: @@ -506,7 +506,7 @@ Exit codes: Validate an existing target output directory for native manifest, path, and frontmatter requirements. ```bash -pluginpack validate --target cursor|claude|antigravity|copilot|codex [--dir ] +pluginpack validate --target copilot|antigravity|cursor|claude|codex [--dir ] ``` Options: @@ -528,7 +528,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|codex --against +pluginpack diff --target copilot|antigravity|cursor|claude|codex --against ``` Options: @@ -550,7 +550,7 @@ Exit codes: Remove stale managed files that are no longer emitted by the current config. ```bash -pluginpack prune [--target cursor|claude|antigravity|copilot|codex] [--dry-run] +pluginpack prune [--target copilot|antigravity|cursor|claude|codex] [--dry-run] ``` Options: @@ -574,7 +574,7 @@ Exit codes: Remove all managed files for configured target outputs. ```bash -pluginpack clean [--target cursor|claude|antigravity|copilot|codex] [--dry-run] +pluginpack clean [--target copilot|antigravity|cursor|claude|codex] [--dry-run] ``` Options: diff --git a/src/adapters.ts b/src/adapters.ts index 2015a9d..52c3a7c 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -1,97 +1,20 @@ import path from "node:path"; -import { - emitAntigravity, - emitClaude, - emitCodex, - emitCopilot, - emitCursor, - withRootFiles, -} from "./targets.js"; -import { - validateAntigravity, - validateClaude, - validateCodex, - validateCopilot, - validateCursor, -} from "./validate.js"; import { emitFromDefinition, validateFromDefinition, + withRootFiles, } from "./targets/engine.js"; import { targets as registry } from "./targets/registry.js"; import type { Artifact, ResolvedProject, - TargetConfig, TargetName, ValidationIssue, ValidationResult, } from "./types.js"; -type TargetEmitter = ( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -) => Promise; - -type TargetValidator = ( - root: string, - issues: ValidationIssue[], -) => Promise; - -/** The emit and validate functions for one target. */ -export type TargetAdapter = { - emit: TargetEmitter; - validate: TargetValidator; -}; - -// Legacy per-target functions, used only for targets not yet migrated to -// src/targets/registry.ts. Delete this map (and ../targets.ts/../validate.ts's -// per-target functions) once every TargetName has a registry entry. -const legacyAdapters: Record = { - cursor: { emit: emitCursor, validate: validateCursor }, - claude: { emit: emitClaude, validate: validateClaude }, - antigravity: { emit: emitAntigravity, validate: validateAntigravity }, - copilot: { emit: emitCopilot, validate: validateCopilot }, - codex: { emit: emitCodex, validate: validateCodex }, -}; - -/** - * The one place a target is wired. `Record` is exhaustive at - * compile time — a new `TargetName` won't build until it has an entry here — - * so emit dispatch, validate dispatch, the CLI `--target` choices, and the - * set `build()` iterates all derive from this single source instead of - * parallel maps. - * - * During migration, a target resolves to the new registry - * (`src/targets/*.ts`) if it has an entry there, otherwise falls back to the - * legacy function — this map's shape stays the same either way, so callers - * never notice. - */ -export const adapters: Record = Object.fromEntries( - (Object.keys(legacyAdapters) as TargetName[]).map((target) => { - const definition = registry[target]; - const adapter: TargetAdapter = definition - ? { - emit: (project, targetName, targetConfig, outDir) => - emitFromDefinition( - project, - targetName, - targetConfig, - outDir, - definition, - ), - validate: (root, issues) => - validateFromDefinition(root, issues, definition), - } - : legacyAdapters[target]; - return [target, adapter]; - }), -) as Record; - -/** Every target name with an adapter — the exhaustive list `build()` and the CLI derive from. */ -export const targetNames = Object.keys(adapters) as TargetName[]; +/** Every target name with a registry entry — the exhaustive list `build()` and the CLI derive from. */ +export const targetNames = Object.keys(registry) as TargetName[]; /** Emits one target's output and applies its `rootFiles`, resolving `outDir` from config if omitted. */ export async function emitTarget( @@ -107,11 +30,12 @@ export async function emitTarget( project.rootDir, outDir ?? targetConfig.outDir, ); - const result = await adapters[target].emit( + const result = await emitFromDefinition( project, target, targetConfig, resolvedOutDir, + registry[target], ); return withRootFiles(project, targetConfig, result); } @@ -123,7 +47,7 @@ export async function validateOutput( ): Promise { const root = path.resolve(dir); const issues: ValidationIssue[] = []; - await adapters[target].validate(root, issues); + await validateFromDefinition(root, issues, registry[target]); return { ok: issues.every((issue) => issue.level !== "error"), issues, diff --git a/src/components.ts b/src/components.ts index 45a37ea..f4cb226 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,5 +1,3 @@ -import type { TargetName } from "./types.js"; - /** Every recognized component directory, across all targets. */ export const componentDirs = [ "skills", @@ -16,23 +14,6 @@ export const componentDirs = [ /** Files copied verbatim to a target's output root rather than treated as components. */ export const staticFiles = ["README.md", "CHANGELOG.md", "LICENSE"]; -/** Component directories emitted for a target when a plugin has no `components` override. */ -export const targetDefaultComponents: Record = { - claude: ["skills", "agents", "hooks", "scripts", "assets"], - copilot: ["skills", "agents", "hooks", "scripts", "assets"], - cursor: ["skills", "agents", "rules", "hooks", "scripts", "assets"], - antigravity: ["skills", "agents", "rules", "hooks", "scripts", "assets"], - codex: ["skills", "hooks", "scripts", "assets"], -}; - -/** Resolves a plugin's component set from its own override, or the target's default. */ -export function resolveTargetComponents( - target: TargetName, - pluginConfig: { components?: string[] }, -): Set { - return new Set(pluginConfig.components ?? targetDefaultComponents[target]); -} - /** Whether a relative path falls under a recognized component directory. */ export function isComponentPath(relativePath: string): boolean { return componentDirs.includes(relativePath.split("/")[0]); diff --git a/src/targets.ts b/src/targets.ts deleted file mode 100644 index 9b490cf..0000000 --- a/src/targets.ts +++ /dev/null @@ -1,707 +0,0 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { collectPluginFiles, resolveMcpServers } from "./render.js"; -import { isSafeRelativePath, json, toPosix } from "./fs.js"; -import { resolveTargetComponents } from "./components.js"; -import { applyUpdateCheck, pluginAllowsUpdateCheck } from "./update-check.js"; -import type { UpdateCheckFormat } from "./update-check.js"; -import type { - Artifact, - EmittedPluginConfig, - FileValue, - Metadata, - ResolvedProject, - TargetConfig, - TargetName, -} from "./types.js"; - -/** - * Emits per-target repo-root files (e.g. a README authored once in the - * source repo) into the artifact so they are managed, pruned, and synced - * like every other generated file — rather than hand-maintained in each - * output repo. - */ -export async function withRootFiles( - project: ResolvedProject, - targetConfig: TargetConfig, - result: Artifact, -): Promise { - const rootFiles = targetConfig.rootFiles; - if (!rootFiles || Object.keys(rootFiles).length === 0) { - return result; - } - const files = new Map(result.files); - for (const [dest, source] of Object.entries(rootFiles)) { - const destPath = toPosix(dest); - if (!isSafeRelativePath(destPath)) { - throw new Error( - `Target "${result.target}" rootFiles destination "${dest}" must be a safe relative path.`, - ); - } - if (files.has(destPath)) { - throw new Error( - `Target "${result.target}" rootFiles destination "${dest}" collides with a generated file.`, - ); - } - let contents: Buffer; - try { - contents = await fs.readFile(path.resolve(project.rootDir, source)); - } catch { - throw new Error( - `Target "${result.target}" rootFiles source "${source}" could not be read.`, - ); - } - files.set(destPath, contents); - } - return artifact(result.target, result.outDir, files); -} - -type EmitPluginContext = { - pluginName: string; - pluginPath: string; - pluginConfig: EmittedPluginConfig; - metadata: Metadata | undefined; - componentDirs: Set; - mcpServers: Record | undefined; - pluginFiles: Map; - manifest: Record | undefined; -}; - -type EmitPluginsOptions = { - resolvePluginPath: ( - pluginName: string, - pluginConfig: EmittedPluginConfig, - ) => string; - // Some targets (Copilot) carry no per-plugin manifest; omit to skip writing one. - pluginManifest?: { - path: (pluginPath: string) => string; - build: ( - metadata: Metadata | undefined, - pluginName: string, - pluginConfig: EmittedPluginConfig, - componentDirs: Set, - mcpServers: Record | undefined, - ) => Record; - }; - // Build this plugin's marketplace entry; omit when the target has no marketplace. - buildEntry?: (ctx: EmitPluginContext) => Record | undefined; - mcp?: "file" | "antigravity"; - // Inject the generated update-check hook (claude/cursor only); resolved from - // targetConfig.updateCheck by the emitter so repository errors surface there. - updateCheck?: { - format: UpdateCheckFormat; - repository: string; - version: (pluginConfig: EmittedPluginConfig) => string; - }; -}; - -/** Shared per-plugin emit loop every legacy `emitXxx` function delegates to. */ -async function emitPlugins( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - files: Map, - options: EmitPluginsOptions, -): Promise[]> { - const entries: Record[] = []; - for (const [pluginName, pluginConfig] of Object.entries( - targetConfig.plugins, - )) { - const pluginPath = options.resolvePluginPath(pluginName, pluginConfig); - const pluginFiles = await collectPluginFiles( - project, - target, - pluginConfig.from, - resolveTargetComponents(target, pluginConfig), - ); - // Inject before componentDirs is derived so hooks/ and scripts/ register - // as components (e.g. the cursor manifest's `hooks` pointer). - if (options.updateCheck && pluginAllowsUpdateCheck(pluginConfig)) { - applyUpdateCheck(pluginFiles, target, { - format: options.updateCheck.format, - pluginName, - version: options.updateCheck.version(pluginConfig), - repository: options.updateCheck.repository, - }); - } - const componentDirs = new Set( - [...pluginFiles.keys()].map((file) => file.split("/")[0]), - ); - for (const [relativePath, value] of pluginFiles) { - files.set(toPosix(path.join(pluginPath, relativePath)), value); - } - - const mcpServers = await resolveMcpServers(project, pluginConfig.from); - if (mcpServers && options.mcp === "file") { - files.set( - toPosix(path.join(pluginPath, ".mcp.json")), - json({ mcpServers }), - ); - } else if (mcpServers && options.mcp === "antigravity") { - files.set( - toPosix(path.join(pluginPath, "mcp_config.json")), - json({ mcpServers }), - ); - } - - const metadata = emittedPluginMetadata(project, pluginConfig); - let manifest: Record | undefined; - if (options.pluginManifest) { - manifest = options.pluginManifest.build( - metadata, - pluginName, - pluginConfig, - componentDirs, - mcpServers, - ); - files.set( - toPosix(options.pluginManifest.path(pluginPath)), - json(manifest), - ); - } - - const entry = options.buildEntry?.({ - pluginName, - pluginPath, - pluginConfig, - metadata, - componentDirs, - mcpServers, - pluginFiles, - manifest, - }); - if (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; -} - -/** - * Resolves a target's `updateCheck` config into the `emitPlugins` option, - * failing fast when no repository URL can be determined. - */ -function updateCheckOption( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - format: UpdateCheckFormat, - version: string, -): EmitPluginsOptions["updateCheck"] { - 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, - repository, - version: (pluginConfig) => pluginConfig.version ?? version, - }; -} - -/** Emits the Cursor target's plugins and marketplace manifest. */ -export async function emitCursor( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const marketplaceDir = targetConfig.marketplaceDir ?? ".cursor-plugin"; - const version = targetConfig.version ?? project.config.version; - const files = new Map(); - - const plugins = await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? pluginName, - pluginManifest: { - path: (pluginPath) => - path.join(pluginPath, marketplaceDir, "plugin.json"), - build: ( - metadata, - pluginName, - pluginConfig, - componentDirs, - mcpServers, - ) => { - const manifest = cursorPluginManifest( - metadata, - pluginConfig.version ?? version, - pluginName, - pluginConfig, - componentDirs, - mcpServers, - ); - // updateCheck injects hooks/ regardless of the components filter (see - // README "Update Check"), and Cursor only runs hooks the manifest - // points at, so force the pointer even if `components` excludes hooks. - if (targetConfig.updateCheck && pluginAllowsUpdateCheck(pluginConfig)) { - manifest.hooks ??= "./hooks/"; - } - return manifest; - }, - }, - buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({ - name: pluginName, - source: pluginPath, - description: - pluginConfig.description ?? - (manifest?.description as string | undefined), - }), - mcp: "file", - updateCheck: updateCheckOption( - project, - target, - targetConfig, - "cursor", - version, - ), - }); - - const marketplace = stripUndefined( - deepMerge( - { - 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, - }, - targetConfig.manifest ?? {}, - ), - ); - files.set( - toPosix(path.join(marketplaceDir, "marketplace.json")), - json(marketplace), - ); - - return artifact(target, outDir, files); -} - -/** - * @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, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const marketplaceDir = targetConfig.marketplaceDir ?? ".claude-plugin"; - 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, marketplaceDir, "plugin.json"), - build: (metadata, pluginName, pluginConfig) => - claudePluginManifest( - metadata, - pluginConfig.version ?? version, - pluginName, - pluginConfig, - ), - }, - buildEntry: ({ pluginName, pluginPath, pluginConfig, manifest }) => ({ - name: pluginName, - source: `./${pluginPath}`, - description: - pluginConfig.description ?? - (manifest?.description as string | undefined), - }), - mcp: "file", - updateCheck: updateCheckOption( - project, - target, - targetConfig, - "claude", - version, - ), - }); - - const marketplace = stripUndefined( - deepMerge( - { - $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, - }, - targetConfig.manifest ?? {}, - ), - ); - files.set( - toPosix(path.join(marketplaceDir, "marketplace.json")), - json(marketplace), - ); - - return artifact(target, outDir, files); -} - -/** - * @deprecated Legacy emitter, superseded by `src/targets/antigravity.ts` via - * the registry in `src/targets/registry.ts`. Kept only until every target - * has migrated (see `src/adapters.ts`). - */ -export async function emitAntigravity( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const version = targetConfig.version ?? project.config.version; - const files = new Map(); - - await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? pluginName, - pluginManifest: { - path: (pluginPath) => path.join(pluginPath, "plugin.json"), - build: (metadata, pluginName, pluginConfig) => - antigravityPluginManifest( - metadata, - pluginConfig.version ?? version, - pluginName, - pluginConfig, - ), - }, - mcp: "antigravity", - }); - - return artifact(target, outDir, files); -} - -/** - * @deprecated Legacy emitter, superseded by `src/targets/copilot.ts` via the - * registry in `src/targets/registry.ts`. Kept only until every target has - * migrated (see `src/adapters.ts`). - */ -export async function emitCopilot( - project: ResolvedProject, - target: TargetName, - targetConfig: TargetConfig, - outDir: string, -): Promise { - const version = targetConfig.version ?? project.config.version; - const pluginRoot = targetConfig.pluginRoot ?? "plugins"; - const files = new Map(); - - // Copilot has no per-plugin manifest; each plugin's data lives in its - // marketplace entry (with a derived skills list), so this omits pluginManifest - // and builds the entry directly. - const plugins = await emitPlugins(project, target, targetConfig, files, { - resolvePluginPath: (pluginName, pluginConfig) => - pluginConfig.path ?? toPosix(path.join(pluginRoot, pluginName)), - buildEntry: ({ - pluginName, - pluginPath, - pluginConfig, - metadata, - mcpServers, - pluginFiles, - }) => - stripUndefined({ - name: pluginName, - source: `./${pluginPath}`, - description: pluginConfig.description ?? metadata?.description, - version: pluginConfig.version ?? version, - skills: [ - ...new Set( - [...pluginFiles.keys()] - .filter((file) => file.startsWith("skills/")) - .map((file) => `./skills/${file.split("/")[1]}`), - ), - ].sort(), - mcpServers: mcpServers ? ".mcp.json" : undefined, - }), - mcp: "file", - }); - - const marketplace = stripUndefined( - deepMerge( - { - name: project.config.name, - metadata: stripUndefined({ - description: project.config.metadata?.description, - version, - keywords: project.config.metadata?.keywords, - }), - owner: - project.config.metadata?.owner ?? project.config.metadata?.author, - plugins, - }, - targetConfig.manifest ?? {}, - ), - ); - // Copilot reuses the Claude marketplace schema and reads it from both the - // repo-root .claude-plugin/ and .github/plugin/ (see github/copilot-plugins). - const marketplaceJson = json(marketplace); - files.set( - toPosix(path.join(".claude-plugin", "marketplace.json")), - marketplaceJson, - ); - files.set( - toPosix(path.join(".github", "plugin", "marketplace.json")), - marketplaceJson, - ); - - return artifact(target, outDir, files); -} - -/** - * @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, - 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, -): Metadata | undefined { - const sourceMetadata = - pluginConfig.from.length === 1 - ? project.plugins.get(pluginConfig.from[0])?.manifest - : undefined; - return stripUndefined({ - ...project.config.metadata, - ...sourceMetadata, - }); -} - -function cursorPluginManifest( - metadata: Metadata | undefined, - version: string, - pluginName: string, - pluginConfig: EmittedPluginConfig, - componentDirs: Set, - mcpServers: Record | undefined, -): Record { - 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, - }; - const components = pluginConfig.components ?? [ - "skills", - "agents", - "commands", - "rules", - "hooks", - ]; - for (const component of components) { - if (componentDirs.has(component)) { - manifest[component] = `./${component}/`; - } - } - if (mcpServers) { - manifest.mcpServers = "./.mcp.json"; - } - return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); -} - -function claudePluginManifest( - metadata: Metadata | undefined, - version: string, - pluginName: string, - pluginConfig: EmittedPluginConfig, -): 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, - }; - 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, - pluginName: string, - pluginConfig: EmittedPluginConfig, -): Record { - const manifest: Record = { - name: pluginName, - version, - description: pluginConfig.description ?? metadata?.description, - }; - return stripUndefined(deepMerge(manifest, pluginConfig.manifest ?? {})); -} - -function artifact( - target: TargetName, - outDir: string, - files: Map, -): Artifact { - const managedPaths = [...files.keys()].sort(); - return { - target, - outDir, - files: new Map([...files.entries()].sort(([a], [b]) => a.localeCompare(b))), - managedPaths, - }; -} - -function stripUndefined>(value: T): T { - for (const key of Object.keys(value)) { - if (value[key] === undefined) { - delete value[key]; - } - } - return value; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Deep-merge an override onto a generated manifest. Nested objects merge so a -// sibling key isn't lost; arrays and scalars from the override replace (not -// concatenate, so keywords/tags don't double up). This is the general escape -// hatch — any field, at any depth, can be overridden via a target/plugin -// `manifest`. -function deepMerge( - base: Record, - override: Record, -): Record { - const result: Record = { ...base }; - for (const [key, value] of Object.entries(override)) { - const existing = result[key]; - result[key] = - isPlainObject(existing) && isPlainObject(value) - ? deepMerge(existing, value) - : value; - } - return result; -} - -function titleCase(value: string): string { - return value - .split(/[-_.]/) - .filter(Boolean) - .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) - .join(" "); -} diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 76cf3cd..0ea1514 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -1,6 +1,7 @@ +import { promises as fs } from "node:fs"; import path from "node:path"; import { collectPluginFiles, resolveMcpServers } from "../render.js"; -import { json, toPosix } from "../fs.js"; +import { isSafeRelativePath, 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"; @@ -17,9 +18,8 @@ import type { PluginTargetDefinition } from "./types.js"; /** * Resolves a plugin's component set from its own `components` override, or - * the target definition's default — decoupled from the legacy global - * `targetDefaultComponents` table (`../components.ts`) so a migrated - * target's defaults are owned entirely by its own `PluginTargetDefinition`. + * the target definition's default — each target owns its own defaults via + * `PluginTargetDefinition.defaultComponents` rather than a shared table. */ function resolveComponents( definition: PluginTargetDefinition, @@ -228,3 +228,44 @@ function artifact( managedPaths, }; } + +/** + * Emits per-target repo-root files (e.g. a README authored once in the + * source repo) into the artifact so they are managed, pruned, and synced + * like every other generated file — rather than hand-maintained in each + * output repo. + */ +export async function withRootFiles( + project: ResolvedProject, + targetConfig: TargetConfig, + result: Artifact, +): Promise { + const rootFiles = targetConfig.rootFiles; + if (!rootFiles || Object.keys(rootFiles).length === 0) { + return result; + } + const files = new Map(result.files); + for (const [dest, source] of Object.entries(rootFiles)) { + const destPath = toPosix(dest); + if (!isSafeRelativePath(destPath)) { + throw new Error( + `Target "${result.target}" rootFiles destination "${dest}" must be a safe relative path.`, + ); + } + if (files.has(destPath)) { + throw new Error( + `Target "${result.target}" rootFiles destination "${dest}" collides with a generated file.`, + ); + } + let contents: Buffer; + try { + contents = await fs.readFile(path.resolve(project.rootDir, source)); + } catch { + throw new Error( + `Target "${result.target}" rootFiles source "${source}" could not be read.`, + ); + } + files.set(destPath, contents); + } + return artifact(result.target, result.outDir, files); +} diff --git a/src/targets/registry.ts b/src/targets/registry.ts index 3d884fe..aa8dab6 100644 --- a/src/targets/registry.ts +++ b/src/targets/registry.ts @@ -7,13 +7,12 @@ import type { TargetName } from "../types.js"; import type { PluginTargetDefinition } from "./types.js"; /** - * Migrated targets, filled in one at a time as each moves off the legacy - * per-target functions in `../targets.ts` and `../validate.ts` — see - * `../adapters.ts`, which falls back to those for any target not yet - * present here. Becomes `Record` once - * every target has an entry, at which point `adapters.ts` can be deleted. + * Every target, in one place — `Record` is exhaustive at + * compile time, so a new `TargetName` won't build until it has an entry + * here. `../adapters.ts` builds its emit/validate dispatch, the CLI + * `--target` choices, and the set `build()` iterates all from this. */ -export const targets: Partial> = { +export const targets: Record = { copilot, antigravity, cursor, diff --git a/src/update-check.ts b/src/update-check.ts index fbe309d..f5b7b7c 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -21,10 +21,11 @@ export type UpdateCheckOptions = { /** * The per-plugin opt-out gate, shared by both call sites that decide whether - * this plugin gets the generated hook (file injection in `emitPlugins`, and - * the manifest's `hooks` pointer in `emitCursor`) — callers still check - * their own target-level `updateCheck` presence first, for type-narrowing - * convenience. + * this plugin gets the generated hook (file injection in + * `src/targets/engine.ts`'s `emitFromDefinition`, and each target's own + * `buildPluginManifest` for the manifest's `hooks` pointer) — callers still + * check their own target-level `updateCheck` presence first, for + * type-narrowing convenience. */ export function pluginAllowsUpdateCheck( pluginConfig: Pick, diff --git a/src/validate.ts b/src/validate.ts deleted file mode 100644 index 64875af..0000000 --- a/src/validate.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { promises as fs, statSync } from "node:fs"; -import path from "node:path"; -import matter from "gray-matter"; -import { - exists, - isNotFoundError, - isSafeRelativePath, - toPosix, - walkFiles, -} from "./fs.js"; -import { UPDATE_CHECK_SCRIPT_PATH } from "./update-check.js"; -import type { TargetName, ValidationIssue } from "./types.js"; - -const pluginNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; -const marketplaceNamePattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; - -/** - * @deprecated Legacy validator, superseded by `src/targets/antigravity.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 validateAntigravity( - root: string, - issues: ValidationIssue[], -): Promise { - const entries = await fs.readdir(root, { withFileTypes: true }); - const pluginDirs = entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) - .map((entry) => path.join(root, entry.name)); - if (pluginDirs.length === 0) { - error( - issues, - "Antigravity output must contain at least one plugin directory.", - ); - return; - } - for (const pluginDir of pluginDirs) { - const manifest = await readJson( - path.join(pluginDir, "plugin.json"), - "Antigravity plugin manifest", - issues, - ); - if (!manifest) { - continue; - } - const pluginName = path.basename(pluginDir); - if ( - typeof manifest.name !== "string" || - !pluginNamePattern.test(manifest.name) - ) { - error( - issues, - `${pluginName}: plugin.json must have a lowercase kebab-case "name".`, - ); - } - if (manifest.name && manifest.name !== pluginName) { - error( - issues, - `${pluginName}: manifest name must match plugin directory name.`, - ); - } - for (const field of ["version", "description"]) { - if (typeof manifest[field] !== "string" || !manifest[field]) { - error( - issues, - `${pluginName}: plugin.json is missing required field "${field}".`, - ); - } - } - const mcpConfigPath = path.join(pluginDir, "mcp_config.json"); - if (await exists(mcpConfigPath)) { - await readJson(mcpConfigPath, `${pluginName} MCP config`, issues); - } - await validateFrontmatter(pluginDir, pluginName, "antigravity", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -/** - * @deprecated Legacy validator, superseded by `src/targets/copilot.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 validateCopilot( - root: string, - issues: ValidationIssue[], -): Promise { - const marketplacePath = path.join(root, ".claude-plugin", "marketplace.json"); - const marketplace = await readJson( - marketplacePath, - "Marketplace manifest", - issues, - ); - if (!marketplace) { - return; - } - validateMarketplaceBasics(marketplace, issues); - if ( - !(await exists(path.join(root, ".github", "plugin", "marketplace.json"))) - ) { - error( - issues, - "Copilot output must mirror the marketplace at .github/plugin/marketplace.json.", - ); - } - 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; - } - await validateFrontmatter( - path.join(root, entry.source), - pluginName, - "copilot", - issues, - ); - } -} - -/** Validates a built Cursor target's output directory. */ -export async function validateCursor( - root: string, - issues: ValidationIssue[], -): Promise { - 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 = validatePluginEntry(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", "commands", "agents", "skills", "rules", "hooks", "mcpServers"], - issues, - ); - await validateFrontmatter(pluginDir, pluginName, "cursor", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -/** - * @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[], -): Promise { - 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 = validatePluginEntry(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}").`, - ); - } - for (const field of ["name", "version", "description"]) { - if (typeof manifest[field] !== "string" || !manifest[field]) { - error( - issues, - `${pluginName}: plugin.json is missing required field "${field}".`, - ); - } - } - if ( - !manifest.author || - typeof manifest.author.name !== "string" || - !manifest.author.name - ) { - error(issues, `${pluginName}: plugin.json is missing "author.name".`); - } - await validateFrontmatter(pluginDir, pluginName, "claude", issues); - await validateHooks(pluginDir, pluginName, issues); - } -} - -/** - * @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[], -): 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[], -): void { - if ( - typeof marketplace.name !== "string" || - !marketplaceNamePattern.test(marketplace.name) - ) { - error( - issues, - 'Marketplace "name" must be lowercase kebab-case and start/end with an alphanumeric character.', - ); - } - const owner = marketplace.owner as Record | undefined; - if (owner && (typeof owner.name !== "string" || !owner.name)) { - error( - issues, - 'Marketplace "owner.name" must be a non-empty string when owner is present.', - ); - } -} - -function validatePluginEntry( - 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; - } - if (typeof entry.source !== "string" || !isSafeRelativePath(entry.source)) { - error(issues, `plugins[${index}].source must be a safe relative path.`); - return null; - } - const pluginDir = path.join(root, entry.source); - if (!entry.source.startsWith("http") && !pathExistsSync(pluginDir)) { - error( - issues, - `plugins[${index}].source directory is missing: ${entry.source}`, - ); - return null; - } - return entry.name; -} - -async function validateReferencedManifestPaths( - pluginDir: string, - pluginName: string, - manifest: Record, - fields: string[], - issues: ValidationIssue[], -): Promise { - for (const field of fields) { - for (const value of extractPathValues(manifest[field])) { - if (value.startsWith("http://") || value.startsWith("https://")) { - continue; - } - if (!isSafeRelativePath(value)) { - error( - issues, - `${pluginName}: field "${field}" has unsafe path "${value}".`, - ); - continue; - } - if (!(await exists(path.join(pluginDir, value)))) { - error( - issues, - `${pluginName}: field "${field}" references missing path "${value}".`, - ); - } - } - } -} - -function extractPathValues(value: unknown): string[] { - if (typeof value === "string") { - return [value]; - } - if (Array.isArray(value)) { - return value.flatMap(extractPathValues); - } - if (value && typeof value === "object") { - const object = value as Record; - return [object.path, object.file].filter( - (entry): entry is string => typeof entry === "string", - ); - } - return []; -} - -async function validateFrontmatter( - pluginDir: string, - pluginName: string, - target: TargetName, - issues: ValidationIssue[], -): Promise { - const files = await walkFiles(pluginDir); - for (const file of files) { - const kind = detectFrontmatterKind(file); - if (!kind) { - continue; - } - const relative = toPosix(path.relative(pluginDir, file)); - const parsed = parseFrontmatter(await fs.readFile(file, "utf8")); - if (!parsed.ok) { - error( - issues, - `${pluginName}: ${kind} frontmatter error in ${relative}: ${parsed.error}`, - ); - continue; - } - if (kind === "agent") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["name", "description"], - issues, - ); - } else if (kind === "command") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["description"], - issues, - ); - if ( - target === "cursor" || - target === "antigravity" || - target === "copilot" - ) { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["name"], - issues, - ); - } - } else if (kind === "skill") { - if (target === "cursor") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["name", "description"], - issues, - ); - } else if (!parsed.value.description && !parsed.value.when_to_use) { - error( - issues, - `${pluginName}: ${kind} frontmatter error in ${relative}: Missing required "description" field.`, - ); - } - } else if (kind === "rule") { - requireFrontmatter( - pluginName, - kind, - relative, - parsed.value, - ["description"], - issues, - ); - } - } -} - -function detectFrontmatterKind( - filePath: string, -): "agent" | "skill" | "command" | "rule" | null { - const normalized = toPosix(filePath); - const inSkillContent = - /\/skills\/[^/]+\//.test(normalized) && !normalized.endsWith("/SKILL.md"); - if ( - normalized.includes("/agents/") && - /\.(md|mdc|markdown)$/.test(normalized) && - !inSkillContent - ) { - return "agent"; - } - if (normalized.includes("/skills/") && normalized.endsWith("/SKILL.md")) { - return "skill"; - } - if ( - normalized.includes("/commands/") && - /\.(md|mdc|markdown|txt)$/.test(normalized) && - !inSkillContent - ) { - return "command"; - } - if ( - normalized.includes("/rules/") && - /\.(md|mdc|markdown)$/.test(normalized) && - !inSkillContent - ) { - return "rule"; - } - return null; -} - -function parseFrontmatter( - content: string, -): { ok: true; value: Record } | { ok: false; error: string } { - try { - const parsed = matter(content); - if (Object.keys(parsed.data).length === 0) { - return { ok: false, error: "No frontmatter found" }; - } - return { ok: true, value: parsed.data }; - } catch (err) { - return { ok: false, error: `YAML parse failed: ${(err as Error).message}` }; - } -} - -function requireFrontmatter( - pluginName: string, - kind: string, - relative: string, - frontmatter: Record, - fields: string[], - issues: ValidationIssue[], -): void { - for (const field of fields) { - if (typeof frontmatter[field] !== "string" || !frontmatter[field]) { - error( - issues, - `${pluginName}: ${kind} frontmatter error in ${relative}: Missing required "${field}" field.`, - ); - } - } -} - -async function validateHooks( - pluginDir: string, - pluginName: string, - issues: ValidationIssue[], -): Promise { - const hooksPath = path.join(pluginDir, "hooks", "hooks.json"); - if (!(await exists(hooksPath))) { - return; - } - const hooks = await readJson( - hooksPath, - `${pluginName} hooks/hooks.json`, - issues, - ); - if (!hooks) { - return; - } - if ( - !hooks.hooks || - typeof hooks.hooks !== "object" || - Array.isArray(hooks.hooks) - ) { - error( - issues, - `${pluginName}: hooks/hooks.json must have a "hooks" object.`, - ); - return; - } - for (const [event, entries] of Object.entries( - hooks.hooks as Record, - )) { - if (!Array.isArray(entries)) { - error( - issues, - `${pluginName}: hooks/hooks.json "hooks.${event}" must be an array.`, - ); - } - } - for (const command of collectCommandStrings(hooks.hooks)) { - if (!command) { - error( - issues, - `${pluginName}: hooks/hooks.json has an empty "command" string.`, - ); - continue; - } - // A command referencing the generated update-check script must ship it. - if ( - command.includes(UPDATE_CHECK_SCRIPT_PATH) && - !(await exists(path.join(pluginDir, UPDATE_CHECK_SCRIPT_PATH))) - ) { - error( - issues, - `${pluginName}: hooks/hooks.json references scripts/pluginpack-update-check.sh but the script is missing.`, - ); - } - } -} - -// All string values under a "command" key, at any depth (Claude nests command -// entries under matcher groups; Cursor keeps them at the event level). -function collectCommandStrings(value: unknown): string[] { - if (Array.isArray(value)) { - return value.flatMap(collectCommandStrings); - } - if (value && typeof value === "object") { - const object = value as Record; - const own = typeof object.command === "string" ? [object.command] : []; - return [...own, ...Object.values(object).flatMap(collectCommandStrings)]; - } - return []; -} - -async function readJson( - filePath: string, - context: string, - issues: ValidationIssue[], -): Promise | null> { - try { - return JSON.parse(await fs.readFile(filePath, "utf8")) as Record< - string, - any - >; - } catch (err) { - error( - issues, - `${context} is missing or invalid (${filePath}): ${(err as Error).message}`, - ); - return null; - } -} - -function pathExistsSync(filePath: string): boolean { - try { - statSync(filePath); - return true; - } catch (error) { - if (isNotFoundError(error)) { - return false; - } - throw error; - } -} - -function error(issues: ValidationIssue[], message: string): void { - issues.push({ level: "error", message }); -} From 8f54038ed32f8f475f2d8ab352931e11d818160e Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 14:21:06 -0700 Subject: [PATCH 7/7] docs: note the shared hooks validation depth in CONFORMANCE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-references the validateHooksShape fix from the prior commit — what it checks and where it's shared from, for the conformance doc's own "what's actually verified and how" mandate. --- CONFORMANCE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 80f1a66..fb0a764 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -90,6 +90,12 @@ cursor manifest's `hooks` key validates against the vendored plugin schema, and `claude plugin validate --strict` exercises the claude hooks file when the CLI is present). +Every target's `validateOutput` shares one `validateHooksShape` +(`src/targets/validation-shared.ts`) for the parts of a hooks file that are +target-agnostic: each event's entries must be an array, no `command` string +may be empty, and any command referencing the generated update-check script +(`scripts/pluginpack-update-check.sh`) must ship that script. + ## Refreshing vendored schemas The Cursor schemas are pinned copies. To update them, re-fetch from the source