global setup and configuration.
This commit is contained in:
Binary file not shown.
Executable
+635
@@ -0,0 +1,635 @@
|
||||
#!/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 <check|sync|prune> [--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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readlink,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { buildPlan, run } from "./pi-links.mjs";
|
||||
|
||||
async function writeJson(file, value) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function writeSkill(directory, name) {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "SKILL.md"),
|
||||
`---\nname: ${name}\ndescription: test\n---\n\n# ${name}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeExtension(target) {
|
||||
if (path.extname(target)) {
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, "export default function () {}\n");
|
||||
return;
|
||||
}
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(target, "index.ts"),
|
||||
"export default function () {}\n",
|
||||
);
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "pi-links-test-"));
|
||||
const repoRoot = path.join(root, "repo");
|
||||
const homeDir = path.join(root, "home");
|
||||
const agentDir = path.join(homeDir, ".pi", "agent");
|
||||
const settingsFile = path.join(agentDir, "settings.json");
|
||||
const globalSkill = path.join(repoRoot, "skills", "group", "global-skill");
|
||||
const projectSkill = path.join(repoRoot, "skills", "tools", "project-skill");
|
||||
const globalExtension = path.join(repoRoot, "extensions", "global-ext");
|
||||
const packageExtension = path.join(
|
||||
repoRoot,
|
||||
"extensions",
|
||||
"package-ext",
|
||||
"index.ts",
|
||||
);
|
||||
const globalAgents = path.join(repoRoot, "docs", "global.AGENTS.md");
|
||||
const globalClaude = path.join(repoRoot, "docs", "global.CLAUDE.md");
|
||||
|
||||
await writeSkill(globalSkill, "global-skill");
|
||||
await writeSkill(projectSkill, "project-skill");
|
||||
await writeExtension(globalExtension);
|
||||
await writeExtension(packageExtension);
|
||||
await mkdir(path.dirname(globalAgents), { recursive: true });
|
||||
await writeFile(globalAgents, "# Global instructions\n");
|
||||
await writeFile(globalClaude, "@global.AGENTS.md\n");
|
||||
await writeJson(path.join(repoRoot, "config", "pi-links.json"), {
|
||||
version: 1,
|
||||
skills: {
|
||||
root: "skills",
|
||||
projectOnly: { "project-skill": "skills/tools/project-skill" },
|
||||
excluded: [],
|
||||
},
|
||||
instructions: {
|
||||
globalSymlinks: {
|
||||
pi: "docs/global.AGENTS.md",
|
||||
codex: "docs/global.AGENTS.md",
|
||||
claude: "docs/global.CLAUDE.md",
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
globalSymlinks: { "global-ext": "extensions/global-ext" },
|
||||
globalLocalPackages: { "package-ext": "extensions/package-ext/index.ts" },
|
||||
},
|
||||
});
|
||||
await writeJson(settingsFile, {
|
||||
packages: [path.relative(agentDir, packageExtension)],
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
repoRoot,
|
||||
homeDir,
|
||||
agentDir,
|
||||
settingsFile,
|
||||
globalSkill,
|
||||
projectSkill,
|
||||
globalExtension,
|
||||
globalAgents,
|
||||
globalClaude,
|
||||
};
|
||||
}
|
||||
|
||||
test("sync creates absolute global links and relative project links", async () => {
|
||||
const options = await fixture();
|
||||
assert.equal(await run("sync", options), 0);
|
||||
assert.equal(await run("check", options), 0);
|
||||
|
||||
const globalSkillLink = path.join(
|
||||
options.homeDir,
|
||||
".agents",
|
||||
"skills",
|
||||
"global-skill",
|
||||
);
|
||||
const projectSkillLink = path.join(
|
||||
options.repoRoot,
|
||||
".pi",
|
||||
"skills",
|
||||
"project-skill",
|
||||
);
|
||||
const extensionLink = path.join(options.agentDir, "extensions", "global-ext");
|
||||
const piInstructions = path.join(options.agentDir, "AGENTS.md");
|
||||
const codexInstructions = path.join(options.homeDir, ".codex", "AGENTS.md");
|
||||
const claudeInstructions = path.join(options.homeDir, ".claude", "CLAUDE.md");
|
||||
assert.equal(await readlink(globalSkillLink), options.globalSkill);
|
||||
assert.equal(await readlink(extensionLink), options.globalExtension);
|
||||
assert.equal(await readlink(piInstructions), options.globalAgents);
|
||||
assert.equal(await readlink(codexInstructions), options.globalAgents);
|
||||
assert.equal(await readlink(claudeInstructions), options.globalClaude);
|
||||
assert.equal(
|
||||
await readlink(projectSkillLink),
|
||||
path.relative(path.dirname(projectSkillLink), options.projectSkill),
|
||||
);
|
||||
});
|
||||
|
||||
test("removed global instruction declarations are prunable without scanning config directories", async () => {
|
||||
const options = await fixture();
|
||||
await run("sync", options);
|
||||
const manifestFile = path.join(options.repoRoot, "config", "pi-links.json");
|
||||
const manifest = JSON.parse(await readFile(manifestFile, "utf8"));
|
||||
delete manifest.instructions.globalSymlinks.claude;
|
||||
await writeJson(manifestFile, manifest);
|
||||
|
||||
const plan = await buildPlan(options);
|
||||
assert.ok(
|
||||
plan.operations.some(
|
||||
(operation) =>
|
||||
operation.type === "remove-link" &&
|
||||
operation.destination ===
|
||||
path.join(options.homeDir, ".claude", "CLAUDE.md"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("dry-run reports drift without writing", async () => {
|
||||
const options = await fixture();
|
||||
assert.equal(await run("sync", { ...options, dryRun: true }), 0);
|
||||
const plan = await buildPlan(options);
|
||||
assert.ok(
|
||||
plan.operations.some((operation) => operation.type === "create-link"),
|
||||
);
|
||||
});
|
||||
|
||||
test("unmanaged destinations are conflicts and remain untouched", async () => {
|
||||
const options = await fixture();
|
||||
const destination = path.join(
|
||||
options.homeDir,
|
||||
".agents",
|
||||
"skills",
|
||||
"global-skill",
|
||||
);
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, "keep me\n");
|
||||
|
||||
const plan = await buildPlan(options);
|
||||
assert.deepEqual(
|
||||
plan.conflicts.map((entry) => entry.path),
|
||||
[destination],
|
||||
);
|
||||
assert.equal(await run("sync", options), 2);
|
||||
});
|
||||
|
||||
test("prune removes only stale links whose targets are inside the repository", async () => {
|
||||
const options = await fixture();
|
||||
await run("sync", options);
|
||||
const skillsDir = path.join(options.homeDir, ".agents", "skills");
|
||||
const stale = path.join(skillsDir, "stale");
|
||||
const external = path.join(skillsDir, "external");
|
||||
await symlink(options.globalSkill, stale);
|
||||
await symlink(path.join(options.root, "outside"), external);
|
||||
|
||||
const plan = await buildPlan(options);
|
||||
assert.ok(
|
||||
plan.operations.some(
|
||||
(operation) =>
|
||||
operation.type === "remove-link" && operation.destination === stale,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!plan.operations.some(
|
||||
(operation) =>
|
||||
operation.type === "remove-link" && operation.destination === external,
|
||||
),
|
||||
);
|
||||
assert.equal(await run("prune", options), 0);
|
||||
assert.equal(await readlink(external), path.join(options.root, "outside"));
|
||||
});
|
||||
|
||||
test("duplicate declared skill names fail before planning changes", async () => {
|
||||
const options = await fixture();
|
||||
await writeSkill(
|
||||
path.join(options.repoRoot, "skills", "other"),
|
||||
"global-skill",
|
||||
);
|
||||
await assert.rejects(
|
||||
() => buildPlan(options),
|
||||
/duplicate skill name global-skill/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
function configure_pi_orca_runtime --argument-names profile
|
||||
set -g pi_orca_node_executable /usr/bin/node
|
||||
set -gx PI_CODING_AGENT_DIR /home/jb/.pi/agent
|
||||
|
||||
if test "$profile" = pi-work
|
||||
set pi_orca_node_executable /home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node
|
||||
set PI_CODING_AGENT_DIR /home/jb/.pi/profiles/cyone
|
||||
end
|
||||
|
||||
# Keep Pi and its subprocesses on the same Node installation. Orca inherits
|
||||
# fnm's PATH, which otherwise makes tool commands resolve a different Node.
|
||||
set -l node_bin (path dirname "$pi_orca_node_executable")
|
||||
set -gx PATH "$node_bin" $PATH
|
||||
end
|
||||
+26
-30
@@ -5,53 +5,49 @@
|
||||
set -l resolver /home/jb/.local/bin/orca-nono-profile
|
||||
|
||||
if not test -x "$resolver"
|
||||
printf 'pi-orca: profile resolver is unavailable: %s\n' "$resolver" >&2
|
||||
exit 1
|
||||
printf 'pi-orca: profile resolver is unavailable: %s\n' "$resolver" >&2
|
||||
exit 1
|
||||
end
|
||||
|
||||
set -l profile ("$resolver" resolve "$PWD" 2>/dev/null)
|
||||
if test $status -ne 0; or test -z "$profile"
|
||||
printf 'pi-orca: could not resolve a trusted profile for %s\n' "$PWD" >&2
|
||||
exit 1
|
||||
printf 'pi-orca: could not resolve a trusted profile for %s\n' "$PWD" >&2
|
||||
exit 1
|
||||
end
|
||||
|
||||
if not /usr/bin/nono profile show "$profile" >/dev/null 2>&1
|
||||
printf 'pi-orca: resolved profile is unavailable: %s\n' "$profile" >&2
|
||||
exit 1
|
||||
printf 'pi-orca: resolved profile is unavailable: %s\n' "$profile" >&2
|
||||
exit 1
|
||||
end
|
||||
|
||||
set -l node_executable /usr/bin/node
|
||||
set -l pi_agent_dir /home/jb/.pi/agent
|
||||
source /srv/data/code/harness-tools/scripts/pi-orca-runtime.fish
|
||||
configure_pi_orca_runtime "$profile"
|
||||
|
||||
# Native Node addons are ABI-specific. Keep the work profile's Node 22 package
|
||||
# tree separate from the default Node 26 package tree.
|
||||
if test "$profile" = pi-work
|
||||
set node_executable /home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node
|
||||
set pi_agent_dir /home/jb/.pi/profiles/cyone
|
||||
end
|
||||
set -l node_executable "$pi_orca_node_executable"
|
||||
set -l pi_agent_dir "$PI_CODING_AGENT_DIR"
|
||||
|
||||
if not test -x "$node_executable"
|
||||
printf 'pi-orca: Node runtime is unavailable: %s\n' "$node_executable" >&2
|
||||
exit 1
|
||||
printf 'pi-orca: Node runtime is unavailable: %s\n' "$node_executable" >&2
|
||||
exit 1
|
||||
end
|
||||
if not test -d "$pi_agent_dir"
|
||||
printf 'pi-orca: Pi agent directory is unavailable: %s\n' "$pi_agent_dir" >&2
|
||||
exit 1
|
||||
printf 'pi-orca: Pi agent directory is unavailable: %s\n' "$pi_agent_dir" >&2
|
||||
exit 1
|
||||
end
|
||||
|
||||
set -lx ORCA_NONO_PROFILE "$profile"
|
||||
set -lx PI_CODING_AGENT_DIR "$pi_agent_dir"
|
||||
|
||||
exec /usr/bin/systemd-run \
|
||||
--user \
|
||||
--scope \
|
||||
--collect \
|
||||
--quiet \
|
||||
--slice=orca-agents.slice \
|
||||
--property=CPUQuota=1600% \
|
||||
--property=MemoryHigh=48G \
|
||||
--property=MemoryMax=64G \
|
||||
--property=MemorySwapMax=8G \
|
||||
--property=TasksMax=8192 \
|
||||
--property=OOMPolicy=stop \
|
||||
-- /usr/bin/nono run --profile "$profile" -- "$node_executable" /home/jb/.bun/bin/pi $argv
|
||||
--user \
|
||||
--scope \
|
||||
--collect \
|
||||
--quiet \
|
||||
--slice=orca-agents.slice \
|
||||
--property=CPUQuota=1600% \
|
||||
--property=MemoryHigh=48G \
|
||||
--property=MemoryMax=64G \
|
||||
--property=MemorySwapMax=8G \
|
||||
--property=TasksMax=8192 \
|
||||
--property=OOMPolicy=stop \
|
||||
-- /usr/bin/nono run --profile "$profile" -- "$node_executable" /home/jb/.bun/bin/pi $argv
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNTIME_SCRIPT = Path(__file__).with_name("pi-orca-runtime.fish")
|
||||
SYSTEM_NODE = Path("/usr/bin/node")
|
||||
WORK_NODE = Path(
|
||||
"/home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node"
|
||||
)
|
||||
|
||||
|
||||
class ConfigurePiOrcaRuntimeTests(unittest.TestCase):
|
||||
def configure_runtime(self, profile: str) -> list[str]:
|
||||
environment = os.environ.copy()
|
||||
environment["PATH"] = ":".join(
|
||||
[str(WORK_NODE.parent), "/usr/local/bin", "/usr/bin"]
|
||||
)
|
||||
command = """
|
||||
source $argv[1]
|
||||
configure_pi_orca_runtime $argv[2]
|
||||
printf '%s\\n' $pi_orca_node_executable $PI_CODING_AGENT_DIR $PATH[1] (command -v node)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["/usr/bin/fish", "-c", command, str(RUNTIME_SCRIPT), profile],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
env=environment,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.splitlines()
|
||||
|
||||
def test_default_profile_prioritizes_system_node(self) -> None:
|
||||
self.assertEqual(
|
||||
self.configure_runtime("pi-harness-tools"),
|
||||
[
|
||||
str(SYSTEM_NODE),
|
||||
"/home/jb/.pi/agent",
|
||||
str(SYSTEM_NODE.parent),
|
||||
str(SYSTEM_NODE),
|
||||
],
|
||||
)
|
||||
|
||||
def test_work_profile_prioritizes_node_22(self) -> None:
|
||||
self.assertEqual(
|
||||
self.configure_runtime("pi-work"),
|
||||
[
|
||||
str(WORK_NODE),
|
||||
"/home/jb/.pi/profiles/cyone",
|
||||
str(WORK_NODE.parent),
|
||||
str(WORK_NODE),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user