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" };
|
||||
}
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user