mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
2965080cb7
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).
417 lines
17 KiB
TypeScript
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");
|
|
});
|
|
});
|