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 { fileURLToPath } from "node:url"; import { buildPlan, run } from "./pi-links.mjs"; const repositoryRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); 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("removed update exposure is prunable without touching unrelated extension links", async () => { const options = await fixture(); const updateSource = path.join(options.repoRoot, "extensions", "update"); const externalSource = path.join(options.root, "external-update"); const extensionsDir = path.join(options.agentDir, "extensions"); const updateLink = path.join(extensionsDir, "update"); const externalLink = path.join(extensionsDir, "external"); await writeExtension(updateSource); await writeExtension(externalSource); await mkdir(extensionsDir, { recursive: true }); await symlink(updateSource, updateLink); await symlink(externalSource, externalLink); const plan = await buildPlan(options); assert.ok( plan.operations.some( (operation) => operation.type === "remove-link" && operation.destination === updateLink, ), ); assert.ok( !plan.operations.some( (operation) => operation.type === "remove-link" && operation.destination === externalLink, ), ); }); test("normal exposure and package evidence retain the Slice 0 security boundary", async () => { const links = JSON.parse( await readFile(path.join(repositoryRoot, "config", "pi-links.json"), "utf8"), ); assert.deepEqual( links.extensions.globalSymlinks, {}, "published extensions must not be re-exposed as development symlinks", ); const lock = JSON.parse( await readFile( path.join(repositoryRoot, "config", "pi-package-lock.json"), "utf8", ), ); assert.equal(lock.packages.length, 9); assert.ok( lock.packages.every((entry) => ["locked", "blocked"].includes(entry.status), ), ); assert.ok(lock.extensions.some((entry) => entry.name === "lm-studio")); assert.equal( lock.extensions.find((entry) => entry.name === "update")?.status, "blocked", ); }); 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/, ); });