mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
feat(cursor): ship a Marketplace plugin with the skill and pinned MCP server (#9878)
Cursor Marketplace installs are Git checkouts, so the plugin manifest points at a synced copy of the authored skill and starts npx -y rea-agents@<release> mcp. Release Please and the release checkpoint keep that pin aligned with the package version. The manifest, skill, and icon checks follow the local plugin bundle contract from #7619, adapted for a checkout that has no dist/. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: GamerDubz <105694977+GamerDubz@users.noreply.github.com>
This commit is contained in:
29 files changed
+4529
-95
No files matched your search
@@ -0,0 +1,14 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { syncCursorPluginSkill } from "./lib/cursor-plugin-bundle.mjs";
|
||||
|
||||
const arguments_ = new Set(process.argv.slice(2));
|
||||
for (const argument of arguments_)
|
||||
if (argument !== "--check")
|
||||
throw new Error(`Unknown Cursor plugin skill option: ${argument}`);
|
||||
|
||||
await syncCursorPluginSkill({
|
||||
root: join(dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
check: arguments_.has("--check"),
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export const CURSOR_PLUGIN_MANIFEST: ".cursor-plugin/plugin.json";
|
||||
export const CURSOR_PLUGIN_MCP: "mcp.json";
|
||||
export const AUTHORED_SKILL_DIRECTORY: ".agents/skills/reverse-engineer-anything";
|
||||
export const CURSOR_PLUGIN_SKILLS: ".cursor-plugin/skills";
|
||||
export const CURSOR_PLUGIN_SKILL: "reverse-engineer-anything";
|
||||
export const CURSOR_PLUGIN_LOGO: "assets/rea-plugin.png";
|
||||
export const CURSOR_PLUGIN_REPOSITORY: "https://github.com/morluto/rea";
|
||||
|
||||
export interface CursorPluginBundleIssue {
|
||||
readonly field: string;
|
||||
}
|
||||
|
||||
export interface CursorPluginBundleInput {
|
||||
readonly manifest: {
|
||||
readonly name?: string;
|
||||
readonly version?: string;
|
||||
readonly description?: string;
|
||||
readonly author?: { readonly name?: string };
|
||||
readonly repository?: string;
|
||||
readonly license?: string;
|
||||
readonly keywords?: readonly string[];
|
||||
readonly skills?: string;
|
||||
readonly logo?: string;
|
||||
readonly mcpServers?: unknown;
|
||||
};
|
||||
readonly mcp: {
|
||||
readonly mcpServers?: {
|
||||
readonly rea?: {
|
||||
readonly command?: string;
|
||||
readonly args?: readonly string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
readonly packageVersion: string;
|
||||
readonly logoExists: boolean;
|
||||
readonly skillNames: readonly string[];
|
||||
readonly skillFrontmatter: {
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CursorPluginInspection {
|
||||
readonly manifest: CursorPluginBundleInput["manifest"] & {
|
||||
readonly version: string;
|
||||
};
|
||||
readonly mcp: {
|
||||
readonly mcpServers: {
|
||||
readonly rea: {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
readonly packageVersion: string;
|
||||
readonly issues: readonly CursorPluginBundleIssue[];
|
||||
}
|
||||
|
||||
export function cursorPluginSkillPaths(
|
||||
directory: string,
|
||||
prefix?: string,
|
||||
): Promise<readonly string[] | null>;
|
||||
|
||||
export function cursorPluginBundleIssues(
|
||||
input: CursorPluginBundleInput,
|
||||
): readonly CursorPluginBundleIssue[];
|
||||
|
||||
export function syncCursorPluginSkill(options: {
|
||||
readonly root: string;
|
||||
readonly check: boolean;
|
||||
}): Promise<void>;
|
||||
|
||||
export function inspectCursorPluginBundle(
|
||||
root: string,
|
||||
): Promise<CursorPluginInspection>;
|
||||
|
||||
export function cursorMarketplaceInstallLinkIssues(
|
||||
documents: readonly { readonly path: string; readonly text: string }[],
|
||||
): readonly { readonly path: string; readonly marker: string }[];
|
||||
@@ -0,0 +1,216 @@
|
||||
import { mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { ensureGeneratedFile } from "./generated-file.mjs";
|
||||
|
||||
// Identity fields follow the shared plugin manifest checked for local Cursor
|
||||
// publication (name, release version, repository, skill, and icon). The
|
||||
// Marketplace checkout has no dist/, so the server is the pinned npm command
|
||||
// instead of the bundled launcher.
|
||||
export const CURSOR_PLUGIN_MANIFEST = ".cursor-plugin/plugin.json";
|
||||
export const CURSOR_PLUGIN_MCP = "mcp.json";
|
||||
export const AUTHORED_SKILL_DIRECTORY =
|
||||
".agents/skills/reverse-engineer-anything";
|
||||
export const CURSOR_PLUGIN_SKILLS = ".cursor-plugin/skills";
|
||||
export const CURSOR_PLUGIN_SKILL = "reverse-engineer-anything";
|
||||
export const CURSOR_PLUGIN_LOGO = "assets/rea-plugin.png";
|
||||
export const CURSOR_PLUGIN_REPOSITORY = "https://github.com/morluto/rea";
|
||||
const GENERATE_COMMAND = "node scripts/generate-cursor-plugin-skill.mjs";
|
||||
const INSTALL_LINK_MARKERS = ["install-mcp", "deeplink/mcp", "mcp-install"];
|
||||
|
||||
const failure = (code, detail) => {
|
||||
const error = new Error(`${code}: ${detail}`);
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
const cursorServerArgs = (version) => ["-y", `rea-agents@${version}`, "mcp"];
|
||||
|
||||
/** Relative file inventory for one skill directory, in stable order. */
|
||||
export const cursorPluginSkillPaths = async (directory, prefix = "") => {
|
||||
const paths = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (cause) {
|
||||
if (cause?.code === "ENOENT") return null;
|
||||
throw cause;
|
||||
}
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||
)) {
|
||||
const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
||||
if (entry.isDirectory())
|
||||
paths.push(
|
||||
...(await cursorPluginSkillPaths(
|
||||
join(directory, entry.name),
|
||||
relativePath,
|
||||
)),
|
||||
);
|
||||
else if (entry.isFile()) paths.push(relativePath);
|
||||
else
|
||||
throw failure(
|
||||
"cursor_plugin_skill_inventory",
|
||||
`Skill bundle requires regular files or directories: ${relativePath}`,
|
||||
);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
const skillFrontmatter = (content) => {
|
||||
const normalized = content.replaceAll("\r\n", "\n");
|
||||
if (!normalized.startsWith("---\n")) return null;
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing === -1) return null;
|
||||
const fields = {};
|
||||
for (const line of normalized.slice(4, closing).split("\n")) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator === -1) continue;
|
||||
const key = line.slice(0, separator).trim();
|
||||
const value = line.slice(separator + 1).trim();
|
||||
if (key !== "" && fields[key] === undefined) fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Structured differences between the Cursor plugin declaration and the
|
||||
* package version it must launch. Empty means the manifest, server pin,
|
||||
* logo, and skill frontmatter agree.
|
||||
*/
|
||||
export const cursorPluginBundleIssues = ({
|
||||
manifest,
|
||||
mcp,
|
||||
packageVersion,
|
||||
logoExists,
|
||||
skillNames,
|
||||
skillFrontmatter: frontmatter,
|
||||
}) => {
|
||||
const issues = [];
|
||||
const add = (field) => issues.push({ field });
|
||||
if (manifest?.name !== "rea") add("name");
|
||||
if (manifest?.version !== packageVersion) add("version");
|
||||
if (typeof manifest?.description !== "string" || manifest.description === "")
|
||||
add("description");
|
||||
if (typeof manifest?.author?.name !== "string" || manifest.author.name === "")
|
||||
add("author.name");
|
||||
if (manifest?.repository !== CURSOR_PLUGIN_REPOSITORY) add("repository");
|
||||
if (typeof manifest?.license !== "string" || manifest.license === "")
|
||||
add("license");
|
||||
if (
|
||||
!Array.isArray(manifest?.keywords) ||
|
||||
manifest.keywords.length === 0 ||
|
||||
manifest.keywords.some((keyword) => typeof keyword !== "string")
|
||||
)
|
||||
add("keywords");
|
||||
if (manifest?.skills !== CURSOR_PLUGIN_SKILLS) add("skills");
|
||||
if (manifest?.logo !== CURSOR_PLUGIN_LOGO) add("logo");
|
||||
if (Object.hasOwn(manifest ?? {}, "mcpServers")) add("mcpServers");
|
||||
if (logoExists !== true) add("logo.file");
|
||||
const server = mcp?.mcpServers?.rea;
|
||||
if (
|
||||
server?.command !== "npx" ||
|
||||
JSON.stringify(server?.args) !==
|
||||
JSON.stringify(cursorServerArgs(packageVersion))
|
||||
)
|
||||
add("mcp.args");
|
||||
if (
|
||||
!Array.isArray(skillNames) ||
|
||||
skillNames.length !== 1 ||
|
||||
skillNames[0] !== CURSOR_PLUGIN_SKILL
|
||||
)
|
||||
add("skills.entries");
|
||||
if (frontmatter?.name !== CURSOR_PLUGIN_SKILL) add("skill.name");
|
||||
if (
|
||||
typeof frontmatter?.description !== "string" ||
|
||||
frontmatter.description === ""
|
||||
)
|
||||
add("skill.description");
|
||||
return issues;
|
||||
};
|
||||
|
||||
/** Copy the authored skill into the Marketplace plugin tree, or fail when it drifted. */
|
||||
export const syncCursorPluginSkill = async ({ root, check }) => {
|
||||
const sourceRoot = join(root, AUTHORED_SKILL_DIRECTORY);
|
||||
const outputRoot = join(root, CURSOR_PLUGIN_SKILLS, CURSOR_PLUGIN_SKILL);
|
||||
const sourcePaths = await cursorPluginSkillPaths(sourceRoot);
|
||||
if (sourcePaths === null)
|
||||
throw failure(
|
||||
"cursor_plugin_skill_inventory",
|
||||
`Authored skill is missing: ${AUTHORED_SKILL_DIRECTORY}`,
|
||||
);
|
||||
if (!check) await rm(outputRoot, { recursive: true, force: true });
|
||||
for (const relativePath of sourcePaths) {
|
||||
const source = (
|
||||
await readFile(join(sourceRoot, relativePath), "utf8")
|
||||
).replaceAll("\r\n", "\n");
|
||||
if (!check)
|
||||
await mkdir(dirname(join(outputRoot, relativePath)), { recursive: true });
|
||||
try {
|
||||
await ensureGeneratedFile({
|
||||
path: join(outputRoot, relativePath),
|
||||
source,
|
||||
check,
|
||||
generateCommand: GENERATE_COMMAND,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw failure(
|
||||
"cursor_plugin_skill_stale",
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
}
|
||||
}
|
||||
const outputPaths = await cursorPluginSkillPaths(outputRoot);
|
||||
if (
|
||||
outputPaths === null ||
|
||||
JSON.stringify(outputPaths) !== JSON.stringify(sourcePaths)
|
||||
)
|
||||
throw failure(
|
||||
"cursor_plugin_skill_inventory",
|
||||
"Cursor plugin skill inventory drifted; run node scripts/generate-cursor-plugin-skill.mjs",
|
||||
);
|
||||
};
|
||||
|
||||
/** Read the committed plugin and report structured contract issues. */
|
||||
export const inspectCursorPluginBundle = async (root) => {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(root, CURSOR_PLUGIN_MANIFEST), "utf8"),
|
||||
);
|
||||
const mcp = JSON.parse(await readFile(join(root, CURSOR_PLUGIN_MCP), "utf8"));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const logoExists = await stat(join(root, CURSOR_PLUGIN_LOGO))
|
||||
.then((metadata) => metadata.isFile())
|
||||
.catch((cause) => {
|
||||
if (cause?.code === "ENOENT") return false;
|
||||
throw cause;
|
||||
});
|
||||
const skillDirectory = join(root, CURSOR_PLUGIN_SKILLS);
|
||||
const skillNames = await readdir(skillDirectory);
|
||||
const skillContent = await readFile(
|
||||
join(skillDirectory, CURSOR_PLUGIN_SKILL, "SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
manifest,
|
||||
mcp,
|
||||
packageVersion: packageJson.version,
|
||||
issues: cursorPluginBundleIssues({
|
||||
manifest,
|
||||
mcp,
|
||||
packageVersion: packageJson.version,
|
||||
logoExists,
|
||||
skillNames,
|
||||
skillFrontmatter: skillFrontmatter(skillContent),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/** MCP-only install links omit the skill, so Marketplace docs must not carry them. */
|
||||
export const cursorMarketplaceInstallLinkIssues = (documents) =>
|
||||
documents.flatMap(({ path, text }) =>
|
||||
INSTALL_LINK_MARKERS.filter((marker) => text.includes(marker)).map(
|
||||
(marker) => ({ path, marker }),
|
||||
),
|
||||
);
|
||||
@@ -35,6 +35,16 @@ const codexServerConfigSchema = z.object({
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const cursorPluginManifest = ".cursor-plugin/plugin.json";
|
||||
const cursorPluginServer = "mcp.json";
|
||||
const cursorServerConfigSchema = z.object({
|
||||
mcpServers: z.object({
|
||||
rea: z.object({
|
||||
command: z.literal("npx"),
|
||||
args: z.tuple([z.literal("-y"), z.string(), z.literal("mcp")]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const sectionsSchema = z.object({
|
||||
versioning: z.string().optional(),
|
||||
"changelog-sections": z.array(
|
||||
@@ -240,6 +250,20 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
: manifest;
|
||||
launches.push([path, server.mcpServers.rea.args[1]]);
|
||||
}
|
||||
const cursorManifest = await readJson(
|
||||
git,
|
||||
sha,
|
||||
cursorPluginManifest,
|
||||
versionSchema,
|
||||
);
|
||||
versions.push([cursorPluginManifest, cursorManifest.version]);
|
||||
const cursorServer = await readJson(
|
||||
git,
|
||||
sha,
|
||||
cursorPluginServer,
|
||||
cursorServerConfigSchema,
|
||||
);
|
||||
const cursorPin = cursorServer.mcpServers.rea.args[1];
|
||||
// Catalogs are generated and checked from this candidate by the release job;
|
||||
// they are not version authority stored in the immutable source tree.
|
||||
const generated = await git([
|
||||
@@ -262,6 +286,13 @@ async function validateCandidate(git, sha, version, commits) {
|
||||
}
|
||||
}
|
||||
// The launcher checks the scalar pin against the runtime in its own package.
|
||||
// Compare manifest versions first so a wholly stale candidate reports the
|
||||
// shared version authority before this Marketplace package specifier.
|
||||
if (cursorPin !== `rea-agents@${version}`) {
|
||||
throw new Error(
|
||||
`${cursorPluginServer}@${sha} launches ${cursorPin}; release branch requires rea-agents@${version}.`,
|
||||
);
|
||||
}
|
||||
for (const [location, launched] of launches) {
|
||||
if (launched !== version) {
|
||||
throw new Error(
|
||||
|
||||
@@ -373,6 +373,8 @@ const VERSION_ONLY_PLUGIN_METADATA = new Set([
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
".codex-plugin/mcp.json",
|
||||
".cursor-plugin/plugin.json",
|
||||
"mcp.json",
|
||||
".claude-plugin/marketplace.json",
|
||||
".agents/plugins/marketplace.json",
|
||||
]);
|
||||
@@ -395,9 +397,14 @@ function pluginMetadataWithoutVersions(path, text) {
|
||||
}
|
||||
if ("version" in manifest) manifest.version = "";
|
||||
const launch = manifest.mcpServers?.rea?.args;
|
||||
// Only the scalar pinned version is release-owned; the launcher owns the
|
||||
// fixed package name and the executable/path remain product behavior.
|
||||
if (Array.isArray(launch) && typeof launch[1] === "string") launch[1] = "";
|
||||
// Only the scalar pinned version is release-owned. Blank that version and
|
||||
// keep a package name such as rea-agents@ so a renamed package still counts
|
||||
// as a product change. The executable and path remain product behavior.
|
||||
if (Array.isArray(launch) && typeof launch[1] === "string")
|
||||
launch[1] = launch[1].replace(
|
||||
/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/u,
|
||||
"",
|
||||
);
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ if (await pathExists(join(root, "src"))) {
|
||||
throw new Error("Prepack could not resolve the active package manager");
|
||||
run(process.execPath, [packageManagerPath, "run", "build:cached"]);
|
||||
run(process.execPath, ["scripts/generate-skill-metadata.mjs", "--check"]);
|
||||
run(process.execPath, [
|
||||
"scripts/generate-cursor-plugin-skill.mjs",
|
||||
"--check",
|
||||
]);
|
||||
} else {
|
||||
for (const path of [
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
|
||||
@@ -266,6 +266,39 @@ export async function verifyPackagePack({ root, workspace, commands }) {
|
||||
)
|
||||
throw new Error(`Packaged plugin ${path} is missing its shared icon`);
|
||||
}
|
||||
const cursorManifest = JSON.parse(
|
||||
(
|
||||
await commands.exec("tar", [
|
||||
"-xOf",
|
||||
tarball,
|
||||
"package/.cursor-plugin/plugin.json",
|
||||
])
|
||||
).stdout,
|
||||
);
|
||||
const cursorServer = JSON.parse(
|
||||
(await commands.exec("tar", ["-xOf", tarball, "package/mcp.json"])).stdout,
|
||||
);
|
||||
const cursorLogo = String(cursorManifest.logo ?? "").replace(/^\.\//u, "");
|
||||
const cursorSkill =
|
||||
"package/.cursor-plugin/skills/reverse-engineer-anything/SKILL.md";
|
||||
if (
|
||||
cursorManifest.version !== packedManifest.version ||
|
||||
cursorManifest.skills !== ".cursor-plugin/skills" ||
|
||||
cursorLogo !== "assets/rea-plugin.png" ||
|
||||
cursorManifest.mcpServers !== undefined ||
|
||||
cursorServer.mcpServers?.rea?.command !== "npx" ||
|
||||
JSON.stringify(cursorServer.mcpServers?.rea?.args) !==
|
||||
JSON.stringify([
|
||||
"-y",
|
||||
`${packedManifest.name}@${packedManifest.version}`,
|
||||
"mcp",
|
||||
]) ||
|
||||
!packedFiles.includes(`package/${cursorLogo}`) ||
|
||||
!packedFiles.includes(cursorSkill)
|
||||
)
|
||||
throw new Error(
|
||||
"Packaged Cursor plugin does not ship the pinned server, skill, and logo",
|
||||
);
|
||||
if (packedFiles.includes("package/.mcp.json"))
|
||||
throw new Error("Plugin metadata must not register a project MCP server");
|
||||
if (!packedFiles.includes("package/.codex-plugin/mcp.json"))
|
||||
|
||||
Reference in new issue
Block a user