diff --git a/README.md b/README.md index 738e246..36548e1 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,25 @@ ai-coding-skills/ Each skill should explicitly document agent compatibility and any prerequisites directly in its own `SKILL.md`. +## Skill Manager Wizard + +Use the repository skill manager to install, update/reinstall, or remove skills for supported local clients: + +```bash +./scripts/manage-skills.sh +# or +node scripts/manage-skills.mjs +``` + +Useful non-interactive modes: + +```bash +node scripts/manage-skills.mjs --dry-run +node scripts/manage-skills.mjs --plan-only --answers answers.json +``` + +The wizard detects Codex, Claude Code, Cursor, OpenCode, and Pi, previews operations, checks Superpowers dependencies for workflow skills, and prints a final operation report. + ## Pi Package The repo root now includes a pi package manifest that ships only the pi-specific surface: diff --git a/docs/PI.md b/docs/PI.md index 4481a53..7f3feb5 100644 --- a/docs/PI.md +++ b/docs/PI.md @@ -52,7 +52,16 @@ Workflow-heavy Pi skills split their shared setup across two docs: ## Package Install -The user-facing install flow is the repo-owned installer script, not a raw `pi install` command. +The multi-client skill manager can guide Pi install/update/remove operations alongside the other supported clients: + +```bash +./scripts/manage-skills.sh +node scripts/manage-skills.mjs --dry-run +``` + +The compatibility Pi package installer remains available for the focused Pi package path. + +The user-facing Pi package install flow is the repo-owned installer script, not a raw `pi install` command. Global install from a cloned checkout: diff --git a/scripts/lib/skill-manager-core.mjs b/scripts/lib/skill-manager-core.mjs new file mode 100644 index 0000000..452aa16 --- /dev/null +++ b/scripts/lib/skill-manager-core.mjs @@ -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: "/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: "/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), 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" }; +} diff --git a/scripts/manage-skills.mjs b/scripts/manage-skills.mjs new file mode 100755 index 0000000..9159469 --- /dev/null +++ b/scripts/manage-skills.mjs @@ -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 + 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" } + } + ] +} + +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; +}); diff --git a/scripts/manage-skills.sh b/scripts/manage-skills.sh new file mode 100755 index 0000000..cc17cd0 --- /dev/null +++ b/scripts/manage-skills.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec node "$(dirname "$0")/manage-skills.mjs" "$@" diff --git a/scripts/tests/skill-manager-core.test.mjs b/scripts/tests/skill-manager-core.test.mjs new file mode 100644 index 0000000..d8e2167 --- /dev/null +++ b/scripts/tests/skill-manager-core.test.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 }); + } +});