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" };
}