global setup and configuration.

This commit is contained in:
jb
2026-08-24 16:33:11 -04:00
parent 10f64b593b
commit 31c9363dbb
95 changed files with 12641 additions and 41 deletions
+218
View File
@@ -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/,
);
});