feat(installer): improve cursor and opencode skill handling
This commit is contained in:
@@ -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