329 lines
14 KiB
JavaScript
Executable File
329 lines
14 KiB
JavaScript
Executable File
#!/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 <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" },
|
|
"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;
|
|
});
|