feat(installer): improve cursor and opencode skill handling
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user