Files
故璃 2965080cb7 feat(skills): replace foreign skill dirs containing SKILL.md during fan-out
Previously, fan-out skipped any existing real directory not recorded in
the lock file, treating it as user content. This left stale skill copies
installed by other tools (e.g. npx skills add) permanently out of date.

Now: if the directory contains a SKILL.md, it is recognized as a skill
artifact and replaced with a symlink to the canonical dir. Directories
without SKILL.md are still preserved (user content safety boundary).
2026-08-11 14:29:10 +08:00

417 lines
17 KiB
TypeScript

import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { expect, test } from "vite-plus/test";
import {
detectInstalledAgents,
fanOutSkillToAgents,
getAgentTargets,
linkSkillToAgents,
unlinkSkillFromAgents,
} from "../src/skills/agents.ts";
import { getSkillsDir } from "../src/skills/lock.ts";
/**
* Isolated environment: HOME/XDG_CONFIG_HOME/BAILIAN_CONFIG_DIR all point to a temp dir,
* so agent detection and the canonical dir never touch the real home.
*/
async function inFakeHome(fn: (home: string) => Promise<void>): Promise<void> {
const saved = {
HOME: process.env.HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CODEX_HOME: process.env.CODEX_HOME,
VIBE_HOME: process.env.VIBE_HOME,
HERMES_HOME: process.env.HERMES_HOME,
AUTOHAND_HOME: process.env.AUTOHAND_HOME,
GROK_HOME: process.env.GROK_HOME,
APPDATA: process.env.APPDATA,
FLATPAK_XDG_CONFIG_HOME: process.env.FLATPAK_XDG_CONFIG_HOME,
};
const home = mkdtempSync(join(tmpdir(), "bl-skill-agents-"));
process.env.HOME = home;
process.env.XDG_CONFIG_HOME = join(home, ".config");
process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian");
// Agent config-dir overrides must not leak in from the dev machine
for (const key of [
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"VIBE_HOME",
"HERMES_HOME",
"AUTOHAND_HOME",
"GROK_HOME",
"APPDATA",
"FLATPAK_XDG_CONFIG_HOME",
]) {
delete process.env[key];
}
try {
await fn(home);
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(home, { recursive: true, force: true });
}
}
/** Create an installed skill in canonical */
function seedCanonicalSkill(name: string): string {
const dir = join(getSkillsDir(), name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), "---\nname: x\ndescription: y\n---\n");
return dir;
}
test("agents: registry mirrors upstream agent list minus non-symlinkable agents", async () => {
await inFakeHome(async (home) => {
const ids = getAgentTargets().map((agent) => agent.id);
expect(ids).toContain("universal");
expect(ids).toContain("universal-xdg");
expect(getAgentTargets()).toHaveLength(65);
// eve (no global dir, upstream forces direct writes) and promptscript (project-only)
// cannot participate in global symlink fan-out
expect(ids).not.toContain("eve");
expect(ids).not.toContain("promptscript");
expect(detectInstalledAgents()).toEqual([]);
mkdirSync(join(home, ".claude"), { recursive: true });
mkdirSync(join(home, ".qoder"), { recursive: true });
expect(detectInstalledAgents().map((a) => a.id)).toEqual(["claude-code", "qoder"]);
// Cline config dir exists → hits the universal pseudo-agent
mkdirSync(join(home, ".cline"), { recursive: true });
expect(detectInstalledAgents().map((a) => a.id)).toEqual(["universal", "claude-code", "qoder"]);
});
});
test("agents: expanded registry detects per-agent config dirs", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".roo"), { recursive: true });
mkdirSync(join(home, ".trae"), { recursive: true });
mkdirSync(join(home, ".gemini"), { recursive: true });
mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true });
mkdirSync(join(home, ".snowflake", "cortex"), { recursive: true });
const detected = detectInstalledAgents().map((agent) => agent.id);
expect(detected).toEqual(["cortex", "gemini-cli", "roo", "trae", "windsurf"]);
// skills dirs follow each agent's own convention
const targets = getAgentTargets();
expect(targets.find((agent) => agent.id === "windsurf")?.skillsDir).toBe(
join(home, ".codeium", "windsurf", "skills"),
);
expect(targets.find((agent) => agent.id === "cortex")?.skillsDir).toBe(
join(home, ".snowflake", "cortex", "skills"),
);
});
});
test("agents: shared-dir agents (Warp/Zed/Kimi/…) light up the universal target", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".warp"), { recursive: true });
mkdirSync(join(home, ".config", "zed"), { recursive: true });
const detected = detectInstalledAgents();
expect(detected.map((agent) => agent.id)).toEqual(["universal"]);
expect(detected[0].skillsDir).toBe(join(home, ".agents", "skills"));
seedCanonicalSkill("demo");
linkSkillToAgents("demo");
expect(lstatSync(join(home, ".agents", "skills", "demo")).isSymbolicLink()).toBe(true);
// No per-agent dirs were invented for shared-dir agents
expect(existsSync(join(home, ".warp", "skills"))).toBe(false);
});
});
test("agents: Replit project marker in cwd lights up universal-xdg", async () => {
await inFakeHome(async (home) => {
const previousCwd = process.cwd();
process.chdir(home);
try {
expect(detectInstalledAgents()).toEqual([]);
mkdirSync(join(home, ".replit"), { recursive: true });
const detected = detectInstalledAgents();
expect(detected.map((agent) => agent.id)).toEqual(["universal-xdg"]);
expect(detected[0].skillsDir).toBe(join(home, ".config", "agents", "skills"));
} finally {
process.chdir(previousCwd);
}
});
});
test("agents: OpenClaw historical alias dirs are detected and link into the existing home", async () => {
await inFakeHome(async (home) => {
// Only the legacy .clawdbot home exists → links must land there, not in .openclaw
mkdirSync(join(home, ".clawdbot"), { recursive: true });
const openclaw = detectInstalledAgents().find((agent) => agent.id === "openclaw");
expect(openclaw?.skillsDir).toBe(join(home, ".clawdbot", "skills"));
seedCanonicalSkill("demo");
linkSkillToAgents("demo");
expect(lstatSync(join(home, ".clawdbot", "skills", "demo")).isSymbolicLink()).toBe(true);
expect(existsSync(join(home, ".openclaw"))).toBe(false);
});
});
test("agents: VIBE_HOME/HERMES_HOME/AUTOHAND_HOME/GROK_HOME relocate their agents", async () => {
await inFakeHome(async (home) => {
const customDirs = {
"mistral-vibe": join(home, "custom-vibe"),
hermes: join(home, "custom-hermes"),
"autohand-code": join(home, "custom-autohand"),
grok: join(home, "custom-grok"),
};
process.env.VIBE_HOME = customDirs["mistral-vibe"];
process.env.HERMES_HOME = customDirs.hermes;
process.env.AUTOHAND_HOME = customDirs["autohand-code"];
process.env.GROK_HOME = customDirs.grok;
for (const dir of Object.values(customDirs)) {
mkdirSync(dir, { recursive: true });
}
const targets = getAgentTargets();
for (const [id, baseDir] of Object.entries(customDirs)) {
const target = targets.find((agent) => agent.id === id);
expect(target?.skillsDir).toBe(join(baseDir, "skills"));
expect(detectInstalledAgents().map((agent) => agent.id)).toContain(id);
}
});
});
test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
const target = seedCanonicalSkill("demo");
const results = linkSkillToAgents("demo");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({ agent: "claude-code", mode: "symlink" });
const linkPath = join(home, ".claude", "skills", "demo");
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(linkPath)).toBe(target);
// Real content is readable through the link
expect(readFileSync(join(linkPath, "SKILL.md"), "utf-8")).toContain("name: x");
// Uninstalled agent dir was not created out of thin air
expect(existsSync(join(home, ".cursor"))).toBe(false);
});
});
test("agents: existing unmanaged dir is skipped; managed stale link is rebuilt", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
seedCanonicalSkill("demo");
// Real dir placed by the user → skipped, not cleared
const foreign = join(home, ".claude", "skills", "demo");
mkdirSync(foreign, { recursive: true });
writeFileSync(join(foreign, "user.txt"), "mine");
const first = linkSkillToAgents("demo");
expect(first[0].mode).toBe("skipped");
expect(readFileSync(join(foreign, "user.txt"), "utf-8")).toBe("mine");
// Replace with our own stale link → rebuilt successfully
rmSync(foreign, { recursive: true, force: true });
const again = linkSkillToAgents("demo");
expect(again[0].mode).toBe("symlink");
const rebuilt = linkSkillToAgents("demo");
expect(rebuilt[0].mode).toBe("symlink");
});
});
test("agents: unlink reclaims managed links, leaves foreign content untouched", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
mkdirSync(join(home, ".agents"), { recursive: true });
seedCanonicalSkill("demo");
const links = linkSkillToAgents("demo");
expect(links.filter((l) => l.mode === "symlink")).toHaveLength(2);
// Foreign file with the same name placed in cursor (should be unaffected even if not detected)
const removed = unlinkSkillFromAgents(
"demo",
links.map((l) => l.path),
);
expect(removed.sort()).toEqual(links.map((l) => l.path).sort());
expect(existsSync(join(home, ".claude", "skills", "demo"))).toBe(false);
expect(existsSync(join(home, ".agents", "skills", "demo"))).toBe(false);
});
});
test("agents: official config-dir env vars relocate detection and fan-out", async () => {
await inFakeHome(async (home) => {
const customClaude = join(home, "relocated-claude");
const customCodex = join(home, "relocated-codex");
mkdirSync(customClaude, { recursive: true });
mkdirSync(customCodex, { recursive: true });
process.env.CLAUDE_CONFIG_DIR = customClaude;
process.env.CODEX_HOME = customCodex;
const targets = getAgentTargets();
const claude = targets.find((agent) => agent.id === "claude-code");
const codex = targets.find((agent) => agent.id === "codex");
expect(claude?.skillsDir).toBe(join(customClaude, "skills"));
expect(codex?.detectDirs).toEqual([customCodex, "/etc/codex"]);
// Detected via the relocated dirs even though default ~/.claude and ~/.codex are absent
const detected = detectInstalledAgents().map((agent) => agent.id);
expect(detected).toContain("claude-code");
expect(detected).toContain("codex");
expect(existsSync(join(home, ".claude"))).toBe(false);
// Fan-out lands in the relocated config dir, not the default location
seedCanonicalSkill("demo");
const results = linkSkillToAgents("demo");
const claudeLink = results.find((link) => link.agent === "claude-code");
expect(claudeLink?.path).toBe(join(customClaude, "skills", "demo"));
expect(lstatSync(claudeLink!.path).isSymbolicLink()).toBe(true);
});
});
test("agents: Amp-style XDG config dir lights up the universal-xdg shared target", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".config", "amp"), { recursive: true });
const xdg = detectInstalledAgents().find((agent) => agent.id === "universal-xdg");
expect(xdg?.skillsDir).toBe(join(home, ".config", "agents", "skills"));
seedCanonicalSkill("demo");
linkSkillToAgents("demo");
const sharedLink = join(home, ".config", "agents", "skills", "demo");
expect(lstatSync(sharedLink).isSymbolicLink()).toBe(true);
});
});
test("agents: foreign skill dir (contains SKILL.md) is replaced even without lock record", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
const canonical = seedCanonicalSkill("demo");
const copyPath = join(home, ".claude", "skills", "demo");
// Simulate a skill installed by another tool (e.g. `npx skills add`) — a real
// directory containing SKILL.md, not recorded in our lock.
mkdirSync(copyPath, { recursive: true });
writeFileSync(join(copyPath, "SKILL.md"), "stale copy from another tool");
// New behavior: contains SKILL.md → recognized as a skill artifact → replaced
const result = linkSkillToAgents("demo");
expect(result[0]).toMatchObject({ agent: "claude-code", mode: "symlink" });
expect(lstatSync(copyPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(copyPath)).toBe(canonical);
});
});
test("agents: foreign non-skill dir (no SKILL.md) stays skipped", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
seedCanonicalSkill("demo");
const foreignPath = join(home, ".claude", "skills", "demo");
// A user's own directory that happens to share the skill name but has no SKILL.md
mkdirSync(foreignPath, { recursive: true });
writeFileSync(join(foreignPath, "my-notes.txt"), "user content");
const result = linkSkillToAgents("demo");
expect(result[0].mode).toBe("skipped");
// User content untouched
expect(readFileSync(join(foreignPath, "my-notes.txt"), "utf-8")).toBe("user content");
});
});
test("agents: recorded copy-fallback artifact is replaced with symlink", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
const canonical = seedCanonicalSkill("demo");
const copyPath = join(home, ".claude", "skills", "demo");
// Simulate a previous install that fell back to copy (no symlink permission)
mkdirSync(copyPath, { recursive: true });
writeFileSync(join(copyPath, "SKILL.md"), "stale copy");
// With the recorded link the artifact is rebuilt and points at canonical again
const recorded = linkSkillToAgents("demo", detectInstalledAgents(), [copyPath]);
expect(recorded[0]).toMatchObject({ agent: "claude-code", mode: "symlink" });
expect(lstatSync(copyPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(copyPath)).toBe(canonical);
// Subsequent runs keep refreshing through the rebuilt link
writeFileSync(join(canonical, "SKILL.md"), "---\nname: x\ndescription: y\n---\nv2\n");
const refreshed = linkSkillToAgents("demo", detectInstalledAgents(), [copyPath]);
expect(refreshed[0].mode).toBe("symlink");
expect(readFileSync(join(copyPath, "SKILL.md"), "utf-8")).toContain("v2");
});
});
test("agents: recorded plain file (not a copy dir) is never replaced", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude", "skills"), { recursive: true });
seedCanonicalSkill("demo");
const filePath = join(home, ".claude", "skills", "demo");
writeFileSync(filePath, "user file");
// Even when (erroneously) recorded, a non-directory never qualifies as a copy artifact
const results = linkSkillToAgents("demo", detectInstalledAgents(), [filePath]);
expect(results[0].mode).toBe("skipped");
expect(readFileSync(filePath, "utf-8")).toBe("user file");
});
});
test("agents: unlink removes recorded copy-fallback directories", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
seedCanonicalSkill("demo");
const copyPath = join(home, ".claude", "skills", "demo");
mkdirSync(copyPath, { recursive: true });
writeFileSync(join(copyPath, "SKILL.md"), "copy");
const removed = unlinkSkillFromAgents("demo", [copyPath]);
expect(removed).toEqual([copyPath]);
expect(existsSync(copyPath)).toBe(false);
});
});
test("fanout: recorded path of an unvisited agent stays in the ledger", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude"), { recursive: true });
seedCanonicalSkill("demo");
// Simulate a copy artifact left by an agent that is no longer detected (e.g. uninstalled
// Qoder): its recorded path must survive the merge so bl skill remove can still reclaim it
const orphanPath = join(home, ".qoder", "skills", "demo");
const fanout = fanOutSkillToAgents("demo", detectInstalledAgents(), [orphanPath]);
expect(fanout.linkedAgents).toEqual(["claude-code"]);
const claudeLink = join(home, ".claude", "skills", "demo");
expect(fanout.links).toContain(claudeLink);
expect(fanout.links).toContain(orphanPath);
});
});
test("fanout: recorded path confirmed foreign this run is dropped from the ledger", async () => {
await inFakeHome(async (home) => {
mkdirSync(join(home, ".claude", "skills"), { recursive: true });
seedCanonicalSkill("demo");
// User replaced our artifact with their own plain file → scanned, skipped as unmanaged;
// keeping the record would let bl skill remove delete user content
const foreignPath = join(home, ".claude", "skills", "demo");
writeFileSync(foreignPath, "user file");
const fanout = fanOutSkillToAgents("demo", detectInstalledAgents(), [foreignPath]);
expect(fanout.linkedAgents).toEqual([]);
expect(fanout.links).not.toContain(foreignPath);
expect(readFileSync(foreignPath, "utf-8")).toBe("user file");
});
});