Files
ai-coding-skills/scripts/lib/skill-manager-core.mjs
T
2026-04-23 21:21:31 -05:00

459 lines
18 KiB
JavaScript

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" };
}