251148c3ff
## Summary - add repository-wide quality tooling and verification scaffolding, including CI workflows, pnpm workspace setup, ESLint/Prettier/markdown checks, and generated-output verification helpers - reorganize skill sources and generation flow by introducing canonical `_source` variants, generator/manifests, reusable helper abstractions, and shared web-automation/browser utilities - clean up and expand documentation so the root README flows into docs and skill docs, with clearer development, reviewer, installer, and workflow guidance ## Notable changes - docs flow and consistency cleanup across `README.md`, `docs/README.md`, and related docs - new scripts for `check`, docs verification, generated-file verification, shell portability, and safe directory replacement - refactors in Atlassian and web-automation skill runtimes to reduce duplication and centralize reusable code - changelog, development documentation, and CI surface updates ## Test Plan - [ ] `pnpm run check` - [ ] review generated/manifests and skill sync outputs - [ ] smoke-check docs flow from `README.md` to `docs/README.md` to skill docs ## Notes - this branch currently includes tracked `skills/web-automation/shared/node_modules` content that should be reviewed carefully as potentially noisy/accidental committed artifacts Co-authored-by: Stefano Fiorini <stefano.fiorini@firsthorizon.com> Reviewed-on: #1
664 lines
26 KiB
JavaScript
664 lines
26 KiB
JavaScript
import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink, readdir } 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: ["~/.agents/skills/superpowers", "~/.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", "cursor", "opencode", "pi"],
|
|
requiresSuperpowers: [],
|
|
requiresReviewerRuntime: false,
|
|
bootstrap: "web-automation",
|
|
bootstrapPrerequisites: ["node>=20", "pnpm|corepack", "cloakbrowser"],
|
|
},
|
|
};
|
|
|
|
export function expandHome(value, cwd = process.cwd(), homeDir = homedir()) {
|
|
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(), { homeDir = homedir() } = {}) {
|
|
const client = CLIENTS[clientId];
|
|
const found = new Set();
|
|
for (const root of client.superpowers.roots) {
|
|
const expanded = expandHome(root, cwd, homeDir);
|
|
if (await pathExists(expanded)) found.add(expanded);
|
|
}
|
|
if (clientId === "claude-code") {
|
|
for (const root of await findClaudeCodeSuperpowersPluginRoots(homeDir)) found.add(root);
|
|
}
|
|
if (clientId === "cursor") {
|
|
for (const root of await findCursorSuperpowersPluginRoots(homeDir)) found.add(root);
|
|
}
|
|
return [...found];
|
|
}
|
|
|
|
async function findClaudeCodeSuperpowersPluginRoots(homeDir) {
|
|
const pluginId = "superpowers@claude-plugins-official";
|
|
const settings = await readJsonIfExists(path.join(homeDir, ".claude", "settings.json"));
|
|
if (settings?.enabledPlugins?.[pluginId] !== true) return [];
|
|
|
|
const installed = await readJsonIfExists(path.join(homeDir, ".claude", "plugins", "installed_plugins.json"));
|
|
const entries = installed?.plugins?.[pluginId];
|
|
if (!Array.isArray(entries)) return [];
|
|
|
|
const roots = [];
|
|
for (const entry of entries) {
|
|
if (!entry?.installPath) continue;
|
|
const skillsRoot = path.join(entry.installPath, "skills");
|
|
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
async function findCursorSuperpowersPluginRoots(homeDir) {
|
|
const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers");
|
|
let entries;
|
|
try {
|
|
entries = await readdir(pluginRoot, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return [];
|
|
throw error;
|
|
}
|
|
|
|
const roots = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillsRoot = path.join(pluginRoot, entry.name, "skills");
|
|
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
|
|
}
|
|
return roots.sort();
|
|
}
|
|
|
|
function piPackageSettingsPath(scope, repoRoot) {
|
|
if (scope === "packageLocal") return path.join(repoRoot, ".pi", "settings.json");
|
|
return path.join(homedir(), ".pi", "agent", "settings.json");
|
|
}
|
|
|
|
async function readJsonIfExists(filePath) {
|
|
try {
|
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function isPiPackageInstalled({ scope, repoRoot }) {
|
|
const settingsPath = piPackageSettingsPath(scope, repoRoot);
|
|
const settings = await readJsonIfExists(settingsPath);
|
|
if (!Array.isArray(settings?.packages)) return false;
|
|
const settingsDir = path.dirname(settingsPath);
|
|
const expected = path.resolve(repoRoot);
|
|
return settings.packages.some((packagePath) => path.resolve(settingsDir, packagePath) === expected);
|
|
}
|
|
|
|
export function isBootstrapInstalled(action, target) {
|
|
const scriptsDir = path.join(target, "scripts");
|
|
if (action === "pnpm-install") return existsSync(path.join(scriptsDir, "node_modules"));
|
|
if (action === "web-automation") {
|
|
return existsSync(path.join(scriptsDir, "node_modules"))
|
|
&& existsSync(path.join(scriptsDir, "node_modules", ".bin", "cloakbrowser"));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function detectHelperInstallState({ source, target, files }) {
|
|
let differs = false;
|
|
for (const file of files) {
|
|
const sourceFile = path.join(source, file);
|
|
const targetFile = path.join(target, file);
|
|
if (!existsSync(targetFile)) return "not-installed";
|
|
if (!existsSync(sourceFile)) return "unknown";
|
|
const [sourceContents, targetContents] = await Promise.all([readFile(sourceFile), readFile(targetFile)]);
|
|
if (!sourceContents.equals(targetContents)) differs = true;
|
|
}
|
|
return differs ? "stale" : "installed";
|
|
}
|
|
|
|
export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) {
|
|
if (["install", "update", "reinstall"].includes(action)) {
|
|
const args = ["install"];
|
|
if (piInstallArg) args.push(piInstallArg);
|
|
args.push(repoRoot);
|
|
return ["pi", args];
|
|
}
|
|
if (action === "remove") {
|
|
const args = ["remove"];
|
|
if (piInstallArg) args.push(piInstallArg);
|
|
args.push(repoRoot);
|
|
return ["pi", args];
|
|
}
|
|
throw new Error(`pi package mode does not support action: ${action}`);
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
async function buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action = null }) {
|
|
if (action === "skip") return null;
|
|
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
|
|
const helperSource = path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source);
|
|
const helperFiles = CLIENTS[clientId].reviewerRuntime.files;
|
|
const helperState = await detectHelperInstallState({ source: helperSource, target: helperTarget, files: helperFiles });
|
|
const effectiveAction = action || (helperState === "stale" || helperState === "unknown" ? "update" : "install");
|
|
const operation = {
|
|
kind: "helper",
|
|
helper: "reviewer-runtime",
|
|
clientId,
|
|
scope,
|
|
action: effectiveAction,
|
|
source: helperSource,
|
|
target: helperTarget,
|
|
files: helperFiles,
|
|
skillsRoot,
|
|
};
|
|
if (!action && helperState === "installed") {
|
|
operation.status = "skipped";
|
|
operation.details = "runtime helper already installed";
|
|
}
|
|
return operation;
|
|
}
|
|
|
|
export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) {
|
|
const operations = [];
|
|
const prompts = [];
|
|
const reportRows = [];
|
|
const plannedHelpers = new Set();
|
|
|
|
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) {
|
|
const action = selection.action || "install";
|
|
if (action === "skip") continue;
|
|
if (["update", "reinstall"].includes(action)) {
|
|
operations.push({ kind: "sync-pi-package", clientId, scope, action: "sync", repoRoot });
|
|
}
|
|
const packageInstalled = await isPiPackageInstalled({ scope, repoRoot });
|
|
if (action === "remove") {
|
|
operations.push({
|
|
kind: "pi-package",
|
|
clientId,
|
|
scope,
|
|
action,
|
|
repoRoot,
|
|
piInstallArg: scopeInfo.piInstallArg || "",
|
|
status: packageInstalled ? undefined : "skipped",
|
|
details: packageInstalled ? "" : "not installed in Pi settings",
|
|
});
|
|
continue;
|
|
}
|
|
operations.push({
|
|
kind: "pi-package",
|
|
clientId,
|
|
scope,
|
|
action,
|
|
repoRoot,
|
|
piInstallArg: scopeInfo.piInstallArg || "",
|
|
status: packageInstalled && action === "install" ? "skipped" : undefined,
|
|
details: packageInstalled && action === "install" ? "already installed in Pi settings" : "",
|
|
});
|
|
for (const skillName of Object.keys(SKILLS)) {
|
|
if (getSkillSource(skillName, clientId, repoRoot)) {
|
|
operations.push({ kind: "package-skill", clientId, scope, skill: skillName, action: "included", status: "included", details: "included in Pi package" });
|
|
}
|
|
}
|
|
for (const bootstrap of [
|
|
{ skill: "atlassian", action: "pnpm-install" },
|
|
{ skill: "web-automation", action: "web-automation" },
|
|
]) {
|
|
const target = path.join(repoRoot, "pi-package", "skills", bootstrap.skill);
|
|
const installed = isBootstrapInstalled(bootstrap.action, target);
|
|
const skipBootstrap = action === "install" && installed;
|
|
operations.push({
|
|
kind: "bootstrap",
|
|
clientId,
|
|
scope,
|
|
skill: bootstrap.skill,
|
|
action: bootstrap.action,
|
|
displayAction: "bootstrap-deps",
|
|
target,
|
|
status: skipBootstrap ? "skipped" : undefined,
|
|
details: skipBootstrap ? "runtime dependencies already installed" : target,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot });
|
|
const actions = selection.actions || {};
|
|
const helperActions = selection.helperActions || {};
|
|
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 [helper, action] of Object.entries(helperActions)) {
|
|
if (helper !== "reviewer-runtime") continue;
|
|
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
|
|
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
|
|
if (plannedHelpers.has(helperKey)) continue;
|
|
plannedHelpers.add(helperKey);
|
|
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action });
|
|
if (helperOperation) operations.push(helperOperation);
|
|
}
|
|
|
|
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);
|
|
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
|
|
if (!plannedHelpers.has(helperKey)) {
|
|
plannedHelpers.add(helperKey);
|
|
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot });
|
|
if (helperOperation) operations.push(helperOperation);
|
|
}
|
|
}
|
|
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) {
|
|
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, item: `${skillName} deps`, action: SKILLS[skillName].bootstrap, displayAction: "bootstrap-deps", 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.item || op.skill || op.helper || op.kind,
|
|
action: op.displayAction || op.action,
|
|
status: op.status || "planned",
|
|
details: op.details || op.target || "",
|
|
});
|
|
}
|
|
|
|
return { operations, prompts, reportRows, assumeYes };
|
|
}
|
|
|
|
/**
|
|
* Remove the target of an operation (skill, helper, or superpowers).
|
|
*
|
|
* Validates that the target is within the skills root before removing.
|
|
* Handles both regular directories and symbolic links.
|
|
* Idempotent: succeeds even when the target does not exist.
|
|
*
|
|
* @param {object} op - Operation object with at least `target` and `skillsRoot`.
|
|
* @returns {Promise<object>} Operation with `status: "ok"`.
|
|
*/
|
|
export async function removeTarget(op) {
|
|
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" };
|
|
}
|
|
|
|
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 === "package-skill") return { ...op, status: "included" };
|
|
if (op.kind === "sync-pi-package") {
|
|
// Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs).
|
|
runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { cwd: op.repoRoot });
|
|
return { ...op, status: "ok" };
|
|
}
|
|
if (op.kind === "pi-package") {
|
|
const [command, args] = piPackageCommand(op);
|
|
runCommand(command, args, { cwd: op.repoRoot });
|
|
return { ...op, status: "ok" };
|
|
}
|
|
if (op.kind === "skill") {
|
|
if (op.action === "remove") return removeTarget(op);
|
|
await copyDirectoryReplacing(op.source, op.target);
|
|
return { ...op, status: "ok" };
|
|
}
|
|
if (op.kind === "helper") {
|
|
if (op.action === "remove") return removeTarget(op);
|
|
await installHelperAllowlist(op);
|
|
return { ...op, status: "ok" };
|
|
}
|
|
if (op.kind === "superpowers") {
|
|
if (op.action === "remove") return removeTarget(op);
|
|
await mkdir(path.dirname(op.target), { recursive: true });
|
|
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" };
|
|
}
|