#!/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, detectHelperInstallState, detectInstalledClients, detectInstalledSkills, executeOperation, isPiPackageInstalled, 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 node scripts/manage-skills.mjs --client --scope --skill --action [--yes] Options: --dry-run Prompt or detect normally, but do not modify files. --plan-only Non-interactive mode. Requires --answers and emits JSON. --answers JSON answers file with { "selections": [...] }. --client codex, claude-code, cursor, opencode, or pi. --scope Client scope. Examples: global, local, packageGlobal, packageLocal. --skill Skill to manage. May be repeated. --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 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" }, "helperActions": { "reviewer-runtime": "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 scopeInfo = resolveClientScope(clientId, scope, process.cwd()); if (clientId === "pi" && scopeInfo.packageMode) { return { selections: [{ clientId, scope, action: args.action || "install", actions: {} }] }; } 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.`); const installed = await isPiPackageInstalled({ scope, repoRoot: process.cwd() }); const choices = "install/update/reinstall/remove/skip"; const defaultAction = installed ? "skip" : "install"; const answer = await rl.question(`${clientId}/${scope} package is ${installed ? "installed" : "not-installed"}; action (${choices}) [${defaultAction}]: `); const chosen = answer.trim() || defaultAction; if (!choices.split("/").includes(chosen)) { console.log(`Invalid action '${chosen}', using skip.`); selections.push({ clientId, scope, action: "skip", actions: {} }); } else { selections.push({ clientId, scope, action: chosen, 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; } } 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 { rl.close(); } } async function readAnswers(source) { if (source === "-") { let content = ""; for await (const chunk of input) content += chunk; return JSON.parse(content); } return JSON.parse(await readFile(path.resolve(source), "utf8")); } 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 = await readAnswers(args.answers); } 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 (plan.operations.length === 0) return; 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 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.item || op.skill || op.helper || op.kind, action: op.displayAction || 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; });