mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(skills): align agent registry with upstream and harden cross-platform install
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
|
||||
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
|
||||
* legacy fallback to skill.tar.br when the entry has no valid object field
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
|
||||
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
|
||||
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
|
||||
* 5. Write ~/.bailian/wiki-sync-state.json
|
||||
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
|
||||
@@ -20,10 +21,12 @@
|
||||
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
|
||||
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
@@ -106,6 +109,9 @@ async function downloadBuffer(url) {
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
function isSafeEntryName(name) {
|
||||
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
|
||||
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
|
||||
if (name.includes("\\") || name.includes("\0")) return false;
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
@@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) {
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the publisher's deterministic content hash over an extracted directory
|
||||
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
|
||||
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
|
||||
*/
|
||||
function computeDirContentHash(dir) {
|
||||
const relPaths = [];
|
||||
const walk = (sub) => {
|
||||
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
|
||||
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) walk(rel);
|
||||
else if (dirent.isFile()) relPaths.push(rel);
|
||||
}
|
||||
};
|
||||
walk("");
|
||||
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
const hash = createHash("sha256");
|
||||
for (const rel of relPaths) {
|
||||
hash.update(rel);
|
||||
hash.update(readFileSync(join(dir, rel)));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
/** Atomic swap: tmpDir (same volume) → catalogDir. */
|
||||
function atomicSwap(tmpDir, catalogDir) {
|
||||
mkdirSync(dirname(catalogDir), { recursive: true });
|
||||
@@ -166,12 +196,22 @@ async function main() {
|
||||
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
|
||||
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
|
||||
|
||||
// 3. Extract to same-volume temp dir + atomic swap
|
||||
// 3. Extract to same-volume temp dir + integrity check + atomic swap
|
||||
const catalogDir = getCatalogDir();
|
||||
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBuf, tmpDir);
|
||||
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
|
||||
// before touching the canonical dir
|
||||
if (entry.contentHash.startsWith("sha256:")) {
|
||||
const actualContentHash = computeDirContentHash(tmpDir);
|
||||
if (actualContentHash !== entry.contentHash) {
|
||||
throw new Error(
|
||||
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
atomicSwap(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -56,7 +56,12 @@ export default defineCommand({
|
||||
return { name, status: "failed", reason: "skill not found in registry" };
|
||||
}
|
||||
try {
|
||||
const record = await installSkillWithFanout(name, entry, agents);
|
||||
const record = await installSkillWithFanout(
|
||||
name,
|
||||
entry,
|
||||
agents,
|
||||
lock.skills[name]?.links ?? [],
|
||||
);
|
||||
lock.skills[name] = record.lockEntry;
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
detectInstalledAgents,
|
||||
fanOutSkillToAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkillWithFanout,
|
||||
@@ -45,6 +46,7 @@ export default defineCommand({
|
||||
const lock = readSkillLock();
|
||||
const disk = new Set(listSkillDirsOnDisk());
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
const results: UpdateOutcome[] = [];
|
||||
const targets: string[] = [];
|
||||
if (requested === "all") {
|
||||
@@ -60,6 +62,11 @@ export default defineCommand({
|
||||
continue;
|
||||
}
|
||||
if (entry.contentHash === locked.contentHash && disk.has(name)) {
|
||||
// Self-healing: content unchanged, but still fill fan-out links for agents
|
||||
// detected since the last install (and refresh recorded copies); the merged
|
||||
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
|
||||
const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []);
|
||||
lock.skills[name] = { ...locked, links: fanout.links };
|
||||
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
|
||||
continue;
|
||||
}
|
||||
@@ -80,14 +87,18 @@ export default defineCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
return { name, status: "failed", reason: "skill not found in registry" };
|
||||
}
|
||||
try {
|
||||
const record = await installSkillWithFanout(name, entry, agents);
|
||||
const record = await installSkillWithFanout(
|
||||
name,
|
||||
entry,
|
||||
agents,
|
||||
lock.skills[name]?.links ?? [],
|
||||
);
|
||||
lock.skills[name] = record.lockEntry;
|
||||
return { name, status: "updated", publishedAt: entry.publishedAt };
|
||||
} catch (err) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
import { detectInstalledAgents, fanOutSkillToAgents } from "../skills/agents.ts";
|
||||
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
|
||||
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
|
||||
import { fetchSkillsIndex } from "../skills/registry.ts";
|
||||
@@ -90,12 +91,17 @@ function recordWikiInLock(lockEntry: SkillLockEntry): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */
|
||||
function wikiLockUpToDate(contentHash: string): boolean {
|
||||
/**
|
||||
* Whether the lock still needs a wiki backfill: content fingerprint mismatch, or the
|
||||
* record carries no fan-out links (postinstall writes contentHash only and never fans
|
||||
* out, so agents would otherwise never see the wiki skill until content changes).
|
||||
*/
|
||||
function wikiLockNeedsBackfill(contentHash: string): boolean {
|
||||
try {
|
||||
return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash;
|
||||
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
|
||||
return locked?.contentHash !== contentHash || !Array.isArray(locked.links);
|
||||
} catch {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,15 +142,25 @@ export async function maybeSyncWikiData(): Promise<boolean> {
|
||||
const dataOk = catalogDataExists();
|
||||
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
|
||||
writeState({ lastChecked: now, contentHash: entry.contentHash });
|
||||
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
|
||||
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, []));
|
||||
// Lock record missing/stale (e.g. postinstall wrote canonical only, without fan-out) → backfill
|
||||
if (wikiLockNeedsBackfill(entry.contentHash)) {
|
||||
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
|
||||
const fanout = fanOutSkillToAgents(WIKI_SKILL_NAME, detectInstalledAgents(), previousLinks);
|
||||
recordWikiInLock(buildSkillLockEntry(entry, fanout.links));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Different content or missing data: delegate to the shared skill install pipeline
|
||||
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
|
||||
try {
|
||||
const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry);
|
||||
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
|
||||
const record = await installSkillWithFanout(
|
||||
WIKI_SKILL_NAME,
|
||||
entry,
|
||||
detectInstalledAgents(),
|
||||
previousLinks,
|
||||
);
|
||||
recordWikiInLock(record.lockEntry);
|
||||
} catch {
|
||||
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
|
||||
|
||||
@@ -29,9 +29,16 @@ export interface AgentTarget {
|
||||
detectDirs: string[];
|
||||
}
|
||||
|
||||
/** Computed on each call (depends on homedir / XDG_CONFIG_HOME; easy to override in tests) */
|
||||
/**
|
||||
* Computed on each call (depends on homedir / XDG_CONFIG_HOME / cwd; easy to override in tests).
|
||||
* Registry mirrors the vercel-labs/skills agent list, minus agents that cannot participate in
|
||||
* global symlink fan-out (eve: no global dir, upstream forces direct writes; promptscript:
|
||||
* project-only). Shared-dir agents (Cline/Warp/Zed/Kimi/… read ~/.agents/skills; Amp/Replit
|
||||
* read $XDG_CONFIG_HOME/agents/skills) are folded into the universal pseudo-agents' detectDirs.
|
||||
*/
|
||||
export function getAgentTargets(): AgentTarget[] {
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
|
||||
const simple = (id: string, displayName: string, dir: string): AgentTarget => ({
|
||||
id,
|
||||
@@ -39,29 +46,194 @@ export function getAgentTargets(): AgentTarget[] {
|
||||
skillsDir: join(home, dir, "skills"),
|
||||
detectDirs: [join(home, dir)],
|
||||
});
|
||||
/** Config base dir that can be relocated via the agent's official env var */
|
||||
const envBase = (envValue: string | undefined, fallbackDir: string): string => {
|
||||
const trimmed = envValue?.trim();
|
||||
return trimmed ? trimmed : join(home, fallbackDir);
|
||||
};
|
||||
/** Target derived from an absolute base dir (detection and skills dir stay in sync) */
|
||||
const fromBase = (id: string, displayName: string, baseDir: string): AgentTarget => ({
|
||||
id,
|
||||
displayName,
|
||||
skillsDir: join(baseDir, "skills"),
|
||||
detectDirs: [baseDir],
|
||||
});
|
||||
|
||||
// OpenClaw was renamed over time (.openclaw → .clawdbot → .moltbot): link into the first
|
||||
// home that actually exists, detect any of them
|
||||
const openclawCandidates = [".openclaw", ".clawdbot", ".moltbot"].map((dir) => join(home, dir));
|
||||
const openclawHome = openclawCandidates.find((dir) => existsSync(dir)) ?? openclawCandidates[0]!;
|
||||
|
||||
// Zed's config_dir(): XDG on Linux/macOS, %APPDATA% on Windows, Flatpak override
|
||||
const zedDetectDirs = [join(xdgConfig, "zed")];
|
||||
const zedAppData = process.env.APPDATA?.trim();
|
||||
if (zedAppData) zedDetectDirs.push(join(zedAppData, "Zed"));
|
||||
const zedFlatpakConfig = process.env.FLATPAK_XDG_CONFIG_HOME?.trim();
|
||||
if (zedFlatpakConfig) zedDetectDirs.push(join(zedFlatpakConfig, "zed"));
|
||||
|
||||
const codexHome = envBase(process.env.CODEX_HOME, ".codex");
|
||||
|
||||
return [
|
||||
// universal pseudo-agent: ~/.agents/skills is a shared dir read by multiple agents (Cline, etc.)
|
||||
// universal pseudo-agent: ~/.agents/skills is a shared dir read by Cline, Warp, Zed,
|
||||
// Kimi Code, Dexto, Firebender, Loaf, …
|
||||
{
|
||||
id: "universal",
|
||||
displayName: "Universal (~/.agents/skills)",
|
||||
skillsDir: join(home, ".agents", "skills"),
|
||||
detectDirs: [join(home, ".agents"), join(home, ".cline")],
|
||||
detectDirs: [
|
||||
join(home, ".agents"),
|
||||
join(home, ".cline"),
|
||||
join(home, ".dexto"),
|
||||
join(home, ".firebender"),
|
||||
join(home, ".kimi-code"),
|
||||
join(home, ".kimi"),
|
||||
join(home, ".loaf"),
|
||||
join(home, ".warp"),
|
||||
...zedDetectDirs,
|
||||
],
|
||||
},
|
||||
simple("claude-code", "Claude Code", ".claude"),
|
||||
simple("openclaw", "OpenClaw", ".openclaw"),
|
||||
simple("hermes", "Hermes Agent", ".hermes"),
|
||||
// XDG variant: $XDG_CONFIG_HOME/agents/skills, shared dir read by Amp-style agents; Replit
|
||||
// also reads it and is detected project-locally via cwd/.replit
|
||||
{
|
||||
id: "universal-xdg",
|
||||
displayName: "Universal (XDG agents/skills)",
|
||||
skillsDir: join(xdgConfig, "agents", "skills"),
|
||||
detectDirs: [join(xdgConfig, "agents"), join(xdgConfig, "amp"), join(cwd, ".replit")],
|
||||
},
|
||||
simple("adal", "AdaL", ".adal"),
|
||||
simple("aider-desk", "AiderDesk", ".aider-desk"),
|
||||
simple("antigravity", "Antigravity", ".gemini/antigravity"),
|
||||
simple("antigravity-cli", "Antigravity CLI", ".gemini/antigravity-cli"),
|
||||
{
|
||||
id: "astrbot",
|
||||
displayName: "AstrBot",
|
||||
skillsDir: join(home, ".astrbot", "data", "skills"),
|
||||
detectDirs: [join(cwd, "data", "skills"), join(home, ".astrbot")],
|
||||
},
|
||||
fromBase("autohand-code", "Autohand Code CLI", envBase(process.env.AUTOHAND_HOME, ".autohand")),
|
||||
simple("augment", "Augment", ".augment"),
|
||||
simple("bob", "IBM Bob", ".bob"),
|
||||
fromBase("claude-code", "Claude Code", envBase(process.env.CLAUDE_CONFIG_DIR, ".claude")),
|
||||
simple("codearts-agent", "CodeArts Agent", ".codeartsdoer"),
|
||||
{
|
||||
id: "codebuddy",
|
||||
displayName: "CodeBuddy",
|
||||
skillsDir: join(home, ".codebuddy", "skills"),
|
||||
detectDirs: [join(cwd, ".codebuddy"), join(home, ".codebuddy")],
|
||||
},
|
||||
simple("codemaker", "Codemaker", ".codemaker"),
|
||||
simple("codestudio", "Code Studio", ".codestudio"),
|
||||
{
|
||||
id: "codex",
|
||||
displayName: "Codex",
|
||||
skillsDir: join(codexHome, "skills"),
|
||||
detectDirs: [codexHome, "/etc/codex"],
|
||||
},
|
||||
simple("command-code", "Command Code", ".commandcode"),
|
||||
{
|
||||
id: "continue",
|
||||
displayName: "Continue",
|
||||
skillsDir: join(home, ".continue", "skills"),
|
||||
detectDirs: [join(cwd, ".continue"), join(home, ".continue")],
|
||||
},
|
||||
simple("cortex", "Cortex Code", ".snowflake/cortex"),
|
||||
simple("crush", "Crush", ".config/crush"),
|
||||
simple("cursor", "Cursor", ".cursor"),
|
||||
{
|
||||
id: "deepagents",
|
||||
displayName: "Deep Agents",
|
||||
skillsDir: join(home, ".deepagents", "agent", "skills"),
|
||||
detectDirs: [join(home, ".deepagents")],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
displayName: "Devin for Terminal",
|
||||
skillsDir: join(xdgConfig, "devin", "skills"),
|
||||
detectDirs: [join(xdgConfig, "devin")],
|
||||
},
|
||||
simple("droid", "Droid", ".factory"),
|
||||
simple("forgecode", "ForgeCode", ".forge"),
|
||||
simple("gemini-cli", "Gemini CLI", ".gemini"),
|
||||
simple("github-copilot", "GitHub Copilot", ".copilot"),
|
||||
{
|
||||
id: "goose",
|
||||
displayName: "Goose",
|
||||
skillsDir: join(xdgConfig, "goose", "skills"),
|
||||
detectDirs: [join(xdgConfig, "goose")],
|
||||
},
|
||||
fromBase("grok", "Grok Build", envBase(process.env.GROK_HOME, ".grok")),
|
||||
fromBase("hermes", "Hermes Agent", envBase(process.env.HERMES_HOME, ".hermes")),
|
||||
simple("iflow-cli", "iFlow CLI", ".iflow"),
|
||||
simple("inference-sh", "inference.sh", ".inferencesh"),
|
||||
{
|
||||
id: "jazz",
|
||||
displayName: "Jazz",
|
||||
skillsDir: join(home, ".jazz", "skills"),
|
||||
detectDirs: [join(home, ".jazz"), join(cwd, ".jazz")],
|
||||
},
|
||||
simple("junie", "Junie", ".junie"),
|
||||
simple("kilo", "Kilo Code", ".kilocode"),
|
||||
{
|
||||
id: "kimchi",
|
||||
displayName: "Kimchi",
|
||||
skillsDir: join(home, ".config", "kimchi", "harness", "skills"),
|
||||
detectDirs: [join(home, ".config", "kimchi")],
|
||||
},
|
||||
simple("kiro-cli", "Kiro CLI", ".kiro"),
|
||||
simple("kode", "Kode", ".kode"),
|
||||
simple("lingma", "Lingma", ".lingma"),
|
||||
simple("mcpjam", "MCPJam", ".mcpjam"),
|
||||
{
|
||||
id: "minimax-code",
|
||||
displayName: "MiniMax Code",
|
||||
skillsDir: join(home, ".minimax", "skills"),
|
||||
detectDirs: [join(home, ".minimax"), "/Applications/MiniMax Code.app"],
|
||||
},
|
||||
fromBase("mistral-vibe", "Mistral Vibe", envBase(process.env.VIBE_HOME, ".vibe")),
|
||||
simple("moxby", "Moxby", ".moxby"),
|
||||
simple("mux", "Mux", ".mux"),
|
||||
simple("neovate", "Neovate", ".neovate"),
|
||||
{
|
||||
id: "opencode",
|
||||
displayName: "OpenCode",
|
||||
skillsDir: join(xdgConfig, "opencode", "skills"),
|
||||
detectDirs: [join(xdgConfig, "opencode")],
|
||||
},
|
||||
simple("cursor", "Cursor", ".cursor"),
|
||||
simple("codex", "Codex", ".codex"),
|
||||
simple("qwen-code", "Qwen Code", ".qwen"),
|
||||
{
|
||||
id: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
skillsDir: join(openclawHome, "skills"),
|
||||
detectDirs: openclawCandidates,
|
||||
},
|
||||
simple("openhands", "OpenHands", ".openhands"),
|
||||
simple("ona", "Ona", ".ona"),
|
||||
simple("pi", "Pi", ".pi/agent"),
|
||||
simple("pochi", "Pochi", ".pochi"),
|
||||
simple("qoder", "Qoder", ".qoder"),
|
||||
simple("qoder-cn", "Qoder CN", ".qoder-cn"),
|
||||
simple("kilo", "Kilo Code", ".kilocode"),
|
||||
simple("qwen-code", "Qwen Code", ".qwen"),
|
||||
simple("reasonix", "Reasonix", ".reasonix"),
|
||||
simple("rovodev", "Rovo Dev", ".rovodev"),
|
||||
simple("roo", "Roo Code", ".roo"),
|
||||
{
|
||||
id: "tabnine-cli",
|
||||
displayName: "Tabnine CLI",
|
||||
skillsDir: join(home, ".tabnine", "agent", "skills"),
|
||||
detectDirs: [join(home, ".tabnine")],
|
||||
},
|
||||
simple("terramind", "Terramind", ".terramind"),
|
||||
simple("tinycloud", "Tinycloud", ".tinycloud"),
|
||||
simple("trae", "Trae", ".trae"),
|
||||
simple("trae-cn", "Trae CN", ".trae-cn"),
|
||||
simple("windsurf", "Windsurf", ".codeium/windsurf"),
|
||||
{
|
||||
id: "zcode",
|
||||
displayName: "ZCode",
|
||||
skillsDir: join(home, ".zcode", "skills"),
|
||||
detectDirs: [join(home, ".zcode"), "/Applications/ZCode.app"],
|
||||
},
|
||||
// Zenflow reads the same ~/.zencoder/skills dir, so one target covers both
|
||||
simple("zencoder", "Zencoder", ".zencoder"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -69,13 +241,45 @@ export function detectInstalledAgents(): AgentTarget[] {
|
||||
return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir)));
|
||||
}
|
||||
|
||||
/** Path equality that respects the host filesystem's case rules (Windows is case-insensitive) */
|
||||
function samePath(left: string, right: string): boolean {
|
||||
if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase();
|
||||
return left === right;
|
||||
}
|
||||
|
||||
/** Whether absPath is the canonical skills dir or lives inside it (case-aware on Windows) */
|
||||
function isUnderCanonicalDir(absPath: string): boolean {
|
||||
const skillsDir = getSkillsDir();
|
||||
if (process.platform === "win32") {
|
||||
const lowerPath = absPath.toLowerCase();
|
||||
const lowerDir = skillsDir.toLowerCase();
|
||||
return lowerPath === lowerDir || lowerPath.startsWith(lowerDir + sep);
|
||||
}
|
||||
return absPath === skillsDir || absPath.startsWith(skillsDir + sep);
|
||||
}
|
||||
|
||||
/** Whether linkPath is managed by this tool: a symlink whose resolved target falls within the canonical skills dir */
|
||||
function isManagedLink(linkPath: string): boolean {
|
||||
try {
|
||||
if (!lstatSync(linkPath).isSymbolicLink()) return false;
|
||||
const target = readlinkSync(linkPath);
|
||||
const abs = isAbsolute(target) ? target : resolve(dirname(linkPath), target);
|
||||
return abs === getSkillsDir() || abs.startsWith(getSkillsDir() + sep);
|
||||
return isUnderCanonicalDir(abs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether linkPath is a copy-fallback artifact recorded in the lock: a real directory
|
||||
* (not a symlink) at a path this tool previously wrote when symlink creation failed
|
||||
* (typical: Windows without Developer Mode). Only recorded paths qualify — foreign
|
||||
* directories are never touched.
|
||||
*/
|
||||
function isRecordedCopy(linkPath: string, recordedLinks: string[]): boolean {
|
||||
if (!recordedLinks.some((recorded) => samePath(recorded, linkPath))) return false;
|
||||
try {
|
||||
return lstatSync(linkPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -88,15 +292,20 @@ export interface LinkResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Fixed skip reason for foreign paths; fanOutSkillToAgents keys ledger drops off this value */
|
||||
const UNMANAGED_SKIP_REASON = "existing file/dir not managed by bl skill";
|
||||
|
||||
/**
|
||||
* Fan out a skill from canonical to each agent's skills dir.
|
||||
* Stale links created by this tool are rebuilt; existing files/dirs NOT managed by this tool
|
||||
* are always skipped (never delete user content). Falls back to copy when symlink fails
|
||||
* (e.g. Windows without Developer Mode).
|
||||
* Stale links created by this tool are rebuilt; recorded copy-fallback artifacts
|
||||
* (real dirs at paths present in recordedLinks) are replaced with fresh content;
|
||||
* any other existing files/dirs are always skipped (never delete user content).
|
||||
* Falls back to copy when symlink fails (e.g. Windows without Developer Mode).
|
||||
*/
|
||||
export function linkSkillToAgents(
|
||||
name: string,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
recordedLinks: string[] = [],
|
||||
): LinkResult[] {
|
||||
const target = join(getSkillsDir(), name);
|
||||
const results: LinkResult[] = [];
|
||||
@@ -111,16 +320,21 @@ export function linkSkillToAgents(
|
||||
/* does not exist */
|
||||
}
|
||||
if (existing) {
|
||||
if (!isManagedLink(linkPath)) {
|
||||
if (isManagedLink(linkPath)) {
|
||||
rmSync(linkPath);
|
||||
} else if (isRecordedCopy(linkPath, recordedLinks)) {
|
||||
// Copy-fallback artifact from a previous install → replace so updates
|
||||
// reach agents that have no symlink permission
|
||||
rmSync(linkPath, { recursive: true, force: true });
|
||||
} else {
|
||||
results.push({
|
||||
agent: agent.id,
|
||||
path: linkPath,
|
||||
mode: "skipped",
|
||||
reason: "existing file/dir not managed by bl skill",
|
||||
reason: UNMANAGED_SKIP_REASON,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
rmSync(linkPath);
|
||||
}
|
||||
mkdirSync(agent.skillsDir, { recursive: true });
|
||||
try {
|
||||
@@ -143,6 +357,51 @@ export function linkSkillToAgents(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-out workflow: link to agents AND compute the next lock ledger in one step.
|
||||
* Shared by bl skill add/update (fresh install and self-healing) and advisor wiki sync,
|
||||
* so every channel applies the same ledger-merge rules.
|
||||
*/
|
||||
export interface FanoutOutcome {
|
||||
results: LinkResult[];
|
||||
/** Agent ids that actually received a link/copy this run (skipped ones excluded) */
|
||||
linkedAgents: string[];
|
||||
/** Next lock links ledger; see merge rules in fanOutSkillToAgents */
|
||||
links: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out and merge the resulting paths with the previously recorded ledger:
|
||||
* - effective paths from this run are recorded;
|
||||
* - recorded paths NOT visited this run are preserved (agent uninstalled/undetected —
|
||||
* the artifact may still exist and must stay reclaimable by bl skill remove);
|
||||
* - recorded paths that failed transiently this run are preserved for the same reason;
|
||||
* - recorded paths confirmed foreign this run (unmanaged skip) are dropped — the user
|
||||
* replaced our artifact, and keeping the record would let remove delete user content.
|
||||
*/
|
||||
export function fanOutSkillToAgents(
|
||||
name: string,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
recordedLinks: string[] = [],
|
||||
): FanoutOutcome {
|
||||
const results = linkSkillToAgents(name, agents, recordedLinks);
|
||||
const effective = results.filter((result) => result.mode !== "skipped");
|
||||
const effectivePaths = effective.map((result) => result.path);
|
||||
const confirmedForeign = results
|
||||
.filter((result) => result.mode === "skipped" && result.reason === UNMANAGED_SKIP_REASON)
|
||||
.map((result) => result.path);
|
||||
const preserved = recordedLinks.filter(
|
||||
(recorded) =>
|
||||
!effectivePaths.some((path) => samePath(path, recorded)) &&
|
||||
!confirmedForeign.some((path) => samePath(path, recorded)),
|
||||
);
|
||||
return {
|
||||
results,
|
||||
linkedAgents: effective.map((result) => result.agent),
|
||||
links: [...effectivePaths, ...preserved],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclaim fan-out artifacts for a skill across all agent dirs.
|
||||
* Symlinks pointing to canonical are removed (including historical links not in lock,
|
||||
@@ -166,7 +425,7 @@ export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []
|
||||
rmSync(linkPath);
|
||||
removed.push(linkPath);
|
||||
}
|
||||
} else if (recordedLinks.includes(linkPath)) {
|
||||
} else if (recordedLinks.some((recorded) => samePath(recorded, linkPath))) {
|
||||
rmSync(linkPath, { recursive: true, force: true });
|
||||
removed.push(linkPath);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import tar from "tar-stream";
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
export function isSafeEntryName(name: string): boolean {
|
||||
// Reject backslashes outright: on Windows path.join expands backslash-separated
|
||||
// ".." segments and a leading "\" resolves to the drive root, so such names can
|
||||
// escape the extraction dir even though they pass the "/"-based checks below.
|
||||
// The publisher always packs with "/" separators, so this never rejects legit archives.
|
||||
if (name.includes("\\") || name.includes("\0")) return false;
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@ export {
|
||||
getAgentTargets,
|
||||
detectInstalledAgents,
|
||||
linkSkillToAgents,
|
||||
fanOutSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
type AgentTarget,
|
||||
type LinkResult,
|
||||
type FanoutOutcome,
|
||||
} from "./agents.ts";
|
||||
export {
|
||||
installSkill,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { detectInstalledAgents, linkSkillToAgents, type AgentTarget } from "./agents.ts";
|
||||
import { detectInstalledAgents, fanOutSkillToAgents, type AgentTarget } from "./agents.ts";
|
||||
import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
import { downloadSkillAsset } from "./registry.ts";
|
||||
@@ -112,22 +112,21 @@ export interface SkillInstallRecord {
|
||||
|
||||
/**
|
||||
* Full install workflow for one skill: install into canonical, fan out to agents, and build
|
||||
* the lock entry recording effective links. Callers decide how to persist the lock entry
|
||||
* (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels).
|
||||
* the lock entry recording the merged links ledger. Callers decide how to persist the lock
|
||||
* entry (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels).
|
||||
* recordedLinks = the skill's previously recorded fan-out paths from the lock; lets the
|
||||
* fan-out replace copy-fallback artifacts and keeps unvisited paths reclaimable.
|
||||
*/
|
||||
export async function installSkillWithFanout(
|
||||
name: string,
|
||||
entry: SkillIndexEntry,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
recordedLinks: string[] = [],
|
||||
): Promise<SkillInstallRecord> {
|
||||
await installSkill(name, entry);
|
||||
const links = linkSkillToAgents(name, agents);
|
||||
const effective = links.filter((link) => link.mode !== "skipped");
|
||||
const fanout = fanOutSkillToAgents(name, agents, recordedLinks);
|
||||
return {
|
||||
lockEntry: buildSkillLockEntry(
|
||||
entry,
|
||||
effective.map((link) => link.path),
|
||||
),
|
||||
linkedAgents: effective.map((link) => link.agent),
|
||||
lockEntry: buildSkillLockEntry(entry, fanout.links),
|
||||
linkedAgents: fanout.linkedAgents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
import { maybeSyncWikiData } from "../src/advisor/sync.ts";
|
||||
import { readSkillLock } from "../src/skills/lock.ts";
|
||||
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const CONTENT_HASH = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
/** Isolated HOME/XDG/BAILIAN_CONFIG_DIR; agent config-dir overrides cleared for determinism */
|
||||
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,
|
||||
};
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-advisor-sync-"));
|
||||
process.env.HOME = home;
|
||||
process.env.XDG_CONFIG_HOME = join(home, ".config");
|
||||
process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian");
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.CODEX_HOME;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub the registry fetch to serve a wiki entry with the given fingerprint */
|
||||
function stubRegistryIndex() {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
skills: {
|
||||
[WIKI_SKILL_NAME]: { contentHash: CONTENT_HASH, publishedAt: "2026-08-01 10:00:00" },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Seed what postinstall leaves behind: canonical data + expired state + lock entry WITHOUT links */
|
||||
function seedPostinstallState(configDir: string): void {
|
||||
const catalogDir = join(configDir, "skills", WIKI_SKILL_NAME);
|
||||
mkdirSync(join(catalogDir, "models"), { recursive: true });
|
||||
writeFileSync(join(catalogDir, "models", "models.jsonl"), "{}\n");
|
||||
writeFileSync(join(catalogDir, "SKILL.md"), "---\nname: wiki\ndescription: docs\n---\n");
|
||||
// State older than the 12h throttle so the hash-hit branch is reached
|
||||
writeFileSync(
|
||||
join(configDir, "wiki-sync-state.json"),
|
||||
JSON.stringify({ lastChecked: Date.now() - 13 * 3600_000, contentHash: CONTENT_HASH }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(configDir, "skills", "skill-lock.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
skills: {
|
||||
[WIKI_SKILL_NAME]: {
|
||||
contentHash: CONTENT_HASH,
|
||||
installedAt: "2026-08-01T00:00:00.000Z",
|
||||
sourceType: "oss",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("advisor sync: hash-hit backfills fan-out links missing from postinstall lock entry", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!);
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
const fetchMock = stubRegistryIndex();
|
||||
|
||||
const updated = await maybeSyncWikiData();
|
||||
|
||||
// Content unchanged → no data update, but the fan-out gap is repaired
|
||||
expect(updated).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
|
||||
expect(locked?.links).toEqual([join(home, ".claude", "skills", WIKI_SKILL_NAME)]);
|
||||
|
||||
// Second run: fresh throttle + links already recorded → no fetch, no rewrite
|
||||
await maybeSyncWikiData();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("advisor sync: hash-hit keeps an existing links array untouched", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!);
|
||||
// Upgrade the lock entry to "already fanned out" shape
|
||||
const existingLink = join(home, ".claude", "skills", WIKI_SKILL_NAME);
|
||||
const lockPath = join(process.env.BAILIAN_CONFIG_DIR!, "skills", "skill-lock.json");
|
||||
const lockContent = JSON.parse(readFileSync(lockPath, "utf-8"));
|
||||
lockContent.skills[WIKI_SKILL_NAME].links = [existingLink];
|
||||
writeFileSync(lockPath, JSON.stringify(lockContent));
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
stubRegistryIndex();
|
||||
|
||||
await maybeSyncWikiData();
|
||||
|
||||
expect(readSkillLock().skills[WIKI_SKILL_NAME]?.links).toEqual([existingLink]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
detectInstalledAgents,
|
||||
fanOutSkillToAgents,
|
||||
getAgentTargets,
|
||||
linkSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
@@ -28,11 +29,32 @@ async function inFakeHome(fn: (home: string) => Promise<void>): Promise<void> {
|
||||
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 {
|
||||
@@ -52,10 +74,16 @@ function seedCanonicalSkill(name: string): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("agents: registry has universal + 11 agents, only detects those whose config dir exists", async () => {
|
||||
test("agents: registry mirrors upstream agent list minus non-symlinkable agents", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
expect(getAgentTargets().map((a) => a.id)).toContain("universal");
|
||||
expect(getAgentTargets()).toHaveLength(11);
|
||||
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 });
|
||||
@@ -68,6 +96,100 @@ test("agents: registry has universal + 11 agents, only detects those whose confi
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -127,3 +249,137 @@ test("agents: unlink reclaims managed links, leaves foreign content untouched",
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
||||
import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
||||
import { createHash } from "crypto";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { brotliCompressSync } from "zlib";
|
||||
import tar from "tar-stream";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { installSkillFromBuffer } from "../src/skills/installer.ts";
|
||||
import type { AgentTarget } from "../src/skills/agents.ts";
|
||||
import { isSafeEntryName } from "../src/skills/extract.ts";
|
||||
import { installSkillFromBuffer, installSkillWithFanout } from "../src/skills/installer.ts";
|
||||
import { getSkillsDir } from "../src/skills/lock.ts";
|
||||
|
||||
/** Run in an isolated temp config dir, restore env afterwards. */
|
||||
@@ -82,6 +84,31 @@ test("installer: tar-slip entry → rejected and canonical not written", async (
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: backslash entry names rejected (Windows tar-slip vector)", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({
|
||||
"SKILL.md": VALID_SKILL_MD,
|
||||
"foo\\..\\evil.txt": "pwned\n",
|
||||
});
|
||||
await expect(installSkillFromBuffer("demo", buf)).rejects.toThrow(/unsafe tar entry/);
|
||||
expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("extract: entry name safety rules", async () => {
|
||||
expect(isSafeEntryName("SKILL.md")).toBe(true);
|
||||
expect(isSafeEntryName("references/usage.md")).toBe(true);
|
||||
expect(isSafeEntryName("../evil")).toBe(false);
|
||||
expect(isSafeEntryName("a/../../evil")).toBe(false);
|
||||
expect(isSafeEntryName("/abs/path")).toBe(false);
|
||||
expect(isSafeEntryName("C:/windows")).toBe(false);
|
||||
// Backslashes: drive-root escape and "\.." expansion on Windows
|
||||
expect(isSafeEntryName("foo\\bar")).toBe(false);
|
||||
expect(isSafeEntryName("\\evil")).toBe(false);
|
||||
expect(isSafeEntryName("foo\\..\\evil")).toBe(false);
|
||||
expect(isSafeEntryName("nul\0byte")).toBe(false);
|
||||
});
|
||||
|
||||
test("installer: SKILL.md validation fails → previously installed version preserved as-is", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD }));
|
||||
@@ -136,3 +163,77 @@ test("installer: contentHash mismatch → rejected, previous install preserved",
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- installSkillWithFanout: download + install + fan-out + lock entry in one workflow ----
|
||||
|
||||
/** Stub global fetch to serve the given archive for any asset URL */
|
||||
function stubAssetDownload(tarBrBuffer: Buffer): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () =>
|
||||
tarBrBuffer.buffer.slice(
|
||||
tarBrBuffer.byteOffset,
|
||||
tarBrBuffer.byteOffset + tarBrBuffer.byteLength,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fake agent whose skills dir lives inside the temp config dir (never touches real HOME) */
|
||||
function fakeAgent(id: string, baseDir: string): AgentTarget {
|
||||
return {
|
||||
id,
|
||||
displayName: id,
|
||||
skillsDir: join(baseDir, id, "skills"),
|
||||
detectDirs: [join(baseDir, id)],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("fanout install: downloads, links agents, and builds lock entry with merged ledger", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const configDir = process.env.BAILIAN_CONFIG_DIR!;
|
||||
const files = { "SKILL.md": VALID_SKILL_MD };
|
||||
stubAssetDownload(await buildTarBr(files));
|
||||
const agent = fakeAgent("claude-code", configDir);
|
||||
// Recorded path of an agent absent from this run: must survive into the merged ledger
|
||||
const orphanPath = join(configDir, "gone-agent", "skills", "demo");
|
||||
|
||||
const record = await installSkillWithFanout(
|
||||
"demo",
|
||||
{ contentHash: expectedHashOf(files), publishedAt: "2026-08-01 10:00:00" },
|
||||
[agent],
|
||||
[orphanPath],
|
||||
);
|
||||
|
||||
expect(record.linkedAgents).toEqual(["claude-code"]);
|
||||
const linkPath = join(agent.skillsDir, "demo");
|
||||
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(record.lockEntry).toMatchObject({
|
||||
contentHash: expectedHashOf(files),
|
||||
publishedAt: "2026-08-01 10:00:00",
|
||||
sourceType: "oss",
|
||||
});
|
||||
expect(record.lockEntry.links).toContain(linkPath);
|
||||
expect(record.lockEntry.links).toContain(orphanPath);
|
||||
});
|
||||
});
|
||||
|
||||
test("fanout install: download failure surfaces as BailianError and leaves no canonical dir", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false, status: 404 })),
|
||||
);
|
||||
await expect(
|
||||
installSkillWithFanout("demo", { contentHash: "sha256:whatever" }, []),
|
||||
).rejects.toThrow(BailianError);
|
||||
expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user