feat(installer): improve cursor and opencode skill handling

This commit is contained in:
Stefano Fiorini
2026-04-24 02:20:06 -05:00
parent d62899308a
commit 193cd45db8
30 changed files with 3832 additions and 64 deletions
+118 -13
View File
@@ -1,4 +1,4 @@
import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink } from "node:fs/promises";
import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink, readdir } from "node:fs/promises";
import { constants, existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
@@ -66,7 +66,7 @@ export const CLIENTS = {
scopes: { global: { skillsRoot: "~/.config/opencode/skills" } },
variant: "opencode",
superpowers: {
roots: ["~/.config/opencode/skills/superpowers"],
roots: ["~/.agents/skills/superpowers", "~/.config/opencode/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
@@ -130,7 +130,7 @@ export const SKILLS = {
},
"web-automation": {
name: "web-automation",
variants: ["codex", "claude-code", "opencode", "pi"],
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: [],
requiresReviewerRuntime: false,
bootstrap: "web-automation",
@@ -138,9 +138,9 @@ export const SKILLS = {
},
};
export function expandHome(value, cwd = process.cwd()) {
export function expandHome(value, cwd = process.cwd(), homeDir = homedir()) {
if (!value) return value;
let expanded = value.replace(/^~(?=$|\/)/, homedir());
let expanded = value.replace(/^~(?=$|\/)/, homeDir);
if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded);
return expanded;
}
@@ -243,14 +243,57 @@ export async function detectInstalledSkills({ skillsRoot, clientId, repoRoot = p
return state;
}
export async function findInstalledSuperpowers(clientId, cwd = process.cwd()) {
export async function findInstalledSuperpowers(clientId, cwd = process.cwd(), { homeDir = homedir() } = {}) {
const client = CLIENTS[clientId];
const found = [];
const found = new Set();
for (const root of client.superpowers.roots) {
const expanded = expandHome(root, cwd);
if (await pathExists(expanded)) found.push(expanded);
const expanded = expandHome(root, cwd, homeDir);
if (await pathExists(expanded)) found.add(expanded);
}
return found;
if (clientId === "claude-code") {
for (const root of await findClaudeCodeSuperpowersPluginRoots(homeDir)) found.add(root);
}
if (clientId === "cursor") {
for (const root of await findCursorSuperpowersPluginRoots(homeDir)) found.add(root);
}
return [...found];
}
async function findClaudeCodeSuperpowersPluginRoots(homeDir) {
const pluginId = "superpowers@claude-plugins-official";
const settings = await readJsonIfExists(path.join(homeDir, ".claude", "settings.json"));
if (settings?.enabledPlugins?.[pluginId] !== true) return [];
const installed = await readJsonIfExists(path.join(homeDir, ".claude", "plugins", "installed_plugins.json"));
const entries = installed?.plugins?.[pluginId];
if (!Array.isArray(entries)) return [];
const roots = [];
for (const entry of entries) {
if (!entry?.installPath) continue;
const skillsRoot = path.join(entry.installPath, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots;
}
async function findCursorSuperpowersPluginRoots(homeDir) {
const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers");
let entries = [];
try {
entries = await readdir(pluginRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const roots = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillsRoot = path.join(pluginRoot, entry.name, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots.sort();
}
function piPackageSettingsPath(scope, repoRoot) {
@@ -286,6 +329,19 @@ export function isBootstrapInstalled(action, target) {
return false;
}
export async function detectHelperInstallState({ source, target, files }) {
let differs = false;
for (const file of files) {
const sourceFile = path.join(source, file);
const targetFile = path.join(target, file);
if (!existsSync(targetFile)) return "not-installed";
if (!existsSync(sourceFile)) return "unknown";
const [sourceContents, targetContents] = await Promise.all([readFile(sourceFile), readFile(targetFile)]);
if (!sourceContents.equals(targetContents)) differs = true;
}
return differs ? "stale" : "installed";
}
export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) {
if (["install", "update", "reinstall"].includes(action)) {
const args = ["install"];
@@ -311,10 +367,36 @@ export function requiredSuperpowersFor(actions) {
return [...required];
}
async function buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action = null }) {
if (action === "skip") return null;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperSource = path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source);
const helperFiles = CLIENTS[clientId].reviewerRuntime.files;
const helperState = await detectHelperInstallState({ source: helperSource, target: helperTarget, files: helperFiles });
const effectiveAction = action || (helperState === "stale" || helperState === "unknown" ? "update" : "install");
const operation = {
kind: "helper",
helper: "reviewer-runtime",
clientId,
scope,
action: effectiveAction,
source: helperSource,
target: helperTarget,
files: helperFiles,
skillsRoot,
};
if (!action && helperState === "installed") {
operation.status = "skipped";
operation.details = "runtime helper already installed";
}
return operation;
}
export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) {
const operations = [];
const prompts = [];
const reportRows = [];
const plannedHelpers = new Set();
for (const selection of selections || []) {
const { clientId, scope = "global" } = selection;
@@ -380,6 +462,7 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
}
const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot });
const actions = selection.actions || {};
const helperActions = selection.helperActions || {};
const missingSuperpowers = requiredSuperpowersFor(actions);
if (missingSuperpowers.length > 0) {
@@ -391,6 +474,16 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
}
}
for (const [helper, action] of Object.entries(helperActions)) {
if (helper !== "reviewer-runtime") continue;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (plannedHelpers.has(helperKey)) continue;
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action });
if (helperOperation) operations.push(helperOperation);
}
for (const [skillName, action] of Object.entries(actions)) {
const source = getSkillSource(skillName, clientId, repoRoot);
if (!source) {
@@ -405,10 +498,15 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
operations.push({ kind: "skill", clientId, scope, skill: skillName, action, source, target, skillsRoot });
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].requiresReviewerRuntime) {
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
operations.push({ kind: "helper", helper: "reviewer-runtime", clientId, scope, action: "install", source: path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source), target: helperTarget, files: CLIENTS[clientId].reviewerRuntime.files, skillsRoot });
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (!plannedHelpers.has(helperKey)) {
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot });
if (helperOperation) operations.push(helperOperation);
}
}
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) {
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, action: SKILLS[skillName].bootstrap, target });
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, item: `${skillName} deps`, action: SKILLS[skillName].bootstrap, displayAction: "bootstrap-deps", target });
}
}
@@ -424,7 +522,7 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
reportRows.push({
client: op.clientId,
scope: op.scope,
item: op.skill || op.helper || op.kind,
item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action,
status: op.status || "planned",
details: op.details || op.target || "",
@@ -520,6 +618,13 @@ export async function executeOperation(op) {
return { ...op, status: "ok" };
}
if (op.kind === "helper") {
if (op.action === "remove") {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
const info = existsSync(op.target) ? await lstat(op.target) : null;
if (info?.isSymbolicLink()) await unlink(op.target);
else await rm(op.target, { recursive: true, force: true });
return { ...op, status: "ok" };
}
await installHelperAllowlist(op);
return { ...op, status: "ok" };
}
+32 -3
View File
@@ -8,6 +8,7 @@ import {
CLIENTS,
SKILLS,
buildOperationPlan,
detectHelperInstallState,
detectInstalledClients,
detectInstalledSkills,
executeOperation,
@@ -42,7 +43,8 @@ Answers JSON example:
{
"clientId": "codex",
"scope": "global",
"actions": { "create-plan": "install", "web-automation": "skip" }
"actions": { "create-plan": "install", "web-automation": "skip" },
"helperActions": { "reviewer-runtime": "skip" }
}
]
}
@@ -168,7 +170,32 @@ async function interactiveAnswers({ dryRun = false } = {}) {
actions[skill] = chosen;
}
}
selections.push({ clientId, scope, actions });
const helperActions = {};
const workflowNeedsReviewerRuntime = Object.entries(actions).some(([skill, action]) => (
["install", "update", "reinstall"].includes(action) && SKILLS[skill]?.requiresReviewerRuntime
));
const helperTarget = reviewerRuntimeRoot(clientId, scopeInfo.skillsRoot, process.cwd());
const helperState = await detectHelperInstallState({
source: path.join(process.cwd(), CLIENTS[clientId].reviewerRuntime.source),
target: helperTarget,
files: CLIENTS[clientId].reviewerRuntime.files,
});
if (workflowNeedsReviewerRuntime || helperState !== "not-installed") {
const choices = helperState === "not-installed" ? "install/skip" : "update/reinstall/remove/skip";
let defaultAction = "skip";
if (workflowNeedsReviewerRuntime && helperState === "not-installed") defaultAction = "install";
if (workflowNeedsReviewerRuntime && ["stale", "unknown"].includes(helperState)) defaultAction = "update";
const answer = await rl.question(`${clientId}/${scope}/reviewer-runtime is ${helperState}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
const allowed = choices.split("/");
if (!allowed.includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
helperActions["reviewer-runtime"] = "skip";
} else {
helperActions["reviewer-runtime"] = chosen;
}
}
selections.push({ clientId, scope, actions, helperActions });
}
return { selections };
} finally {
@@ -212,6 +239,8 @@ async function main() {
}
}
if (plan.operations.length === 0) return;
if (args.dryRun) {
console.log("\nDry-run mode: no filesystem changes performed.");
return;
@@ -275,7 +304,7 @@ async function main() {
const rows = results.map((op) => ({
client: op.clientId,
scope: op.scope,
item: op.skill || op.helper || op.kind,
item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action,
status: op.status,
details: op.details || op.target || "",
+188 -3
View File
@@ -11,6 +11,7 @@ import {
SKILLS,
buildOperationPlan,
detectInstalledSkills,
findInstalledSuperpowers,
getSkillSource,
piPackageCommand,
parseReviewerShorthand,
@@ -20,8 +21,7 @@ import {
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("manifest records supported variants and helper allowlists", () => {
assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "opencode", "pi"]);
assert.equal(SKILLS["web-automation"].variants.includes("cursor"), false);
assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "cursor", "opencode", "pi"]);
assert.deepEqual(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
});
@@ -39,7 +39,7 @@ test("parseReviewerShorthand keeps provider-qualified model ids verbatim", () =>
});
test("unsupported skill variant is reported as unsupported", () => {
assert.equal(getSkillSource("web-automation", "cursor"), null);
assert.ok(getSkillSource("web-automation", "cursor").endsWith("skills/web-automation/cursor"));
assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation"));
});
@@ -75,6 +75,176 @@ test("plan install workflow skill includes reviewer-runtime and missing Superpow
}
});
test("plan skips already current reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-current-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await mkdir(path.join(repo, "skills", "do-task", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await writeFile(path.join(repo, "skills", "do-task", "cursor", "SKILL.md"), "---\nname: do-task\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update", "do-task": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "install");
assert.equal(helperRows[0].status, "skipped");
assert.match(helperRows[0].details, /already installed/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan auto-updates stale reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-stale-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}:new\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}:old\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "update");
assert.equal(helperRows[0].status, "planned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan honors explicit reviewer-runtime helper actions", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-explicit-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: {}, helperActions: { "reviewer-runtime": "reinstall" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action, row.status]), [
["reviewer-runtime", "reinstall", "planned"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan labels skill bootstrap rows as dependency rows", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-bootstrap-label-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "web-automation", "claude-code"), { recursive: true });
await writeFile(path.join(repo, "skills", "web-automation", "claude-code", "SKILL.md"), "---\nname: web-automation\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "claude-code", scope: "global", skillsRoot: install, actions: { "web-automation": "update" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action]), [
["web-automation", "update"],
["web-automation deps", "bootstrap-deps"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Claude Code Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-claude-superpowers-"));
try {
const installPath = path.join(dir, ".claude", "plugins", "cache", "claude-plugins-official", "superpowers", "4.2.0");
await mkdir(path.join(installPath, "skills", "brainstorming"), { recursive: true });
await writeFile(path.join(installPath, "skills", "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
await mkdir(path.join(dir, ".claude", "plugins"), { recursive: true });
await writeFile(path.join(dir, ".claude", "settings.json"), JSON.stringify({
enabledPlugins: {
"superpowers@claude-plugins-official": true,
},
}));
await writeFile(path.join(dir, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({
plugins: {
"superpowers@claude-plugins-official": [
{
scope: "user",
installPath,
version: "4.2.0",
},
],
},
}));
assert.deepEqual(await findInstalledSuperpowers("claude-code", process.cwd(), { homeDir: dir }), [
path.join(installPath, "skills"),
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects OpenCode shared agents Superpowers installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-opencode-superpowers-"));
try {
const sharedRoot = path.join(dir, ".agents", "skills", "superpowers");
await mkdir(path.join(sharedRoot, "brainstorming"), { recursive: true });
await writeFile(path.join(sharedRoot, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("opencode", process.cwd(), { homeDir: dir }), [
sharedRoot,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Cursor Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-cursor-superpowers-"));
try {
const pluginSkills = path.join(dir, ".cursor", "plugins", "cache", "cursor-public", "superpowers", "abc123", "skills");
await mkdir(path.join(pluginSkills, "brainstorming"), { recursive: true });
await writeFile(path.join(pluginSkills, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("cursor", process.cwd(), { homeDir: dir }), [
pluginSkills,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan removing last workflow skill prompts for optional Superpowers removal", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-remove-"));
try {
@@ -230,6 +400,21 @@ test("cli package mode preserves package action and ignores skill narrowing", ()
assert.equal(plan.operations[0].action, "remove");
});
test("cli exits without confirmation when no operations are planned", () => {
const output = execFileSync(process.execPath, [
path.join(REPO_ROOT, "scripts", "manage-skills.mjs"),
"--answers",
"/dev/stdin",
], {
cwd: REPO_ROOT,
encoding: "utf8",
input: JSON.stringify({ selections: [{ clientId: "pi", scope: "packageGlobal", action: "skip", actions: {} }] }),
});
assert.match(output, /No operations planned\./);
assert.doesNotMatch(output, /Proceed with these operations/);
assert.doesNotMatch(output, /Final report/);
});
test("validateRemoveTarget rejects paths outside the manifest root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-"));
try {