From 4f74ef5627e3f5f63891b6fb97061d603e53f275 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 26 Jul 2026 13:42:48 -0700 Subject: [PATCH 1/2] 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/2] 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;