Merge remote-tracking branch 'refs/remotes/origin/main' into feat/update-defmodel

This commit is contained in:
clh02467605
2026-08-05 17:08:44 +08:00
11 changed files with 858 additions and 46 deletions
+42 -2
View File
@@ -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 });
+6 -1
View File
@@ -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,
+13 -2
View File
@@ -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 -7
View File
@@ -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
+277 -18
View File
@@ -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);
}
+5
View File
@@ -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("..");
}
+2
View File
@@ -29,9 +29,11 @@ export {
getAgentTargets,
detectInstalledAgents,
linkSkillToAgents,
fanOutSkillToAgents,
unlinkSkillFromAgents,
type AgentTarget,
type LinkResult,
type FanoutOutcome,
} from "./agents.ts";
export {
installSkill,
+9 -10
View File
@@ -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,
};
}
+118
View File
@@ -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]);
});
});
+259 -3
View File
@@ -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");
});
});
+104 -3
View 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);
});
});