feat: add multi-client skill manager
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
import { access, cp, lstat, mkdir, realpath, rm, stat, symlink, chmod, unlink } from "node:fs/promises";
|
||||
import { constants, existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
|
||||
|
||||
export const CLIENTS = {
|
||||
codex: {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
detectCommands: [["codex", "--version"]],
|
||||
scopes: { global: { skillsRoot: "~/.codex/skills" } },
|
||||
variant: "codex",
|
||||
superpowers: {
|
||||
roots: ["~/.agents/skills/superpowers", "~/.codex/superpowers/skills"],
|
||||
layout: "flat",
|
||||
},
|
||||
reviewerRuntime: {
|
||||
root: "~/.codex/skills/reviewer-runtime",
|
||||
source: "skills/reviewer-runtime",
|
||||
files: ["run-review.sh", "notify-telegram.sh"],
|
||||
},
|
||||
},
|
||||
"claude-code": {
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
detectCommands: [["claude", "--version"]],
|
||||
scopes: { global: { skillsRoot: "~/.claude/skills" } },
|
||||
variant: "claude-code",
|
||||
superpowers: {
|
||||
roots: ["~/.claude/skills/superpowers"],
|
||||
layout: "flat",
|
||||
},
|
||||
reviewerRuntime: {
|
||||
root: "~/.claude/skills/reviewer-runtime",
|
||||
source: "skills/reviewer-runtime",
|
||||
files: ["run-review.sh", "notify-telegram.sh"],
|
||||
},
|
||||
},
|
||||
cursor: {
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
detectCommands: [["cursor-agent", "--version"], ["cursor", "agent", "--version"]],
|
||||
scopes: {
|
||||
local: { skillsRoot: ".cursor/skills" },
|
||||
global: { skillsRoot: "~/.cursor/skills" },
|
||||
},
|
||||
variant: "cursor",
|
||||
superpowers: {
|
||||
roots: [".cursor/skills/superpowers/skills", "~/.cursor/skills/superpowers/skills"],
|
||||
layout: "cursor-nested",
|
||||
targetSuffix: "superpowers/skills",
|
||||
},
|
||||
reviewerRuntime: {
|
||||
root: "<skillsRoot>/reviewer-runtime",
|
||||
source: "skills/reviewer-runtime",
|
||||
files: ["run-review.sh", "notify-telegram.sh"],
|
||||
},
|
||||
},
|
||||
opencode: {
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
detectCommands: [["opencode", "--version"]],
|
||||
scopes: { global: { skillsRoot: "~/.config/opencode/skills" } },
|
||||
variant: "opencode",
|
||||
superpowers: {
|
||||
roots: ["~/.config/opencode/skills/superpowers"],
|
||||
layout: "flat",
|
||||
},
|
||||
reviewerRuntime: {
|
||||
root: "~/.config/opencode/skills/reviewer-runtime",
|
||||
source: "skills/reviewer-runtime",
|
||||
files: ["run-review.sh", "notify-telegram.sh"],
|
||||
},
|
||||
},
|
||||
pi: {
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
detectCommands: [["pi", "--version"]],
|
||||
scopes: {
|
||||
packageGlobal: { skillsRoot: "~/.pi/agent/skills", packageMode: true, piInstallArg: "" },
|
||||
packageLocal: { skillsRoot: ".pi/skills", packageMode: true, piInstallArg: "-l" },
|
||||
global: { skillsRoot: "~/.pi/agent/skills" },
|
||||
local: { skillsRoot: ".pi/skills" },
|
||||
},
|
||||
variant: "pi",
|
||||
superpowers: {
|
||||
roots: ["~/.agents/skills/superpowers", "~/.pi/agent/skills/superpowers", ".pi/skills/superpowers"],
|
||||
layout: "flat",
|
||||
},
|
||||
reviewerRuntime: {
|
||||
root: "<skillsRoot>/reviewer-runtime/pi",
|
||||
source: "skills/reviewer-runtime/pi",
|
||||
files: ["run-review.sh", "notify-telegram.sh"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SKILLS = {
|
||||
atlassian: {
|
||||
name: "atlassian",
|
||||
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
|
||||
requiresSuperpowers: [],
|
||||
requiresReviewerRuntime: false,
|
||||
bootstrap: "pnpm-install",
|
||||
bootstrapPrerequisites: ["node>=20", "pnpm|corepack"],
|
||||
auxiliary: { shared: "build-time/shared-not-installed" },
|
||||
},
|
||||
"create-plan": {
|
||||
name: "create-plan",
|
||||
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
|
||||
requiresSuperpowers: ["brainstorming", "writing-plans"],
|
||||
requiresReviewerRuntime: true,
|
||||
},
|
||||
"do-task": {
|
||||
name: "do-task",
|
||||
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
|
||||
requiresSuperpowers: ["brainstorming", "test-driven-development", "verification-before-completion", "finishing-a-development-branch"],
|
||||
conditionalSuperpowers: ["using-git-worktrees"],
|
||||
requiresReviewerRuntime: true,
|
||||
requiresNotifier: true,
|
||||
},
|
||||
"implement-plan": {
|
||||
name: "implement-plan",
|
||||
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
|
||||
requiresSuperpowers: ["executing-plans", "using-git-worktrees", "verification-before-completion", "finishing-a-development-branch"],
|
||||
requiresReviewerRuntime: true,
|
||||
},
|
||||
"web-automation": {
|
||||
name: "web-automation",
|
||||
variants: ["codex", "claude-code", "opencode", "pi"],
|
||||
requiresSuperpowers: [],
|
||||
requiresReviewerRuntime: false,
|
||||
bootstrap: "web-automation",
|
||||
bootstrapPrerequisites: ["node>=20", "pnpm|corepack", "cloakbrowser"],
|
||||
},
|
||||
};
|
||||
|
||||
export function expandHome(value, cwd = process.cwd()) {
|
||||
if (!value) return value;
|
||||
let expanded = value.replace(/^~(?=$|\/)/, homedir());
|
||||
if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded);
|
||||
return expanded;
|
||||
}
|
||||
|
||||
export function resolveClientScope(clientId, scope = "global", cwd = process.cwd()) {
|
||||
const client = CLIENTS[clientId];
|
||||
if (!client) throw new Error(`Unsupported client: ${clientId}`);
|
||||
const scopeConfig = client.scopes[scope];
|
||||
if (!scopeConfig) throw new Error(`Unsupported scope for ${clientId}: ${scope}`);
|
||||
const skillsRoot = expandHome(scopeConfig.skillsRoot, cwd);
|
||||
return { ...scopeConfig, skillsRoot };
|
||||
}
|
||||
|
||||
export function reviewerRuntimeRoot(clientId, skillsRoot, cwd = process.cwd()) {
|
||||
const template = CLIENTS[clientId].reviewerRuntime.root;
|
||||
return expandHome(template.replace("<skillsRoot>", skillsRoot), cwd);
|
||||
}
|
||||
|
||||
export function parseReviewerShorthand(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const slash = value.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
if (value.slice(0, slash) !== "pi") return null;
|
||||
const model = value.slice(slash + 1);
|
||||
if (!model) return null;
|
||||
return { reviewerCli: "pi", reviewerModel: model };
|
||||
}
|
||||
|
||||
export function getSkillSource(skillName, clientId, repoRoot = process.cwd()) {
|
||||
const skill = SKILLS[skillName];
|
||||
const client = CLIENTS[clientId];
|
||||
if (!skill || !client || !skill.variants.includes(client.variant)) return null;
|
||||
if (clientId === "pi") return path.join(repoRoot, "pi-package", "skills", skillName);
|
||||
return path.join(repoRoot, "skills", skillName, client.variant);
|
||||
}
|
||||
|
||||
export async function pathExists(candidate) {
|
||||
try {
|
||||
await access(candidate, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectInstalledClients({ cwd = process.cwd() } = {}) {
|
||||
const result = {};
|
||||
for (const [clientId, client] of Object.entries(CLIENTS)) {
|
||||
let confidence = "not-found";
|
||||
let detail = "not detected";
|
||||
for (const command of client.detectCommands) {
|
||||
const proc = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
|
||||
if (proc.status === 0) {
|
||||
confidence = "cli";
|
||||
detail = (proc.stdout || proc.stderr || "detected").trim().split("\n")[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confidence === "not-found") {
|
||||
for (const scopeName of Object.keys(client.scopes)) {
|
||||
const { skillsRoot } = resolveClientScope(clientId, scopeName, cwd);
|
||||
if (await pathExists(skillsRoot)) {
|
||||
confidence = "directory";
|
||||
detail = skillsRoot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result[clientId] = { clientId, label: client.label, confidence, detail };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function detectInstalledSkills({ skillsRoot, clientId, repoRoot = process.cwd() }) {
|
||||
const state = {};
|
||||
for (const skillName of Object.keys(SKILLS)) {
|
||||
if (getSkillSource(skillName, clientId) === null) {
|
||||
state[skillName] = { state: "unsupported", path: null };
|
||||
continue;
|
||||
}
|
||||
const target = path.join(skillsRoot, skillName);
|
||||
const skillMd = path.join(target, "SKILL.md");
|
||||
let installState = "not-installed";
|
||||
if (existsSync(skillMd)) {
|
||||
installState = "installed";
|
||||
const source = getSkillSource(skillName, clientId, repoRoot);
|
||||
const sourceSkill = source && path.join(source, "SKILL.md");
|
||||
if (sourceSkill && existsSync(sourceSkill)) {
|
||||
const [targetStat, sourceStat] = await Promise.all([stat(skillMd), stat(sourceSkill)]);
|
||||
if (sourceStat.mtimeMs > targetStat.mtimeMs + 1000) installState = "stale";
|
||||
} else {
|
||||
installState = "unknown";
|
||||
}
|
||||
}
|
||||
state[skillName] = {
|
||||
state: installState,
|
||||
path: target,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function findInstalledSuperpowers(clientId, cwd = process.cwd()) {
|
||||
const client = CLIENTS[clientId];
|
||||
const found = [];
|
||||
for (const root of client.superpowers.roots) {
|
||||
const expanded = expandHome(root, cwd);
|
||||
if (await pathExists(expanded)) found.push(expanded);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function requiredSuperpowersFor(actions) {
|
||||
const required = new Set();
|
||||
for (const [skillName, action] of Object.entries(actions || {})) {
|
||||
if (!["install", "update", "reinstall"].includes(action)) continue;
|
||||
for (const skill of SKILLS[skillName]?.requiresSuperpowers || []) required.add(skill);
|
||||
}
|
||||
return [...required];
|
||||
}
|
||||
|
||||
export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) {
|
||||
const operations = [];
|
||||
const prompts = [];
|
||||
const reportRows = [];
|
||||
|
||||
for (const selection of selections || []) {
|
||||
const { clientId, scope = "global" } = selection;
|
||||
const client = CLIENTS[clientId];
|
||||
if (!client) throw new Error(`Unsupported client: ${clientId}`);
|
||||
const scopeInfo = selection.skillsRoot ? { skillsRoot: selection.skillsRoot } : resolveClientScope(clientId, scope, repoRoot);
|
||||
const skillsRoot = scopeInfo.skillsRoot;
|
||||
if (clientId === "pi" && scopeInfo.packageMode) {
|
||||
operations.push({ kind: "pi-package", clientId, scope, action: selection.action || "install", repoRoot, piInstallArg: scopeInfo.piInstallArg || "" });
|
||||
operations.push({ kind: "bootstrap", clientId, scope, skill: "atlassian", action: "pnpm-install", target: path.join(repoRoot, "pi-package", "skills", "atlassian") });
|
||||
operations.push({ kind: "bootstrap", clientId, scope, skill: "web-automation", action: "web-automation", target: path.join(repoRoot, "pi-package", "skills", "web-automation") });
|
||||
continue;
|
||||
}
|
||||
const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot });
|
||||
const actions = selection.actions || {};
|
||||
const missingSuperpowers = requiredSuperpowersFor(actions);
|
||||
|
||||
if (missingSuperpowers.length > 0) {
|
||||
const installedSuperpowers = superpowersByClient && Object.hasOwn(superpowersByClient, clientId)
|
||||
? superpowersByClient[clientId]
|
||||
: await findInstalledSuperpowers(clientId, repoRoot);
|
||||
if (installedSuperpowers.length === 0) {
|
||||
prompts.push({ kind: "missing-superpowers", clientId, scope, required: missingSuperpowers });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [skillName, action] of Object.entries(actions)) {
|
||||
const source = getSkillSource(skillName, clientId, repoRoot);
|
||||
if (!source) {
|
||||
operations.push({ kind: "skill", clientId, scope, skill: skillName, action: "unsupported", status: "skipped", details: "variant unavailable" });
|
||||
continue;
|
||||
}
|
||||
if (action === "skip") continue;
|
||||
const target = path.join(skillsRoot, skillName);
|
||||
if (clientId === "pi" && !operations.some((op) => op.kind === "sync-pi-package")) {
|
||||
operations.push({ kind: "sync-pi-package", clientId, scope, action: "sync", repoRoot });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) {
|
||||
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, action: SKILLS[skillName].bootstrap, target });
|
||||
}
|
||||
}
|
||||
|
||||
const workflowSkills = Object.keys(SKILLS).filter((skillName) => (SKILLS[skillName].requiresSuperpowers || []).length > 0);
|
||||
const removing = workflowSkills.filter((skillName) => actions[skillName] === "remove");
|
||||
if (removing.length > 0) {
|
||||
const remaining = workflowSkills.some((skillName) => actions[skillName] !== "remove" && installed[skillName]?.state === "installed");
|
||||
if (!remaining) prompts.push({ kind: "remove-superpowers", clientId, scope, removedWorkflowSkills: removing });
|
||||
}
|
||||
}
|
||||
|
||||
for (const op of operations) {
|
||||
reportRows.push({
|
||||
client: op.clientId,
|
||||
scope: op.scope,
|
||||
item: op.skill || op.helper || op.kind,
|
||||
action: op.action,
|
||||
status: op.status || "planned",
|
||||
details: op.details || op.target || "",
|
||||
});
|
||||
}
|
||||
|
||||
return { operations, prompts, reportRows, assumeYes };
|
||||
}
|
||||
|
||||
export async function validateRemoveTarget(target, skillsRoot, { repoRoot = process.cwd() } = {}) {
|
||||
const resolvedRoot = path.resolve(skillsRoot);
|
||||
const resolvedTarget = path.resolve(target);
|
||||
const home = path.resolve(homedir());
|
||||
const resolvedRepo = path.resolve(repoRoot);
|
||||
if (resolvedTarget === resolvedRoot) throw new Error("refusing to remove skills root itself");
|
||||
if (resolvedTarget === home) throw new Error("refusing to remove home directory");
|
||||
if (resolvedTarget === resolvedRepo) throw new Error("refusing to remove repository root");
|
||||
if (resolvedTarget === path.parse(resolvedTarget).root) throw new Error("refusing to remove filesystem root");
|
||||
const relative = path.relative(resolvedRoot, resolvedTarget);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative) || relative === "") {
|
||||
throw new Error(`refusing to remove target outside skills root: ${target}`);
|
||||
}
|
||||
try {
|
||||
const info = await lstat(resolvedTarget);
|
||||
if (!info.isSymbolicLink()) {
|
||||
const [realRoot, realTarget] = await Promise.all([realpath(resolvedRoot), realpath(resolvedTarget)]);
|
||||
const realRelative = path.relative(realRoot, realTarget);
|
||||
if (realRelative.startsWith("..") || path.isAbsolute(realRelative) || realRelative === "") {
|
||||
throw new Error(`refusing to remove real target outside skills root: ${target}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.code !== "ENOENT") throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function copyDirectoryReplacing(source, target) {
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await rm(target, { recursive: true, force: true });
|
||||
await cp(source, target, { recursive: true, force: true, dereference: false });
|
||||
}
|
||||
|
||||
export async function installHelperAllowlist({ source, target, files }) {
|
||||
await mkdir(target, { recursive: true });
|
||||
for (const file of files) {
|
||||
await cp(path.join(source, file), path.join(target, file), { force: true });
|
||||
if (file.endsWith(".sh")) await chmod(path.join(target, file), 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { encoding: "utf8", stdio: "pipe", ...options });
|
||||
if (result.status !== 0) {
|
||||
const detail = (result.stderr || result.stdout || `${command} exited ${result.status}`).trim();
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (result.stdout || result.stderr || "ok").trim();
|
||||
}
|
||||
|
||||
function resolvePnpmCommand() {
|
||||
if (spawnSync("pnpm", ["--version"], { encoding: "utf8" }).status === 0) return ["pnpm"];
|
||||
if (spawnSync("corepack", ["--version"], { encoding: "utf8" }).status === 0) return ["corepack", "pnpm"];
|
||||
throw new Error("missing prerequisite: pnpm or corepack");
|
||||
}
|
||||
|
||||
function requireNode20() {
|
||||
const major = Number.parseInt(process.versions.node.split(".")[0], 10);
|
||||
if (major < 20) throw new Error(`missing prerequisite: Node.js 20+ (found ${process.version})`);
|
||||
}
|
||||
|
||||
export async function executeOperation(op) {
|
||||
if (op.action === "unsupported" || op.status === "skipped") return { ...op, status: "skipped" };
|
||||
if (op.kind === "sync-pi-package") {
|
||||
runCommand(path.join(op.repoRoot, "scripts", "sync-pi-package-skills.sh"), [], { cwd: op.repoRoot });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "pi-package") {
|
||||
if (op.action !== "install" && op.action !== "update" && op.action !== "reinstall") {
|
||||
throw new Error(`pi package mode does not support action: ${op.action}`);
|
||||
}
|
||||
const args = ["install"];
|
||||
if (op.piInstallArg) args.push(op.piInstallArg);
|
||||
args.push(op.repoRoot);
|
||||
runCommand("pi", args, { cwd: op.repoRoot });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "skill") {
|
||||
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 copyDirectoryReplacing(op.source, op.target);
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "helper") {
|
||||
await installHelperAllowlist(op);
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "superpowers") {
|
||||
await mkdir(path.dirname(op.target), { recursive: true });
|
||||
if (op.action === "remove") {
|
||||
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
|
||||
await rm(op.target, { recursive: true, force: true });
|
||||
} else if (op.mode === "copy") {
|
||||
await copyDirectoryReplacing(op.source, op.target);
|
||||
} else {
|
||||
await rm(op.target, { recursive: true, force: true });
|
||||
await symlink(op.source, op.target, "dir");
|
||||
}
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "bootstrap") {
|
||||
requireNode20();
|
||||
const pnpm = resolvePnpmCommand();
|
||||
const scriptsDir = path.join(op.target, "scripts");
|
||||
if (op.action === "pnpm-install") {
|
||||
runCommand(pnpm[0], [...pnpm.slice(1), "install", "--frozen-lockfile", "--dir", scriptsDir]);
|
||||
} else if (op.action === "web-automation") {
|
||||
runCommand(pnpm[0], [...pnpm.slice(1), "install", "--frozen-lockfile", "--dir", scriptsDir]);
|
||||
runCommand(pnpm[0], [...pnpm.slice(1), "--dir", scriptsDir, "exec", "cloakbrowser", "install"]);
|
||||
runCommand(pnpm[0], [...pnpm.slice(1), "rebuild", "--dir", scriptsDir, "better-sqlite3", "esbuild"]);
|
||||
}
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
return { ...op, status: "warning", details: "operation is planned/manual" };
|
||||
}
|
||||
Executable
+276
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
CLIENTS,
|
||||
SKILLS,
|
||||
buildOperationPlan,
|
||||
detectInstalledClients,
|
||||
detectInstalledSkills,
|
||||
executeOperation,
|
||||
parseReviewerShorthand,
|
||||
resolveClientScope,
|
||||
reviewerRuntimeRoot,
|
||||
} from "./lib/skill-manager-core.mjs";
|
||||
|
||||
function usage() {
|
||||
return `Usage:
|
||||
node scripts/manage-skills.mjs [--dry-run]
|
||||
node scripts/manage-skills.mjs --plan-only --answers <answers.json>
|
||||
node scripts/manage-skills.mjs --client <client> --scope <scope> --skill <skill> --action <install|update|reinstall|remove|skip> [--yes]
|
||||
|
||||
Options:
|
||||
--dry-run Prompt or detect normally, but do not modify files.
|
||||
--plan-only Non-interactive mode. Requires --answers and emits JSON.
|
||||
--answers <file> JSON answers file with { "selections": [...] }.
|
||||
--client <client> codex, claude-code, cursor, opencode, or pi.
|
||||
--scope <scope> Client scope. Examples: global, local, packageGlobal, packageLocal.
|
||||
--skill <skill> Skill to manage. May be repeated.
|
||||
--action <action> Action for --skill entries. Defaults to install.
|
||||
--yes Execute planned operations without final confirmation.
|
||||
--pi-package With --client pi, default to packageGlobal scope (full bundle).
|
||||
--reviewer <value> Parse/display reviewer shorthand, e.g. pi/claude-opus-4-7.
|
||||
-h, --help Show this help.
|
||||
|
||||
Answers JSON example:
|
||||
{
|
||||
"selections": [
|
||||
{
|
||||
"clientId": "codex",
|
||||
"scope": "global",
|
||||
"actions": { "create-plan": "install", "web-automation": "skip" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Final report columns: client, scope, skill/helper, action, status, details.
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { skills: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--dry-run": args.dryRun = true; break;
|
||||
case "--plan-only": args.planOnly = true; break;
|
||||
case "--answers": args.answers = argv[++i]; break;
|
||||
case "--client": args.client = argv[++i]; break;
|
||||
case "--scope": args.scope = argv[++i]; break;
|
||||
case "--skill": args.skills.push(argv[++i]); break;
|
||||
case "--action": args.action = argv[++i]; break;
|
||||
case "--yes": args.yes = true; break;
|
||||
case "--pi-package": args.piPackage = true; break;
|
||||
case "--reviewer": args.reviewer = argv[++i]; break;
|
||||
case "-h":
|
||||
case "--help": args.help = true; break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
if (!rows.length) return "No operations planned.";
|
||||
const headers = ["client", "scope", "skill/helper", "action", "status", "details"];
|
||||
const data = rows.map((row) => [row.client, row.scope, row.item, row.action, row.status, row.details]);
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...data.map((row) => String(row[index] ?? "").length)));
|
||||
const format = (row) => row.map((cell, index) => String(cell ?? "").padEnd(widths[index])).join(" ");
|
||||
return [format(headers), format(widths.map((width) => "-".repeat(width))), ...data.map(format)].join("\n");
|
||||
}
|
||||
|
||||
async function answersToPlan(answers, { assumeYes = false } = {}) {
|
||||
const plan = await buildOperationPlan({ selections: answers.selections || [], assumeYes, repoRoot: process.cwd(), superpowersByClient: answers.superpowersByClient });
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function buildCliSelection(args) {
|
||||
if (!args.client) return null;
|
||||
const clientId = args.client;
|
||||
if (!CLIENTS[clientId]) throw new Error(`Unsupported client: ${clientId}`);
|
||||
const scope = args.scope || (clientId === "pi" && args.piPackage ? "packageGlobal" : "global");
|
||||
const skills = args.skills.length ? args.skills : Object.keys(SKILLS);
|
||||
const action = args.action || "install";
|
||||
const actions = Object.fromEntries(skills.map((skill) => [skill, action]));
|
||||
return { selections: [{ clientId, scope, actions }] };
|
||||
}
|
||||
|
||||
async function interactiveAnswers({ dryRun = false } = {}) {
|
||||
const detected = await detectInstalledClients();
|
||||
console.log("Detected supported clients:");
|
||||
for (const info of Object.values(detected)) {
|
||||
console.log(`- ${info.clientId}: ${info.confidence} (${info.detail})`);
|
||||
}
|
||||
if (dryRun && !input.isTTY) {
|
||||
return { selections: [] };
|
||||
}
|
||||
const rl = readline.createInterface({ input, output });
|
||||
try {
|
||||
const clientLine = await rl.question("Clients to manage (comma-separated, blank for detected CLI clients): ");
|
||||
const chosenClients = clientLine.trim()
|
||||
? clientLine.split(",").map((value) => value.trim()).filter(Boolean)
|
||||
: Object.values(detected).filter((info) => info.confidence !== "not-found").map((info) => info.clientId);
|
||||
const selections = [];
|
||||
for (const clientId of chosenClients) {
|
||||
if (!CLIENTS[clientId]) {
|
||||
console.log(`Skipping unsupported client: ${clientId}`);
|
||||
continue;
|
||||
}
|
||||
const scopeNames = Object.keys(CLIENTS[clientId].scopes);
|
||||
let scope = scopeNames[0];
|
||||
if (scopeNames.length > 1) {
|
||||
const answer = await rl.question(`${clientId} scope (${scopeNames.join("/")}) [${scope}]: `);
|
||||
if (answer.trim()) scope = answer.trim();
|
||||
}
|
||||
const scopeInfo = resolveClientScope(clientId, scope, process.cwd());
|
||||
if (scopeInfo.packageMode) {
|
||||
console.log(`${clientId}/${scope}: package mode manages the full Pi package bundle; per-skill prompts are skipped.`);
|
||||
selections.push({ clientId, scope, action: "install", actions: {} });
|
||||
continue;
|
||||
}
|
||||
const installed = await detectInstalledSkills({ clientId, skillsRoot: scopeInfo.skillsRoot });
|
||||
const actions = {};
|
||||
for (const skill of Object.keys(SKILLS)) {
|
||||
const state = installed[skill]?.state || "unsupported";
|
||||
if (state === "unsupported") {
|
||||
console.log(`${clientId}/${scope}/${skill}: unsupported`);
|
||||
actions[skill] = "skip";
|
||||
continue;
|
||||
}
|
||||
const choices = state === "installed" || state === "stale" || state === "unknown" ? "update/reinstall/remove/skip" : "install/skip";
|
||||
const defaultAction = "skip";
|
||||
const answer = await rl.question(`${clientId}/${scope}/${skill} is ${state}; action (${choices}) [${defaultAction}]: `);
|
||||
const chosen = answer.trim() || defaultAction;
|
||||
const allowed = choices.split("/");
|
||||
if (!allowed.includes(chosen)) {
|
||||
console.log(`Invalid action '${chosen}', using skip.`);
|
||||
actions[skill] = "skip";
|
||||
} else {
|
||||
actions[skill] = chosen;
|
||||
}
|
||||
}
|
||||
selections.push({ clientId, scope, actions });
|
||||
}
|
||||
return { selections };
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
if (args.reviewer) {
|
||||
console.log(JSON.stringify(parseReviewerShorthand(args.reviewer), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
let answers;
|
||||
if (args.answers) {
|
||||
answers = JSON.parse(await readFile(path.resolve(args.answers), "utf8"));
|
||||
} else {
|
||||
answers = await buildCliSelection(args);
|
||||
}
|
||||
if (!answers) answers = await interactiveAnswers({ dryRun: args.dryRun });
|
||||
|
||||
const plan = await answersToPlan(answers, { assumeYes: args.yes });
|
||||
|
||||
if (args.planOnly) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Operation preview:");
|
||||
console.log(renderTable(plan.reportRows));
|
||||
if (plan.prompts.length) {
|
||||
console.log("\nDependency prompts:");
|
||||
for (const prompt of plan.prompts) {
|
||||
const promptDetail = prompt.required ? prompt.required.join(",") : (prompt.removedWorkflowSkills ? prompt.removedWorkflowSkills.join(",") : "");
|
||||
console.log(`- ${prompt.kind}: ${prompt.clientId}/${prompt.scope} ${promptDetail}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log("\nDry-run mode: no filesystem changes performed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.prompts.length && input.isTTY && !args.yes) {
|
||||
const rl = readline.createInterface({ input, output });
|
||||
try {
|
||||
for (const prompt of plan.prompts) {
|
||||
if (prompt.kind === "missing-superpowers") {
|
||||
const install = await rl.question(`Install/link Superpowers for ${prompt.clientId}/${prompt.scope}? (yes/no) [no]: `);
|
||||
if (install.trim().toLowerCase() === "yes") {
|
||||
const source = await rl.question("Absolute path to Obra Superpowers skills directory: ");
|
||||
const modeAnswer = await rl.question("Install mode (symlink/copy) [symlink]: ");
|
||||
const client = CLIENTS[prompt.clientId];
|
||||
const scope = resolveClientScope(prompt.clientId, prompt.scope, process.cwd());
|
||||
const target = client.superpowers.layout === "cursor-nested"
|
||||
? `${scope.skillsRoot}/superpowers/skills`
|
||||
: `${scope.skillsRoot}/superpowers`;
|
||||
plan.operations.push({ kind: "superpowers", clientId: prompt.clientId, scope: prompt.scope, action: "install", source: source.trim(), target, skillsRoot: scope.skillsRoot, mode: modeAnswer.trim() === "copy" ? "copy" : "symlink" });
|
||||
}
|
||||
}
|
||||
if (prompt.kind === "remove-superpowers") {
|
||||
const removeAnswer = await rl.question(`Remove Superpowers for ${prompt.clientId}/${prompt.scope} too? (yes/no) [no]: `);
|
||||
if (removeAnswer.trim().toLowerCase() === "yes") {
|
||||
const scope = resolveClientScope(prompt.clientId, prompt.scope, process.cwd());
|
||||
const client = CLIENTS[prompt.clientId];
|
||||
const target = `${scope.skillsRoot}/superpowers`;
|
||||
plan.operations.push({ kind: "superpowers", clientId: prompt.clientId, scope: prompt.scope, action: "remove", target, skillsRoot: scope.skillsRoot });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.yes) {
|
||||
if (!input.isTTY) {
|
||||
console.log("Refusing to execute without --yes in non-interactive mode.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const answer = await rl.question("Proceed with these operations? (yes/no): ");
|
||||
rl.close();
|
||||
if (answer.trim().toLowerCase() !== "yes") {
|
||||
console.log("Skipped by user.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const operation of plan.operations) {
|
||||
try {
|
||||
results.push(await executeOperation(operation));
|
||||
} catch (error) {
|
||||
results.push({ ...operation, status: "failed", details: error.message });
|
||||
}
|
||||
}
|
||||
const rows = results.map((op) => ({
|
||||
client: op.clientId,
|
||||
scope: op.scope,
|
||||
item: op.skill || op.helper || op.kind,
|
||||
action: op.action,
|
||||
status: op.status,
|
||||
details: op.details || op.target || "",
|
||||
}));
|
||||
console.log("\nFinal report:");
|
||||
console.log(renderTable(rows));
|
||||
if (results.some((result) => result.status === "failed")) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec node "$(dirname "$0")/manage-skills.mjs" "$@"
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CLIENTS,
|
||||
SKILLS,
|
||||
buildOperationPlan,
|
||||
detectInstalledSkills,
|
||||
getSkillSource,
|
||||
parseReviewerShorthand,
|
||||
validateRemoveTarget,
|
||||
} from "../lib/skill-manager-core.mjs";
|
||||
|
||||
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(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
|
||||
assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
|
||||
});
|
||||
|
||||
test("parseReviewerShorthand keeps provider-qualified model ids verbatim", () => {
|
||||
assert.deepEqual(parseReviewerShorthand("pi/claude-opus-4-7"), {
|
||||
reviewerCli: "pi",
|
||||
reviewerModel: "claude-opus-4-7",
|
||||
});
|
||||
assert.deepEqual(parseReviewerShorthand("pi/anthropic/claude-opus-4-7"), {
|
||||
reviewerCli: "pi",
|
||||
reviewerModel: "anthropic/claude-opus-4-7",
|
||||
});
|
||||
assert.equal(parseReviewerShorthand("codex/gpt-5"), null);
|
||||
});
|
||||
|
||||
test("unsupported skill variant is reported as unsupported", () => {
|
||||
assert.equal(getSkillSource("web-automation", "cursor"), null);
|
||||
assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation"));
|
||||
});
|
||||
|
||||
test("detectInstalledSkills reports installed and missing skills", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-detect-"));
|
||||
try {
|
||||
await mkdir(path.join(dir, "skills", "create-plan"), { recursive: true });
|
||||
await writeFile(path.join(dir, "skills", "create-plan", "SKILL.md"), "---\nname: create-plan\n---\n");
|
||||
const state = await detectInstalledSkills({ skillsRoot: path.join(dir, "skills"), clientId: "codex" });
|
||||
assert.equal(state["create-plan"].state, "installed");
|
||||
assert.equal(state["web-automation"].state, "not-installed");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("plan install workflow skill includes reviewer-runtime and missing Superpowers prompt", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-plan-"));
|
||||
try {
|
||||
await mkdir(path.join(dir, "repo", "skills", "create-plan", "codex"), { recursive: true });
|
||||
await writeFile(path.join(dir, "repo", "skills", "create-plan", "codex", "SKILL.md"), "---\nname: create-plan\n---\n");
|
||||
const plan = await buildOperationPlan({
|
||||
selections: [{ clientId: "codex", scope: "global", skillsRoot: path.join(dir, "install"), actions: { "create-plan": "install" } }],
|
||||
assumeYes: true,
|
||||
repoRoot: path.join(dir, "repo"),
|
||||
superpowersByClient: { codex: [] },
|
||||
});
|
||||
assert.equal(plan.operations.some((op) => op.kind === "skill" && op.skill === "create-plan" && op.action === "install"), true);
|
||||
assert.equal(plan.operations.some((op) => op.kind === "helper" && op.helper === "reviewer-runtime"), true);
|
||||
assert.equal(plan.prompts.some((prompt) => prompt.kind === "missing-superpowers" && prompt.clientId === "codex"), true);
|
||||
} 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 {
|
||||
await mkdir(path.join(dir, "skills", "do-task"), { recursive: true });
|
||||
await writeFile(path.join(dir, "skills", "do-task", "SKILL.md"), "---\nname: do-task\n---\n");
|
||||
const plan = await buildOperationPlan({
|
||||
selections: [{ clientId: "codex", scope: "global", skillsRoot: path.join(dir, "skills"), actions: { "do-task": "remove" } }],
|
||||
assumeYes: true,
|
||||
repoRoot: process.cwd(),
|
||||
});
|
||||
assert.equal(plan.prompts.some((prompt) => prompt.kind === "remove-superpowers" && prompt.clientId === "codex"), true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pi package mode plans full package install instead of per-skill copy", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-"));
|
||||
try {
|
||||
const plan = await buildOperationPlan({
|
||||
selections: [{ clientId: "pi", scope: "packageLocal", action: "install", actions: { "create-plan": "skip", atlassian: "remove" } }],
|
||||
repoRoot: dir,
|
||||
});
|
||||
assert.equal(plan.operations.some((op) => op.kind === "pi-package" && op.piInstallArg === "-l"), true);
|
||||
assert.equal(plan.operations.some((op) => op.kind === "skill"), false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("validateRemoveTarget rejects paths outside the manifest root", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-"));
|
||||
try {
|
||||
await mkdir(path.join(dir, "skills", "create-plan"), { recursive: true });
|
||||
await mkdir(path.join(dir, "outside"), { recursive: true });
|
||||
assert.equal(await validateRemoveTarget(path.join(dir, "skills", "create-plan"), path.join(dir, "skills")), true);
|
||||
await assert.rejects(() => validateRemoveTarget(path.join(dir, "outside"), path.join(dir, "skills")), /outside skills root/);
|
||||
await assert.rejects(() => validateRemoveTarget(dir, dir), /refusing to remove skills root itself/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user