Files
modelstudioai__cli/packages/core/tests/skills-agents.test.ts

386 lines
16 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: recorded copy-fallback artifact is replaced; unrecorded dir stays skipped", 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, e.g. Windows)
mkdirSync(copyPath, { recursive: true });
writeFileSync(join(copyPath, "SKILL.md"), "stale copy");
// Without a lock record the dir is foreign → skipped, content untouched
const unrecorded = linkSkillToAgents("demo");
expect(unrecorded[0].mode).toBe("skipped");
expect(readFileSync(join(copyPath, "SKILL.md"), "utf-8")).toBe("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");
});
});