diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts new file mode 100644 index 0000000..b9493d9 --- /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: pluginConfig.version ?? 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..d62b121 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -366,6 +366,142 @@ 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("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;