mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
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>
217 lines
7.5 KiB
JavaScript
217 lines
7.5 KiB
JavaScript
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 }),
|
|
),
|
|
);
|