#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { constants } from "node:fs"; import { access, lstat, mkdir, readFile, readdir, readlink, realpath, symlink, unlink, } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const SCRIPT_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); const MANIFEST_PATH = "config/pi-links.json"; const COMMANDS = new Set(["check", "sync", "prune"]); function usage() { return ( `Usage: node scripts/pi-links.mjs [--dry-run]\n\n` + ` check Report missing, stale, or conflicting exposure without writing\n` + ` sync Create or repair manifest-declared links and local package entries\n` + ` prune Remove repository-owned links and local package entries not in the manifest\n` + ` --dry-run Print sync/prune changes without applying them` ); } function isInside(parent, child) { const relative = path.relative(parent, child); return ( relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ); } async function pathExists(target) { try { await access(target, constants.F_OK); return true; } catch { return false; } } async function statOrUndefined(target) { try { return await lstat(target); } catch (error) { if (error?.code === "ENOENT") return undefined; throw error; } } async function canonical(target) { try { return await realpath(target); } catch { return path.resolve(target); } } function parseSkillName(contents, skillFile) { const frontmatter = contents.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); if (!frontmatter) throw new Error(`${skillFile}: missing YAML frontmatter`); const match = frontmatter[1].match( /^name:\s*(?:"([^"]+)"|'([^']+)'|([^#\r\n]+))\s*$/m, ); const name = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim(); if (!name) throw new Error(`${skillFile}: missing frontmatter name`); if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) { throw new Error(`${skillFile}: unsafe skill name ${JSON.stringify(name)}`); } return name; } async function discoverSkills(root) { const found = []; async function visit(directory) { const skillFile = path.join(directory, "SKILL.md"); if (await pathExists(skillFile)) { const name = parseSkillName(await readFile(skillFile, "utf8"), skillFile); found.push({ name, source: directory }); return; } const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if ( !entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules" ) continue; await visit(path.join(directory, entry.name)); } } await visit(root); return found; } async function readJson(file) { const contents = await readFile(file, "utf8"); try { return JSON.parse(contents); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`${file}: invalid JSON: ${reason}`, { cause: error }); } } function assertRecord(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be an object`); } return value; } function assertStringArray(value, label) { if ( !Array.isArray(value) || value.some((entry) => typeof entry !== "string") ) { throw new Error(`${label} must be an array of strings`); } return value; } async function loadManifest(repoRoot) { const manifestFile = path.join(repoRoot, MANIFEST_PATH); const manifest = await readJson(manifestFile); if (manifest.version !== 1) throw new Error(`${manifestFile}: unsupported version`); const skills = assertRecord(manifest.skills, "skills"); const extensions = assertRecord(manifest.extensions, "extensions"); const instructions = assertRecord(manifest.instructions, "instructions"); assertRecord(skills.projectOnly, "skills.projectOnly"); assertStringArray(skills.excluded, "skills.excluded"); assertRecord(extensions.globalSymlinks, "extensions.globalSymlinks"); assertRecord( extensions.globalLocalPackages, "extensions.globalLocalPackages", ); assertRecord(instructions.globalSymlinks, "instructions.globalSymlinks"); if (typeof skills.root !== "string") throw new Error("skills.root must be a string"); return manifest; } async function validateSource(repoRoot, relativeSource, kind) { const source = path.resolve(repoRoot, relativeSource); if (!isInside(repoRoot, source)) throw new Error(`${relativeSource}: source escapes repository`); const stat = await statOrUndefined(source); if (!stat) throw new Error(`${relativeSource}: source does not exist`); if (kind === "skill") { if ( !stat.isDirectory() || !(await pathExists(path.join(source, "SKILL.md"))) ) { throw new Error(`${relativeSource}: skill source must contain SKILL.md`); } } else if (kind === "instruction") { if (!stat.isFile() || path.extname(source).toLowerCase() !== ".md") { throw new Error( `${relativeSource}: instruction source must be a Markdown file`, ); } } else if (stat.isDirectory()) { if ( !(await pathExists(path.join(source, "index.ts"))) && !(await pathExists(path.join(source, "index.js"))) ) { throw new Error( `${relativeSource}: extension directory must contain index.ts or index.js`, ); } } else if (!/\.[cm]?[jt]s$/i.test(source)) { throw new Error( `${relativeSource}: extension file must be JavaScript or TypeScript`, ); } return source; } async function resolveDesiredState(options = {}) { const repoRoot = path.resolve(options.repoRoot ?? SCRIPT_ROOT); const homeDir = path.resolve(options.homeDir ?? os.homedir()); const agentDir = path.resolve( options.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(homeDir, ".pi", "agent"), ); const globalSkillsDir = path.resolve( options.globalSkillsDir ?? path.join(homeDir, ".agents", "skills"), ); const globalExtensionsDir = path.resolve( options.globalExtensionsDir ?? path.join(agentDir, "extensions"), ); const projectSkillsDir = path.join(repoRoot, ".pi", "skills"); const settingsFile = path.resolve( options.settingsFile ?? path.join(agentDir, "settings.json"), ); const manifest = await loadManifest(repoRoot); const instructionDestinations = { pi: path.join(agentDir, "AGENTS.md"), codex: path.join(homeDir, ".codex", "AGENTS.md"), claude: path.join(homeDir, ".claude", "CLAUDE.md"), }; const skillsRoot = await validateSource( repoRoot, manifest.skills.root, "skill-root", ).catch(async (error) => { const source = path.resolve(repoRoot, manifest.skills.root); const stat = await statOrUndefined(source); if (!stat?.isDirectory()) throw error; return source; }); const discovered = await discoverSkills(skillsRoot); const byName = new Map(); for (const skill of discovered) { const previous = byName.get(skill.name); if (previous) { throw new Error( `duplicate skill name ${skill.name}: ${previous.source} and ${skill.source}`, ); } byName.set(skill.name, skill); } const projectOnly = new Map(Object.entries(manifest.skills.projectOnly)); const excluded = new Set(manifest.skills.excluded); for (const name of [...projectOnly.keys(), ...excluded]) { if (!byName.has(name)) throw new Error(`manifest references unknown skill ${name}`); } for (const name of projectOnly.keys()) { if (excluded.has(name)) throw new Error(`skill ${name} cannot be both project-only and excluded`); } const links = []; for (const skill of discovered) { if (excluded.has(skill.name)) continue; const declaredProjectSource = projectOnly.get(skill.name); if (declaredProjectSource !== undefined) { const source = await validateSource( repoRoot, declaredProjectSource, "skill", ); if ((await canonical(source)) !== (await canonical(skill.source))) { throw new Error( `project-only skill ${skill.name} source does not match discovered source`, ); } links.push({ kind: "skill", scope: "project", name: skill.name, source, destination: path.join(projectSkillsDir, skill.name), relative: true, }); continue; } links.push({ kind: "skill", scope: "global", name: skill.name, source: skill.source, destination: path.join(globalSkillsDir, skill.name), relative: false, }); } const extensionSources = new Set(); for (const [name, relativeSource] of Object.entries( manifest.extensions.globalSymlinks, )) { const source = await validateSource(repoRoot, relativeSource, "extension"); const sourceKey = await canonical(source); if (extensionSources.has(sourceKey)) throw new Error( `extension source listed more than once: ${relativeSource}`, ); extensionSources.add(sourceKey); links.push({ kind: "extension", scope: "global", name, source, destination: path.join(globalExtensionsDir, name), relative: false, }); } for (const [name, relativeSource] of Object.entries( manifest.instructions.globalSymlinks, )) { const destination = instructionDestinations[name]; if (!destination) throw new Error(`unknown global instruction target ${name}`); const source = await validateSource( repoRoot, relativeSource, "instruction", ); links.push({ kind: "instruction", scope: "global", name, source, destination, relative: false, type: "file", }); } const packages = []; for (const [name, relativeSource] of Object.entries( manifest.extensions.globalLocalPackages, )) { const source = await validateSource(repoRoot, relativeSource, "extension"); const sourceKey = await canonical(source); if (extensionSources.has(sourceKey)) { throw new Error( `extension ${name} is exposed as both a symlink and local package`, ); } extensionSources.add(sourceKey); packages.push({ kind: "extension", scope: "global", name, source }); } links.sort((a, b) => a.destination.localeCompare(b.destination)); packages.sort((a, b) => a.source.localeCompare(b.source)); return { repoRoot, settingsFile, managedDirectories: [ globalSkillsDir, globalExtensionsDir, projectSkillsDir, ], managedFiles: Object.values(instructionDestinations), links, packages, }; } function localPackagePath(source, settingsFile) { if (typeof source !== "string") return undefined; if (/^(?:npm:|git:|https?:|ssh:|git\+|file:)/i.test(source)) return undefined; if (!path.isAbsolute(source) && !source.startsWith(".")) return undefined; return path.resolve(path.dirname(settingsFile), source); } async function readLocalPackages(settingsFile) { if (!(await pathExists(settingsFile))) return []; const settings = await readJson(settingsFile); if (!Array.isArray(settings.packages)) return []; return settings.packages .map((entry) => (typeof entry === "string" ? entry : entry?.source)) .map((source) => ({ source, resolved: localPackagePath(source, settingsFile), })) .filter((entry) => entry.resolved); } async function inspectDesiredLink(link, repoRoot) { const stat = await statOrUndefined(link.destination); if (!stat) return { type: "create-link", link }; if (!stat.isSymbolicLink()) { return { type: "conflict", path: link.destination, reason: "destination is not a symlink", }; } const rawTarget = await readlink(link.destination); const resolvedTarget = path.resolve( path.dirname(link.destination), rawTarget, ); if ((await canonical(resolvedTarget)) === (await canonical(link.source))) return undefined; if (isInside(repoRoot, resolvedTarget)) return { type: "replace-link", link, previousTarget: resolvedTarget }; return { type: "conflict", path: link.destination, reason: `symlink points outside this repository: ${rawTarget}`, }; } async function discoverStaleLinks(state, desiredDestinations) { const stale = []; for (const directory of state.managedDirectories) { const stat = await statOrUndefined(directory); if (!stat?.isDirectory()) continue; const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (!entry.isSymbolicLink()) continue; const destination = path.join(directory, entry.name); if (desiredDestinations.has(destination)) continue; const rawTarget = await readlink(destination); const resolvedTarget = path.resolve(directory, rawTarget); if (isInside(state.repoRoot, resolvedTarget)) { stale.push({ type: "remove-link", destination, previousTarget: resolvedTarget, }); } } } for (const destination of state.managedFiles) { if (desiredDestinations.has(destination)) continue; const stat = await statOrUndefined(destination); if (!stat?.isSymbolicLink()) continue; const rawTarget = await readlink(destination); const resolvedTarget = path.resolve(path.dirname(destination), rawTarget); if (isInside(state.repoRoot, resolvedTarget)) { stale.push({ type: "remove-link", destination, previousTarget: resolvedTarget, }); } } return stale; } export async function buildPlan(options = {}) { const state = await resolveDesiredState(options); const desiredDestinations = new Set( state.links.map((link) => link.destination), ); const desiredPackageSources = new Set( await Promise.all(state.packages.map((entry) => canonical(entry.source))), ); const operations = []; const conflicts = []; for (const link of state.links) { const result = await inspectDesiredLink(link, state.repoRoot); if (result?.type === "conflict") conflicts.push(result); else if (result) operations.push(result); } operations.push(...(await discoverStaleLinks(state, desiredDestinations))); const installedPackages = await readLocalPackages(state.settingsFile); const installedCounts = new Map(); for (const entry of installedPackages) { const key = await canonical(entry.resolved); installedCounts.set(key, (installedCounts.get(key) ?? 0) + 1); } for (const [source, count] of installedCounts) { if (count > 1 && isInside(state.repoRoot, source)) { conflicts.push({ type: "conflict", path: state.settingsFile, reason: `local package appears ${count} times: ${source}`, }); } } for (const pkg of state.packages) { const source = await canonical(pkg.source); if (!installedCounts.has(source)) operations.push({ type: "install-package", package: pkg }); } for (const entry of installedPackages) { const source = await canonical(entry.resolved); if ( isInside(state.repoRoot, source) && !desiredPackageSources.has(source) ) { operations.push({ type: "remove-package", source }); } } operations.sort((a, b) => describeOperation(a).localeCompare(describeOperation(b)), ); conflicts.sort((a, b) => `${a.path}: ${a.reason}`.localeCompare(`${b.path}: ${b.reason}`), ); return { state, operations, conflicts }; } function describeOperation(operation) { switch (operation.type) { case "create-link": return `create ${operation.link.destination} -> ${operation.link.source}`; case "replace-link": return `repair ${operation.link.destination} -> ${operation.link.source}`; case "remove-link": return `remove stale link ${operation.destination} -> ${operation.previousTarget}`; case "install-package": return `install local package ${operation.package.source}`; case "remove-package": return `remove stale local package ${operation.source}`; default: return `${operation.type}`; } } function selectOperations(command, operations) { if (command === "sync") { return operations.filter( (operation) => operation.type === "create-link" || operation.type === "replace-link" || operation.type === "install-package", ); } if (command === "prune") { return operations.filter( (operation) => operation.type === "remove-link" || operation.type === "remove-package", ); } return []; } async function createOrReplaceLink(operation) { const { link } = operation; await mkdir(path.dirname(link.destination), { recursive: true }); if (operation.type === "replace-link") await unlink(link.destination); const target = link.relative ? path.relative(path.dirname(link.destination), link.source) : link.source; await symlink(target, link.destination, link.type ?? "dir"); } function runPiPackage(command, source) { const result = spawnSync("pi", [command, source], { stdio: "inherit" }); if (result.error) throw result.error; if (result.status !== 0) throw new Error( `pi ${command} failed for ${source} with exit code ${result.status}`, ); } async function applyOperations(operations) { for (const operation of operations) { if (operation.type === "create-link" || operation.type === "replace-link") { await createOrReplaceLink(operation); } else if (operation.type === "remove-link") { await unlink(operation.destination); } else if (operation.type === "install-package") { runPiPackage("install", operation.package.source); } else if (operation.type === "remove-package") { runPiPackage("remove", operation.source); } } } export async function run(command, options = {}) { const plan = await buildPlan(options); for (const conflict of plan.conflicts) console.error(`CONFLICT ${conflict.path}: ${conflict.reason}`); if (plan.conflicts.length > 0) return 2; if (command === "check") { if (plan.operations.length === 0) { console.log( `OK ${plan.state.links.length} links and ${plan.state.packages.length} local package entries match ${MANIFEST_PATH}`, ); return 0; } for (const operation of plan.operations) console.log(`DRIFT ${describeOperation(operation)}`); return 1; } const selected = selectOperations(command, plan.operations); if (selected.length === 0) { console.log(`OK nothing to ${command}`); return 0; } for (const operation of selected) console.log( `${options.dryRun ? "WOULD" : "APPLY"} ${describeOperation(operation)}`, ); if (!options.dryRun) await applyOperations(selected); return 0; } async function main(argv = process.argv.slice(2)) { const command = argv.find((arg) => !arg.startsWith("-")); const unknown = argv.filter( (arg) => arg.startsWith("-") && arg !== "--dry-run", ); if (!COMMANDS.has(command) || unknown.length > 0) { console.error(usage()); return 2; } return run(command, { dryRun: argv.includes("--dry-run") }); } if ( process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href ) { main() .then((code) => { process.exitCode = code; }) .catch((error) => { console.error( `ERROR ${error instanceof Error ? error.message : String(error)}`, ); process.exitCode = 2; }); }