global setup and configuration.
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user