Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/targets/cursor.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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",
},
],
};
62 changes: 60 additions & 2 deletions src/targets/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -41,6 +79,12 @@ export async function emitFromDefinition(
const version = targetConfig.version ?? project.config.version;
const files = new Map<string, FileValue>();
const entries: Record<string, unknown>[] = [];
const updateCheck = resolveUpdateCheck(
project,
target,
targetConfig,
version,
);

for (const [pluginName, pluginConfig] of Object.entries(
targetConfig.plugins,
Expand All @@ -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]),
);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions src/targets/registry.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,4 +14,5 @@ import type { PluginTargetDefinition } from "./types.js";
export const targets: Partial<Record<TargetName, PluginTargetDefinition>> = {
copilot,
antigravity,
cursor,
};
4 changes: 2 additions & 2 deletions src/targets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export type PluginTargetDefinition = {

buildPluginManifest: (ctx: ManifestBuildContext) => Record<string, unknown>;
/** 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: (
Expand All @@ -108,7 +108,7 @@ export type PluginTargetDefinition = {
ctx: MarketplaceManifestContext,
) => Record<string, unknown>;
/** 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;
Expand Down
Loading