diff --git a/packages/cli/postinstall.js b/packages/cli/postinstall.js index a502a0b..8dd3f5e 100644 --- a/packages/cli/postinstall.js +++ b/packages/cli/postinstall.js @@ -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/ (sha256-.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 }); diff --git a/packages/commands/src/commands/skill/add.ts b/packages/commands/src/commands/skill/add.ts index 5cb6f97..073a80a 100644 --- a/packages/commands/src/commands/skill/add.ts +++ b/packages/commands/src/commands/skill/add.ts @@ -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, diff --git a/packages/commands/src/commands/skill/update.ts b/packages/commands/src/commands/skill/update.ts index 127e641..61e1b1f 100644 --- a/packages/commands/src/commands/skill/update.ts +++ b/packages/commands/src/commands/skill/update.ts @@ -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 => { 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) { diff --git a/packages/core/src/advisor/sync.ts b/packages/core/src/advisor/sync.ts index 6e36ce7..45d9cb4 100644 --- a/packages/core/src/advisor/sync.ts +++ b/packages/core/src/advisor/sync.ts @@ -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 { 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 diff --git a/packages/core/src/skills/agents.ts b/packages/core/src/skills/agents.ts index 135fa0e..1ddd369 100644 --- a/packages/core/src/skills/agents.ts +++ b/packages/core/src/skills/agents.ts @@ -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); } diff --git a/packages/core/src/skills/extract.ts b/packages/core/src/skills/extract.ts index 48a8a99..d8ed825 100644 --- a/packages/core/src/skills/extract.ts +++ b/packages/core/src/skills/extract.ts @@ -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(".."); } diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 58529b2..740d159 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -29,9 +29,11 @@ export { getAgentTargets, detectInstalledAgents, linkSkillToAgents, + fanOutSkillToAgents, unlinkSkillFromAgents, type AgentTarget, type LinkResult, + type FanoutOutcome, } from "./agents.ts"; export { installSkill, diff --git a/packages/core/src/skills/installer.ts b/packages/core/src/skills/installer.ts index 0bcba40..1f93d1e 100644 --- a/packages/core/src/skills/installer.ts +++ b/packages/core/src/skills/installer.ts @@ -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 { 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, }; } diff --git a/packages/core/tests/advisor-sync.test.ts b/packages/core/tests/advisor-sync.test.ts new file mode 100644 index 0000000..4f171e6 --- /dev/null +++ b/packages/core/tests/advisor-sync.test.ts @@ -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): Promise { + 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]); + }); +}); diff --git a/packages/core/tests/skills-agents.test.ts b/packages/core/tests/skills-agents.test.ts index eee877a..dc80ca3 100644 --- a/packages/core/tests/skills-agents.test.ts +++ b/packages/core/tests/skills-agents.test.ts @@ -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): Promise { 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"); + }); +}); diff --git a/packages/core/tests/skills-installer.test.ts b/packages/core/tests/skills-installer.test.ts index 63dd3d0..13dfcb7 100644 --- a/packages/core/tests/skills-installer.test.ts +++ b/packages/core/tests/skills-installer.test.ts @@ -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); + }); +});