mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat: add skill commend
This commit is contained in:
+89
-38
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* postinstall.js —— Wiki 数据同步(第一层:npm install 触发)
|
||||
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
|
||||
*
|
||||
* npm/pnpm 装完 bailian-cli 后自动执行:无条件下载全量 Wiki 数据包并覆盖本地目录,
|
||||
* 保证用户首次使用 `bl advisor recommend` 时数据已就位。
|
||||
* Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
|
||||
* package and overwrites the local directory, ensuring data is in place the first time the user runs
|
||||
* `bl advisor recommend`.
|
||||
*
|
||||
* 流程:
|
||||
* 1. 从公共读 OSS 直链下载 manifest.json + wiki-doc-full.tar.br(~2.15MB)
|
||||
* 2. 校验 sha256
|
||||
* 3. Node 原生 brotli 解压 + tar-stream 解包到同盘临时目录
|
||||
* 4. renameSync 原子替换到 ~/.bailian/skills/bailian-docs-llm-wiki/
|
||||
* 5. 写 ~/.bailian/wiki-sync-state.json
|
||||
* Flow (unified skill publishing protocol: skills/index.json + one skill.tar.br per skill):
|
||||
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
|
||||
* 2. Download skills/bailian-docs-llm-wiki/skill.tar.br (brotli q6, ~2.3MB)
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
|
||||
* 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)
|
||||
*
|
||||
* 设计约束:
|
||||
* - 无条件覆盖:每次 install 都全量替换,不比对已有版本
|
||||
* - 失败静默:任何一步失败 → console.warn → process.exit(0),绝不阻塞安装
|
||||
* - 独立实现:不 import bailian-cli-core,避免打包后 ESM 路径问题
|
||||
* - 依赖 Node 原生模块 + tar-stream(与 sync.ts / Crawler oss-upload.mjs 一致)
|
||||
* Design constraints:
|
||||
* - Unconditional overwrite: every install fully replaces, no version comparison
|
||||
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
|
||||
* - 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,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
@@ -33,14 +35,15 @@ import { pipeline } from "node:stream/promises";
|
||||
import { createBrotliDecompress } from "node:zlib";
|
||||
import tar from "tar-stream";
|
||||
|
||||
const OSS_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/bailian-docs-llm-wiki";
|
||||
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const CONFIG_DIR_NAME = ".bailian";
|
||||
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
|
||||
const STATE_FILE_NAME = "wiki-sync-state.json";
|
||||
const MANIFEST_KEY = "manifest.json";
|
||||
const ASSET_KEY = "wiki-doc-full.tar.br";
|
||||
const INDEX_KEY = "index.json";
|
||||
const ASSET_NAME = "skill.tar.br";
|
||||
|
||||
const MANIFEST_TIMEOUT_MS = 3000;
|
||||
const INDEX_TIMEOUT_MS = 3000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
function getConfigDir() {
|
||||
@@ -56,6 +59,35 @@ function getStatePath() {
|
||||
return join(getConfigDir(), STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function getSkillLockPath() {
|
||||
return join(getConfigDir(), "skills", "skill-lock.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
|
||||
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
|
||||
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
|
||||
* best-effort: failure does not affect data sync results.
|
||||
*/
|
||||
function upsertSkillLock(name, entry) {
|
||||
try {
|
||||
let lock = { version: 1, skills: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
|
||||
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
|
||||
lock = parsed;
|
||||
}
|
||||
} catch {
|
||||
/* absent/corrupted → empty table */
|
||||
}
|
||||
lock.skills[name] = { ...lock.skills[name], ...entry };
|
||||
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
||||
} catch {
|
||||
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, timeoutMs) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -68,11 +100,21 @@ async function downloadBuffer(url) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/** brotli 解压 + tar-stream 解包到 destDir(与 Crawler tar.pack() 对称)。 */
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
function isSafeEntryName(name) {
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
|
||||
async function extractTarBr(tarBrBuffer, destDir) {
|
||||
const extract = tar.extract();
|
||||
|
||||
extract.on("entry", (header, stream, next) => {
|
||||
if (!isSafeEntryName(header.name)) {
|
||||
next(new Error(`unsafe tar entry: ${header.name}`));
|
||||
return;
|
||||
}
|
||||
const filePath = join(destDir, header.name);
|
||||
if (header.type === "directory") {
|
||||
mkdirSync(filePath, { recursive: true });
|
||||
@@ -90,7 +132,7 @@ async function extractTarBr(tarBrBuffer, destDir) {
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/** 原子替换:tmpDir(同盘)→ catalogDir。 */
|
||||
/** Atomic swap: tmpDir (same volume) → catalogDir. */
|
||||
function atomicSwap(tmpDir, catalogDir) {
|
||||
mkdirSync(dirname(catalogDir), { recursive: true });
|
||||
const backup = `${catalogDir}.old-${Date.now()}`;
|
||||
@@ -105,18 +147,16 @@ function atomicSwap(tmpDir, catalogDir) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. 下载 manifest
|
||||
const manifest = await fetchJson(`${OSS_BASE_URL}/${MANIFEST_KEY}`, MANIFEST_TIMEOUT_MS);
|
||||
if (!manifest?.version) throw new Error("manifest 无 version");
|
||||
// 1. Download skills/index.json and get the wiki entry
|
||||
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
|
||||
const entry = index?.skills?.[WIKI_SKILL_NAME];
|
||||
if (!entry?.contentHash)
|
||||
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
|
||||
|
||||
// 2. 下载 tar.br + 校验
|
||||
const tarBuf = await downloadBuffer(`${OSS_BASE_URL}/${ASSET_KEY}`);
|
||||
const sha256 = createHash("sha256").update(tarBuf).digest("hex");
|
||||
if (manifest.asset?.sha256 && sha256 !== manifest.asset.sha256) {
|
||||
throw new Error("sha256 校验失败");
|
||||
}
|
||||
// 2. Download skill.tar.br (per-entry path safety check during extraction)
|
||||
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${ASSET_NAME}`);
|
||||
|
||||
// 3. 解包到同盘临时目录 + 原子替换
|
||||
// 3. Extract to same-volume temp dir + atomic swap
|
||||
const catalogDir = getCatalogDir();
|
||||
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
@@ -128,24 +168,35 @@ async function main() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 4. 写 state
|
||||
// 4. Write state
|
||||
try {
|
||||
writeFileSync(
|
||||
getStatePath(),
|
||||
JSON.stringify({ lastChecked: Date.now(), version: manifest.version }),
|
||||
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
|
||||
);
|
||||
} catch {
|
||||
/* state 写失败不影响:首次 recommend 会重新检查 */
|
||||
/* state write failure has no impact: first recommend will re-check */
|
||||
}
|
||||
|
||||
process.stdout.write(`bailian-cli: wiki 数据已就绪 (v${manifest.version})\n`);
|
||||
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
|
||||
upsertSkillLock(WIKI_SKILL_NAME, {
|
||||
contentHash: entry.contentHash,
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
});
|
||||
|
||||
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// 无条件放行:安装期网络/权限问题不应阻塞 npm install,
|
||||
// 首次 `bl advisor recommend` 时 sync.ts 会兜底同步。
|
||||
// Unconditional pass-through: install-time network/permission issues should not block npm install;
|
||||
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`bailian-cli: wiki 数据预下载跳过 (${msg}),首次使用时将自动同步。\n`);
|
||||
process.stderr.write(
|
||||
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
|
||||
);
|
||||
// Force a success exit code so a download failure never fails `npm install`.
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
|
||||
@@ -83,6 +83,10 @@ import {
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
skillAdd,
|
||||
skillUpdate,
|
||||
skillRemove,
|
||||
skillList,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
@@ -174,4 +178,8 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
"skill add": skillAdd,
|
||||
"skill update": skillUpdate,
|
||||
"skill remove": skillRemove,
|
||||
"skill list": skillList,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
detectInstalledAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkill,
|
||||
linkSkillToAgents,
|
||||
readSkillLock,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { parseSkillNames } from "./shared.ts";
|
||||
|
||||
interface AddOutcome {
|
||||
name: string;
|
||||
status: "installed" | "failed";
|
||||
publishedAt?: string;
|
||||
agents?: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Install skills from the Bailian skill registry into local agents",
|
||||
auth: "none",
|
||||
usageArgs: "[--name <all|name,...>]",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<all|name,...>",
|
||||
description: "Skills to install: all (default) or comma-separated skill names",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--name all", "--name spark-video,bailian-model-recommend"],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
const requested = parseSkillNames(ctx.flags.name, true);
|
||||
const index = await fetchSkillsIndex();
|
||||
const remoteNames = Object.keys(index.skills);
|
||||
const names = requested === "all" ? remoteNames : requested;
|
||||
|
||||
const lock = readSkillLock();
|
||||
const agents = detectInstalledAgents();
|
||||
const results: AddOutcome[] = [];
|
||||
|
||||
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual
|
||||
for (const name of names) {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
results.push({ name, status: "failed", reason: "skill not found in registry" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await installSkill(name, entry);
|
||||
const links = linkSkillToAgents(name, agents);
|
||||
const effective = links.filter((link) => link.mode !== "skipped");
|
||||
lock.skills[name] = {
|
||||
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
links: effective.map((link) => link.path),
|
||||
};
|
||||
results.push({
|
||||
name,
|
||||
status: "installed",
|
||||
publishedAt: entry.publishedAt,
|
||||
agents: effective.map((link) => link.agent),
|
||||
});
|
||||
} catch (err) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{ registry: getSkillRegistryBaseUrl(), agents: agents.map((a) => a.id), skills: results },
|
||||
format,
|
||||
);
|
||||
} else if (results.length === 0) {
|
||||
emitBare("Skill registry is empty; no skills to install.");
|
||||
} else {
|
||||
const rows = results.map((r) => [
|
||||
r.name,
|
||||
r.status,
|
||||
r.publishedAt ? r.publishedAt.slice(0, 10) : "-",
|
||||
r.status === "installed" ? r.agents?.join(", ") || "-" : (r.reason ?? "-"),
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
|
||||
emitBare(line);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.status === "failed");
|
||||
if (failed.length > 0) {
|
||||
throw new BailianError(
|
||||
`${failed.length}/${results.length} skill(s) failed to install`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the reason for failed skills in the output; network failures can be retried with bl skill add",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
computeSkillStatuses,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
listSkillDirsOnDisk,
|
||||
readSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const DESCRIPTION_MAX = 60;
|
||||
|
||||
function truncate(text: string | undefined): string {
|
||||
if (!text) return "-";
|
||||
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1)}…` : text;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "List registry skills and diff against local installs",
|
||||
auth: "none",
|
||||
exampleArgs: ["", "--output json"],
|
||||
notes: [
|
||||
"STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)",
|
||||
],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
// Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk
|
||||
const index = await fetchSkillsIndex();
|
||||
const lock = readSkillLock();
|
||||
const rows = computeSkillStatuses(index, lock, listSkillDirsOnDisk());
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
registry: getSkillRegistryBaseUrl(),
|
||||
...(index.updatedAt ? { updatedAt: index.updatedAt } : {}),
|
||||
skills: rows,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
emitBare("Skill registry is empty and no skills are installed locally.");
|
||||
return;
|
||||
}
|
||||
const table = rows.map((row) => [
|
||||
row.name,
|
||||
row.status,
|
||||
row.publishedAt ? row.publishedAt.slice(0, 10) : "-",
|
||||
truncate(row.description),
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "UpdatedAt", "DESCRIPTION"], table)) {
|
||||
emitBare(line);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listSkillDirsOnDisk,
|
||||
readSkillLock,
|
||||
removeSkillDir,
|
||||
unlinkSkillFromAgents,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { parseSkillNames } from "./shared.ts";
|
||||
|
||||
interface RemoveOutcome {
|
||||
name: string;
|
||||
status: "removed" | "failed";
|
||||
removedLinks?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Remove locally installed skills (registry is untouched)",
|
||||
auth: "none",
|
||||
usageArgs: "--name <all|name,...>",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<all|name,...>",
|
||||
description: "Skills to remove: all or comma-separated skill names",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--name spark-video", "--name all"],
|
||||
async run(ctx) {
|
||||
// Purely local operation: no remote access, works offline
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
const requested = parseSkillNames(ctx.flags.name, false);
|
||||
const lock = readSkillLock();
|
||||
const names = requested === "all" ? Object.keys(lock.skills) : requested;
|
||||
|
||||
if (names.length === 0) {
|
||||
emitResult({ skills: [] }, format);
|
||||
if (format === "text") emitBare("No skills installed locally; nothing to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
const diskDirs = new Set(listSkillDirsOnDisk());
|
||||
const results: RemoveOutcome[] = [];
|
||||
for (const name of names) {
|
||||
const locked = lock.skills[name];
|
||||
if (!locked) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: diskDirs.has(name)
|
||||
? "directory not managed by bl skill (untracked); remove manually if needed"
|
||||
: "not installed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Reclaim agent fan-out first, then delete canonical, finally clear the lock entry
|
||||
const removedLinks = unlinkSkillFromAgents(name, locked.links ?? []);
|
||||
removeSkillDir(name);
|
||||
delete lock.skills[name];
|
||||
results.push({ name, status: "removed", removedLinks: removedLinks.length });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ skills: results }, format);
|
||||
} else {
|
||||
const rows = results.map((r) => [
|
||||
r.name,
|
||||
r.status,
|
||||
r.status === "removed" ? `reclaimed ${r.removedLinks} agent link(s)` : (r.reason ?? "-"),
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "DETAIL"], rows)) {
|
||||
emitBare(line);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.status === "failed");
|
||||
if (failed.length > 0) {
|
||||
throw new BailianError(
|
||||
`${failed.length}/${results.length} skill(s) failed to remove`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the reason for failed skills in the output; use bl skill list to verify local install status",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { UsageError } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Parse --name: `all` or a comma-separated list of skill names (deduplicated, trimmed).
|
||||
* `all` cannot be mixed with specific names.
|
||||
*/
|
||||
export function parseSkillNames(raw: string | undefined, defaultAll: boolean): string[] | "all" {
|
||||
const value = (raw ?? (defaultAll ? "all" : "")).trim();
|
||||
if (!value) {
|
||||
throw new UsageError("--name cannot be empty", "Use --name all or --name skill-a,skill-b");
|
||||
}
|
||||
const parts = [
|
||||
...new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (parts.includes("all")) {
|
||||
if (parts.length > 1) {
|
||||
throw new UsageError(
|
||||
"--name all cannot be mixed with specific skill names",
|
||||
"Use either all or a comma-separated list of names",
|
||||
);
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
detectInstalledAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkill,
|
||||
linkSkillToAgents,
|
||||
listSkillDirsOnDisk,
|
||||
readSkillLock,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { parseSkillNames } from "./shared.ts";
|
||||
|
||||
interface UpdateOutcome {
|
||||
name: string;
|
||||
status: "updated" | "up-to-date" | "skipped" | "failed";
|
||||
publishedAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update installed skills to the latest registry versions",
|
||||
auth: "none",
|
||||
usageArgs: "[--name <all|name,...>]",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<all|name,...>",
|
||||
description:
|
||||
"Skills to update: all (default, only changed ones) or comma-separated names (force reinstall)",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--name spark-video"],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
const requested = parseSkillNames(ctx.flags.name, true);
|
||||
const index = await fetchSkillsIndex();
|
||||
const lock = readSkillLock();
|
||||
const disk = new Set(listSkillDirsOnDisk());
|
||||
|
||||
const results: UpdateOutcome[] = [];
|
||||
const targets: string[] = [];
|
||||
if (requested === "all") {
|
||||
// Default: only process skills already installed in lock; reinstall only if version changed or local dir is missing
|
||||
for (const [name, locked] of Object.entries(lock.skills)) {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
results.push({
|
||||
name,
|
||||
status: "skipped",
|
||||
reason: "delisted from remote; local copy retained",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.contentHash === locked.contentHash && disk.has(name)) {
|
||||
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
|
||||
continue;
|
||||
}
|
||||
targets.push(name);
|
||||
}
|
||||
} else {
|
||||
// Explicit names = force reinstall (equivalent to add if not yet installed)
|
||||
targets.push(...requested);
|
||||
}
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
for (const name of targets) {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
results.push({ name, status: "failed", reason: "skill not found in registry" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await installSkill(name, entry);
|
||||
const links = linkSkillToAgents(name, agents);
|
||||
const effective = links.filter((link) => link.mode !== "skipped");
|
||||
lock.skills[name] = {
|
||||
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
links: effective.map((link) => link.path),
|
||||
};
|
||||
results.push({ name, status: "updated", publishedAt: entry.publishedAt });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ registry: getSkillRegistryBaseUrl(), skills: results }, format);
|
||||
} else if (results.length === 0) {
|
||||
emitBare("No skills installed locally; run bl skill add first.");
|
||||
} else {
|
||||
const rows = results.map((r) => [
|
||||
r.name,
|
||||
r.status,
|
||||
r.publishedAt ? r.publishedAt.slice(0, 10) : "-",
|
||||
r.reason ?? "-",
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "REASON"], rows)) {
|
||||
emitBare(line);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.status === "failed");
|
||||
if (failed.length > 0) {
|
||||
throw new BailianError(
|
||||
`${failed.length} skill(s) failed to update`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the reason for failed skills in the output; network failures can be retried with bl skill update",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -90,3 +90,7 @@ export { default as pluginInstall } from "./commands/plugin/install.ts";
|
||||
export { default as pluginLink } from "./commands/plugin/link.ts";
|
||||
export { default as pluginList } from "./commands/plugin/list.ts";
|
||||
export { default as pluginRemove } from "./commands/plugin/remove.ts";
|
||||
export { default as skillAdd } from "./commands/skill/add.ts";
|
||||
export { default as skillUpdate } from "./commands/skill/update.ts";
|
||||
export { default as skillRemove } from "./commands/skill/remove.ts";
|
||||
export { default as skillList } from "./commands/skill/list.ts";
|
||||
|
||||
+103
-113
@@ -1,63 +1,61 @@
|
||||
/**
|
||||
* sync.ts —— Wiki 数据同步(第二层:recommend 触发)
|
||||
* sync.ts — Wiki data sync (layer 2: triggered by recommend)
|
||||
*
|
||||
* `bl advisor recommend` 执行时调用 `maybeSyncWikiData()`:
|
||||
* 1. 12h throttle:距上次检查不足 12h 直接跳过
|
||||
* 2. 从公共读 OSS 下载 manifest.json 比对版本
|
||||
* 3. 版本相同 → 仅刷新 lastChecked
|
||||
* 4. 版本不同 → 下载 tar.br → 校验 sha256 → brotli 解压 + tar-stream 解包
|
||||
* 到同盘临时目录 → renameSync 原子替换 → 写 state
|
||||
* Called via `maybeSyncWikiData()` during `bl advisor recommend`:
|
||||
* 1. 12h throttle: skip if last check was less than 12h ago
|
||||
* 2. Download skills/index.json from public-read OSS, compare bailian-docs-llm-wiki entry version
|
||||
* 3. Same version → only refresh lastChecked
|
||||
* 4. Different version → download skills/bailian-docs-llm-wiki/skill.tar.br → brotli decompress +
|
||||
* tar-stream extract (per-entry path safety check) to same-volume temp dir → renameSync atomic swap
|
||||
* → write state + skill-lock.json record (same ledger as bl skill add; list shows installed)
|
||||
*
|
||||
* 与 postinstall.js(第一层,npm install 无条件覆盖)互补。二者都用
|
||||
* Node 原生 brotli + tar-stream extract(),与 Crawler 端 tar.pack() 对称。
|
||||
* Protocol: unified skill publishing protocol (FC publish-skills, all skills are isomorphic), entry point is
|
||||
* skills/index.json, one skill.tar.br per skill (brotli q6).
|
||||
*
|
||||
* 失败策略:任何一步失败都静默返回且不更新 lastChecked,下次 recommend 立即重试。
|
||||
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Extraction and atomic swap
|
||||
* reuse skills/extract.ts (same as bl skill installer), symmetric with publisher tar.pack().
|
||||
*
|
||||
* Failure strategy: any step failure silently returns without updating lastChecked; next recommend retries immediately.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createBrotliDecompress } from "node:zlib";
|
||||
import tar from "tar-stream";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
import { atomicSwap, extractTarBr } from "../skills/extract.ts";
|
||||
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
|
||||
|
||||
/** 公共读 OSS 目录,直链下载(硬编码,不走 env)。 */
|
||||
const OSS_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/bailian-docs-llm-wiki";
|
||||
/** Public-read OSS skill registry root (hardcoded, does not use env). */
|
||||
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
|
||||
const STATE_FILE_NAME = "wiki-sync-state.json";
|
||||
const MODELS_FILE = "models.jsonl";
|
||||
const MANIFEST_KEY = "manifest.json";
|
||||
const ASSET_KEY = "wiki-doc-full.tar.br";
|
||||
const INDEX_KEY = "index.json";
|
||||
const ASSET_NAME = "skill.tar.br";
|
||||
|
||||
const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h
|
||||
const MANIFEST_TIMEOUT_MS = 3000;
|
||||
const INDEX_TIMEOUT_MS = 3000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
interface SyncState {
|
||||
lastChecked: number;
|
||||
version: string;
|
||||
/** Content fingerprint of the last synced revision; the change-detection token */
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
name: string;
|
||||
version: string;
|
||||
/** A single skill entry in skills/index.json (unified publishing protocol) */
|
||||
interface IndexSkillEntry {
|
||||
/** Reserved for the skill's own semantic version; not used for change detection */
|
||||
version?: string;
|
||||
publishedAt?: string;
|
||||
asset: {
|
||||
name: string;
|
||||
url?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
compression: string;
|
||||
};
|
||||
description?: string;
|
||||
contentHash?: string;
|
||||
compression?: string;
|
||||
}
|
||||
|
||||
interface SkillsIndex {
|
||||
version: number;
|
||||
updatedAt?: string;
|
||||
skills: Record<string, IndexSkillEntry>;
|
||||
}
|
||||
|
||||
function getCatalogDir(): string {
|
||||
@@ -65,9 +63,9 @@ function getCatalogDir(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 Wiki 数据是否已就绪。以 `models.jsonl` 作为存在信号,与
|
||||
* `CatalogSource.available()` 判定一致:只要 advisor 真正消费的文件在,
|
||||
* 就认为数据可用。
|
||||
* Whether local Wiki data is ready. Uses `models.jsonl` as the existence signal, consistent with
|
||||
* `CatalogSource.available()`: as long as the file advisor actually consumes exists,
|
||||
* the data is considered available.
|
||||
*/
|
||||
function catalogDataExists(): boolean {
|
||||
return existsSync(join(getCatalogDir(), "models", MODELS_FILE));
|
||||
@@ -89,15 +87,48 @@ function writeState(state: SyncState): void {
|
||||
try {
|
||||
writeFileSync(getStatePath(), JSON.stringify(state));
|
||||
} catch {
|
||||
/* 非关键:state 写失败下次会重新检查 */
|
||||
/* Non-critical: if state write fails, next run will re-check */
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManifest(url: string): Promise<Manifest | null> {
|
||||
/**
|
||||
* Record this sync in skill-lock.json so the wiki skill shares the same ledger as bl skill
|
||||
* (list shows installed instead of untracked; update can manage subsequent upgrades).
|
||||
* Bookkeeping in the silent channel must be best-effort: failure does not affect sync results.
|
||||
*/
|
||||
function recordWikiInLock(entry: IndexSkillEntry): void {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) });
|
||||
upsertSkillLockEntry(WIKI_SKILL_NAME, {
|
||||
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
});
|
||||
} catch {
|
||||
/* Bookkeeping failure does not block sync; next sync or bl skill add will fill it in */
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */
|
||||
function wikiLockUpToDate(contentHash: string): boolean {
|
||||
try {
|
||||
return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch skills/index.json and extract the wiki skill entry; returns null on any failure */
|
||||
async function fetchIndexEntry(): Promise<IndexSkillEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, {
|
||||
signal: AbortSignal.timeout(INDEX_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as Manifest;
|
||||
const index = (await res.json()) as SkillsIndex;
|
||||
if (typeof index?.version !== "number" || !index.skills) return null;
|
||||
return index.skills[WIKI_SKILL_NAME] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -113,98 +144,57 @@ async function downloadBuffer(url: string): Promise<Buffer | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** brotli 解压 + tar-stream 解包到 destDir(与 Crawler tar.pack() 对称)。 */
|
||||
async function extractTarBr(tarBrBuffer: Buffer, destDir: string): Promise<void> {
|
||||
const extract = tar.extract();
|
||||
|
||||
extract.on("entry", (header, stream, next) => {
|
||||
const filePath = join(destDir, header.name);
|
||||
if (header.type === "directory") {
|
||||
mkdirSync(filePath, { recursive: true });
|
||||
stream.resume();
|
||||
stream.on("end", next);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const ws = createWriteStream(filePath);
|
||||
stream.pipe(ws);
|
||||
ws.on("finish", next);
|
||||
ws.on("error", next);
|
||||
});
|
||||
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子替换:把 tmpDir 解包好的内容替换到 catalogDir。
|
||||
* tmpDir 必须与 catalogDir 同盘(同一 parent 下),renameSync 才是原子的。
|
||||
*/
|
||||
function atomicSwap(tmpDir: string, catalogDir: string): void {
|
||||
mkdirSync(dirname(catalogDir), { recursive: true });
|
||||
const backup = `${catalogDir}.old-${Date.now()}`;
|
||||
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
|
||||
try {
|
||||
renameSync(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
// 替换失败 → 回滚旧目录,避免留下空洞
|
||||
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
|
||||
throw err;
|
||||
}
|
||||
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并同步 Wiki 数据。静默执行,任何异常都不抛出。
|
||||
* @returns 是否实际更新了数据(用于测试/调试)
|
||||
* Check and sync Wiki data. Runs silently; never throws.
|
||||
* @returns Whether data was actually updated (for testing/debugging)
|
||||
*/
|
||||
export async function maybeSyncWikiData(): Promise<boolean> {
|
||||
const state = readState();
|
||||
const now = Date.now();
|
||||
|
||||
// 1. throttle gate:仅当「在 12h 窗口内」且「本地数据确实存在」时才跳过。
|
||||
// 数据缺失(用户手动删除、postinstall 失败但 state 残留等)时无视 throttle,
|
||||
// 立即走同步补齐,避免 advisor 拿不到数据。
|
||||
// 1. throttle gate: only skip when "within the 12h window" AND "local data actually exists".
|
||||
// If data is missing (user deleted manually, postinstall failed but state remains, etc.),
|
||||
// ignore throttle and sync immediately to ensure advisor has data.
|
||||
if (state && now - state.lastChecked < THROTTLE_MS && catalogDataExists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 拉 manifest
|
||||
const manifest = await fetchManifest(`${OSS_BASE_URL}/${MANIFEST_KEY}`);
|
||||
if (!manifest?.version) return false; // 失败不写 lastChecked,下次重试
|
||||
// 2. Fetch skills/index.json and get the wiki entry
|
||||
const entry = await fetchIndexEntry();
|
||||
if (!entry?.contentHash) return false; // On failure, do not write lastChecked; retry next time
|
||||
|
||||
// 3. 版本相同且本地数据存在:仅刷新 lastChecked,无需重新下载。
|
||||
// 覆盖两种状态:(a) state.version === manifest.version → 直接命中;
|
||||
// (b) state 缺失但数据完好(用户或意外只删了 state)→ 用 manifest 版本
|
||||
// 写回 state,避免无谓的 2MB 下载+解压。
|
||||
// 数据缺失或版本落后时落到第 4 步全量下载补齐。
|
||||
// 3. Same content and local data exists: only refresh lastChecked, no re-download needed.
|
||||
// Covers two cases: (a) state.contentHash === entry.contentHash → direct hit;
|
||||
// (b) state missing but data intact (user or accident only deleted state) → write the fingerprint
|
||||
// back to state, avoiding unnecessary download+extract.
|
||||
// If data is missing or the fingerprint differs, falls through to step 4 for full download.
|
||||
const dataOk = catalogDataExists();
|
||||
if (dataOk && (!state || state.version === manifest.version)) {
|
||||
writeState({ lastChecked: now, version: manifest.version });
|
||||
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(entry);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 版本不同:下载 + 校验 + 解包 + 原子替换
|
||||
const tarBuf = await downloadBuffer(`${OSS_BASE_URL}/${ASSET_KEY}`);
|
||||
// 4. Different content: download + extract (with entry path safety check) + atomic swap
|
||||
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${ASSET_NAME}`);
|
||||
if (!tarBuf) return false;
|
||||
|
||||
// sha256 校验
|
||||
const sha256 = createHash("sha256").update(tarBuf).digest("hex");
|
||||
if (manifest.asset?.sha256 && sha256 !== manifest.asset.sha256) return false;
|
||||
|
||||
const catalogDir = getCatalogDir();
|
||||
// 同盘临时目录:extract 到这里再 rename,跨盘 rename 会 EXDEV
|
||||
// Same-volume temp dir: extract here then rename; cross-device rename would EXDEV
|
||||
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBuf, tmpDir);
|
||||
atomicSwap(tmpDir, catalogDir);
|
||||
} catch {
|
||||
// 解包/替换失败 → 清理临时目录,不动现有数据,不写 state
|
||||
// Extract/swap failed → clean up temp dir, leave existing data untouched, do not write state
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 成功:写 state
|
||||
writeState({ lastChecked: now, version: manifest.version });
|
||||
// 5. Success: write state + skill-lock.json record (unified bl skill ledger)
|
||||
writeState({ lastChecked: now, contentHash: entry.contentHash });
|
||||
recordWikiInLock(entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./types/index.ts";
|
||||
export * from "./utils/index.ts";
|
||||
export * from "./telemetry/index.ts";
|
||||
export * from "./advisor/index.ts";
|
||||
export * from "./skills/index.ts";
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
|
||||
/**
|
||||
* Agent fan-out: after a skill lands in the canonical dir (~/.bailian/skills/<name>),
|
||||
* symlink it into each detected AI agent's global skills directory so that a single
|
||||
* install becomes visible across all agents.
|
||||
*
|
||||
* Detection semantics: if the agent's config dir exists → agent is installed → create link;
|
||||
* otherwise skip (never create ~/.xxx dirs that pollute home). When a new agent is installed
|
||||
* later, any subsequent `bl skill add/update` will fill in missing links (self-healing).
|
||||
*/
|
||||
export interface AgentTarget {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** Global directory where this agent reads skills from */
|
||||
skillsDir: string;
|
||||
/** 任一存在即判定"本机装了该 agent" */
|
||||
detectDirs: string[];
|
||||
}
|
||||
|
||||
/** Computed on each call (depends on homedir / XDG_CONFIG_HOME; easy to override in tests) */
|
||||
export function getAgentTargets(): AgentTarget[] {
|
||||
const home = homedir();
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
|
||||
const simple = (id: string, displayName: string, dir: string): AgentTarget => ({
|
||||
id,
|
||||
displayName,
|
||||
skillsDir: join(home, dir, "skills"),
|
||||
detectDirs: [join(home, dir)],
|
||||
});
|
||||
return [
|
||||
// universal pseudo-agent: ~/.agents/skills is a shared dir read by multiple agents (Cline, etc.)
|
||||
{
|
||||
id: "universal",
|
||||
displayName: "Universal (~/.agents/skills)",
|
||||
skillsDir: join(home, ".agents", "skills"),
|
||||
detectDirs: [join(home, ".agents"), join(home, ".cline")],
|
||||
},
|
||||
simple("claude-code", "Claude Code", ".claude"),
|
||||
simple("openclaw", "OpenClaw", ".openclaw"),
|
||||
simple("hermes", "Hermes Agent", ".hermes"),
|
||||
{
|
||||
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"),
|
||||
simple("qoder", "Qoder", ".qoder"),
|
||||
simple("qoder-cn", "Qoder CN", ".qoder-cn"),
|
||||
simple("kilo", "Kilo Code", ".kilocode"),
|
||||
];
|
||||
}
|
||||
|
||||
export function detectInstalledAgents(): AgentTarget[] {
|
||||
return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir)));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkResult {
|
||||
agent: string;
|
||||
path: string;
|
||||
mode: "symlink" | "copy" | "skipped";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function linkSkillToAgents(
|
||||
name: string,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
): LinkResult[] {
|
||||
const target = join(getSkillsDir(), name);
|
||||
const results: LinkResult[] = [];
|
||||
for (const agent of agents) {
|
||||
const linkPath = join(agent.skillsDir, name);
|
||||
try {
|
||||
let existing = false;
|
||||
try {
|
||||
lstatSync(linkPath); // existsSync returns false for dangling symlinks; must use lstat
|
||||
existing = true;
|
||||
} catch {
|
||||
/* does not exist */
|
||||
}
|
||||
if (existing) {
|
||||
if (!isManagedLink(linkPath)) {
|
||||
results.push({
|
||||
agent: agent.id,
|
||||
path: linkPath,
|
||||
mode: "skipped",
|
||||
reason: "existing file/dir not managed by bl skill",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
rmSync(linkPath);
|
||||
}
|
||||
mkdirSync(agent.skillsDir, { recursive: true });
|
||||
try {
|
||||
symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
|
||||
results.push({ agent: agent.id, path: linkPath, mode: "symlink" });
|
||||
} catch {
|
||||
// No symlink permission (typical: Windows non-Developer Mode) → fall back to copy
|
||||
cpSync(target, linkPath, { recursive: true });
|
||||
results.push({ agent: agent.id, path: linkPath, mode: "copy" });
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({
|
||||
agent: agent.id,
|
||||
path: linkPath,
|
||||
mode: "skipped",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclaim fan-out artifacts for a skill across all agent dirs.
|
||||
* Symlinks pointing to canonical are removed (including historical links not in lock,
|
||||
* via defensive scan of the full registry); real directories are only removed if recorded
|
||||
* in lock (copy-fallback artifacts). A single failure does not block the rest.
|
||||
*/
|
||||
export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] {
|
||||
const removed: string[] = [];
|
||||
const candidates = new Set(recordedLinks);
|
||||
for (const agent of getAgentTargets()) candidates.add(join(agent.skillsDir, name));
|
||||
for (const linkPath of candidates) {
|
||||
try {
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(linkPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (isManagedLink(linkPath)) {
|
||||
rmSync(linkPath);
|
||||
removed.push(linkPath);
|
||||
}
|
||||
} else if (recordedLinks.includes(linkPath)) {
|
||||
rmSync(linkPath, { recursive: true, force: true });
|
||||
removed.push(linkPath);
|
||||
}
|
||||
} catch {
|
||||
/* single failure does not block remaining cleanup */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* tar.br archive extraction and atomic swap — shared by advisor wiki sync and `bl skill` install.
|
||||
* Symmetric with the publisher (FC skills-publish.mjs: tar.pack + brotli); uses only Node built-in
|
||||
* zlib + tar-stream, no extra decompression dependencies.
|
||||
*/
|
||||
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createBrotliDecompress } from "node:zlib";
|
||||
import tar from "tar-stream";
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
export function isSafeEntryName(name: string): boolean {
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
/** Brotli decompress + tar-stream extract into destDir (per-entry path safety check). */
|
||||
export async function extractTarBr(tarBrBuffer: Buffer, destDir: string): Promise<void> {
|
||||
const extract = tar.extract();
|
||||
|
||||
extract.on("entry", (header, stream, next) => {
|
||||
if (!isSafeEntryName(header.name)) {
|
||||
// Use destroy so the pipeline rejects with this error; silence the entry stream
|
||||
// to avoid its companion error becoming an unhandled exception
|
||||
stream.on("error", () => {});
|
||||
stream.resume();
|
||||
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
|
||||
return;
|
||||
}
|
||||
const filePath = join(destDir, header.name);
|
||||
if (header.type === "directory") {
|
||||
mkdirSync(filePath, { recursive: true });
|
||||
stream.resume();
|
||||
stream.on("end", next);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const ws = createWriteStream(filePath);
|
||||
stream.pipe(ws);
|
||||
ws.on("finish", next);
|
||||
ws.on("error", next);
|
||||
});
|
||||
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic swap: replace destDir with the extracted content from tmpDir.
|
||||
* tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic.
|
||||
*/
|
||||
export function atomicSwap(tmpDir: string, destDir: string): void {
|
||||
mkdirSync(dirname(destDir), { recursive: true });
|
||||
const backup = `${destDir}.old-${Date.now()}`;
|
||||
if (existsSync(destDir)) renameSync(destDir, backup);
|
||||
try {
|
||||
renameSync(tmpDir, destDir);
|
||||
} catch (err) {
|
||||
// Swap failed → roll back the old directory to avoid leaving a hole
|
||||
if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir);
|
||||
throw err;
|
||||
}
|
||||
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// bl skill management: OSS unified publishing protocol client + local install/fan-out/status reconciliation.
|
||||
export type {
|
||||
SkillIndexEntry,
|
||||
SkillsIndex,
|
||||
SkillLockEntry,
|
||||
SkillLockFile,
|
||||
SkillStatus,
|
||||
SkillStatusRow,
|
||||
} from "./types.ts";
|
||||
export { getSkillRegistryBaseUrl, fetchSkillsIndex, downloadSkillAsset } from "./registry.ts";
|
||||
export {
|
||||
getSkillsDir,
|
||||
getSkillLockPath,
|
||||
emptySkillLock,
|
||||
readSkillLock,
|
||||
writeSkillLock,
|
||||
upsertSkillLockEntry,
|
||||
} from "./lock.ts";
|
||||
export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts";
|
||||
export { validateSkillDir, type SkillMeta } from "./validate.ts";
|
||||
export { extractTarBr, atomicSwap, isSafeEntryName } from "./extract.ts";
|
||||
export {
|
||||
getAgentTargets,
|
||||
detectInstalledAgents,
|
||||
linkSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
type AgentTarget,
|
||||
type LinkResult,
|
||||
} from "./agents.ts";
|
||||
export {
|
||||
installSkill,
|
||||
installSkillFromBuffer,
|
||||
removeSkillDir,
|
||||
type InstalledSkill,
|
||||
} from "./installer.ts";
|
||||
export { listSkillDirsOnDisk, computeSkillStatuses } from "./status.ts";
|
||||
@@ -0,0 +1,74 @@
|
||||
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 { atomicSwap, extractTarBr } from "./extract.ts";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
import { downloadSkillAsset } from "./registry.ts";
|
||||
import { isSafeSkillName } from "./sanitize.ts";
|
||||
import { validateSkillDir, type SkillMeta } from "./validate.ts";
|
||||
import type { SkillIndexEntry } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Skill installer: download → extract to tmpdir (with tar-slip check) → validate SKILL.md →
|
||||
* atomic swap into canonical. Canonical is only touched after all validations pass; on any failure
|
||||
* the current installation is preserved and temp artifacts are cleaned up in finally.
|
||||
*/
|
||||
export interface InstalledSkill {
|
||||
name: string;
|
||||
path: string;
|
||||
meta: SkillMeta;
|
||||
}
|
||||
|
||||
function assertSafeName(name: string): void {
|
||||
if (!isSafeSkillName(name)) {
|
||||
throw new BailianError(
|
||||
`Invalid skill name: ${name}`,
|
||||
ExitCode.GENERAL,
|
||||
"Skill name contains path separators, traversal sequences, or other illegal characters; refusing to write to disk",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Install from an in-memory tar.br archive (the download-and-onwards half of installSkill; test-friendly) */
|
||||
export async function installSkillFromBuffer(
|
||||
name: string,
|
||||
tarBrBuffer: Buffer,
|
||||
): Promise<InstalledSkill> {
|
||||
assertSafeName(name);
|
||||
const skillsDir = getSkillsDir();
|
||||
const dest = join(skillsDir, name);
|
||||
// Same-volume temp dir: extract here then rename; cross-device rename would EXDEV
|
||||
const tmpDir = join(skillsDir, `.tmp-${name}-${process.pid}-${Date.now()}`);
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBrBuffer, tmpDir);
|
||||
const meta = validateSkillDir(tmpDir, name);
|
||||
atomicSwap(tmpDir, dest);
|
||||
return { name, path: dest, meta };
|
||||
} finally {
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Install a single skill by index entry (download + validate + write to disk) */
|
||||
export async function installSkill(name: string, entry: SkillIndexEntry): Promise<InstalledSkill> {
|
||||
if (entry.compression && entry.compression !== "tar.br") {
|
||||
throw new BailianError(
|
||||
`Skill ${name} uses unsupported compression format: ${entry.compression}`,
|
||||
ExitCode.GENERAL,
|
||||
"Upgrade bailian-cli to the latest version and retry",
|
||||
);
|
||||
}
|
||||
const buffer = await downloadSkillAsset(name);
|
||||
return installSkillFromBuffer(name, buffer);
|
||||
}
|
||||
|
||||
/** Remove the skill directory under canonical; returns whether it was actually deleted (dir absent → false) */
|
||||
export function removeSkillDir(name: string): boolean {
|
||||
assertSafeName(name);
|
||||
const dest = join(getSkillsDir(), name);
|
||||
if (!existsSync(dest)) return false;
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
import type { SkillLockEntry, SkillLockFile } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Local skill state: canonical directory + skill-lock.json.
|
||||
*
|
||||
* The lock only records "installation facts" (version, timestamp, fan-out links) and never
|
||||
* caches the remote index — list/update diffs are always "live remote index vs lock".
|
||||
* Paths follow the config.json directory logic (BAILIAN_CONFIG_DIR can redirect everything).
|
||||
*/
|
||||
export function getSkillsDir(): string {
|
||||
return join(getConfigDir(), "skills");
|
||||
}
|
||||
|
||||
export function getSkillLockPath(): string {
|
||||
return join(getSkillsDir(), "skill-lock.json");
|
||||
}
|
||||
|
||||
export function emptySkillLock(): SkillLockFile {
|
||||
return { version: 1, skills: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read installation records. Returns an empty lock when the file is absent (first install),
|
||||
* corrupted, or has an unrecognized version — an empty lock is a valid initial state, not an
|
||||
* error; subsequent install actions will rebuild correct records.
|
||||
*/
|
||||
export function readSkillLock(): SkillLockFile {
|
||||
const path = getSkillLockPath();
|
||||
if (!existsSync(path)) return emptySkillLock();
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf-8")) as SkillLockFile;
|
||||
if (parsed?.version !== 1 || typeof parsed.skills !== "object" || parsed.skills === null) {
|
||||
return emptySkillLock();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return emptySkillLock();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSkillLock(lock: SkillLockFile): void {
|
||||
mkdirSync(getSkillsDir(), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-update a single skill's installation record (read-modify-write).
|
||||
* Shallow-merges with the existing entry: fields not provided in patch (typically links —
|
||||
* agent fan-out records) are preserved, preventing "install-only, no fan-out" sync channels
|
||||
* like postinstall/advisor from overwriting link records established by bl skill add.
|
||||
*/
|
||||
export function upsertSkillLockEntry(name: string, patch: SkillLockEntry): void {
|
||||
const lock = readSkillLock();
|
||||
lock.skills[name] = { ...lock.skills[name], ...patch };
|
||||
writeSkillLock(lock);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import type { SkillsIndex } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Skill registry client: public-read OSS, pure HTTPS GET, zero credentials (usable with auth: "none").
|
||||
* Defaults to the skills/ prefix of the bailian-wiki bucket; override with BAILIAN_SKILL_REGISTRY_URL
|
||||
* for canary/private mirror scenarios.
|
||||
*/
|
||||
const DEFAULT_REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
||||
/** index.json protocol version supported by this client */
|
||||
const SUPPORTED_INDEX_VERSION = 1;
|
||||
|
||||
const INDEX_TIMEOUT_MS = 10_000;
|
||||
const ASSET_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function getSkillRegistryBaseUrl(): string {
|
||||
const override = process.env.BAILIAN_SKILL_REGISTRY_URL?.trim();
|
||||
return (override || DEFAULT_REGISTRY_BASE_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the remote skill index. No local caching — the diff comparison is always
|
||||
* "live remote index vs local skill-lock.json".
|
||||
*/
|
||||
export async function fetchSkillsIndex(): Promise<SkillsIndex> {
|
||||
const url = `${getSkillRegistryBaseUrl()}/index.json`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
`Cannot access skill registry: ${url}`,
|
||||
ExitCode.NETWORK,
|
||||
"Check network connectivity; if using a private mirror, verify BAILIAN_SKILL_REGISTRY_URL configuration",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new BailianError(
|
||||
`Skill registry returned HTTP ${res.status}: ${url}`,
|
||||
ExitCode.NETWORK,
|
||||
res.status === 404
|
||||
? "Skill index not yet published or registry URL is incorrect; confirm the publisher has generated index.json"
|
||||
: "Remote error, retry later",
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
"Skill index index.json is not valid JSON",
|
||||
ExitCode.GENERAL,
|
||||
"Remote may be in the middle of publishing, retry later",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
const index = parsed as SkillsIndex;
|
||||
if (
|
||||
typeof index !== "object" ||
|
||||
index === null ||
|
||||
typeof index.skills !== "object" ||
|
||||
index.skills === null
|
||||
) {
|
||||
throw new BailianError(
|
||||
"Skill index index.json has invalid structure",
|
||||
ExitCode.GENERAL,
|
||||
"Retry later or contact the publisher",
|
||||
);
|
||||
}
|
||||
if (index.version !== SUPPORTED_INDEX_VERSION) {
|
||||
throw new BailianError(
|
||||
`Skill index protocol version ${index.version} is not supported by this CLI`,
|
||||
ExitCode.GENERAL,
|
||||
"Upgrade bailian-cli to the latest version and retry",
|
||||
);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Download the tar.br archive for a single skill (one skill = one GET) */
|
||||
export async function downloadSkillAsset(name: string): Promise<Buffer> {
|
||||
const url = `${getSkillRegistryBaseUrl()}/${name}/skill.tar.br`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { signal: AbortSignal.timeout(ASSET_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
`Failed to download skill ${name}: ${url}`,
|
||||
ExitCode.NETWORK,
|
||||
"Network error, retryable",
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new BailianError(
|
||||
`Failed to download skill ${name}: HTTP ${res.status}`,
|
||||
ExitCode.NETWORK,
|
||||
res.status === 404
|
||||
? "index.json and skill object are temporarily inconsistent (publishing in progress), retry later"
|
||||
: "Remote error, retry later",
|
||||
);
|
||||
}
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Sanitize a skill name into a safe directory name (semantics aligned with vercel-labs/skills sanitizeName):
|
||||
* skill names come from the remote index (untrusted input) and are interpolated into file paths, so they
|
||||
* must be disinfected first — path separators/drive letters/whitespace/Windows-illegal chars are collapsed
|
||||
* to hyphens, `..` is destroyed, leading/trailing `.-` are stripped.
|
||||
*
|
||||
* `bl skill` uses this as an "equivalence check": if the sanitized name differs from the original,
|
||||
* installation is rejected outright (the publisher already has an isomorphic allowlist; this is client-side defense-in-depth).
|
||||
*/
|
||||
export function sanitizeSkillName(name: string): string {
|
||||
const sanitized = name
|
||||
.replace(/[\\/:*?"<>|\s]+/g, "-")
|
||||
.replace(/\.\.+/g, "-")
|
||||
.replace(/^[-.]+|[-.]+$/g, "");
|
||||
return sanitized || "unnamed-skill";
|
||||
}
|
||||
|
||||
/** Whether the skill name is already a safe directory name (unchanged after sanitization) */
|
||||
export function isSafeSkillName(name: string): boolean {
|
||||
return name.length > 0 && sanitizeSkillName(name) === name;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
import type { SkillLockFile, SkillStatusRow, SkillsIndex } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Three-way reconciliation for list: remote index (live) × skill-lock.json (installation facts) × disk (ground truth).
|
||||
*/
|
||||
|
||||
/** Scan skill directories under canonical (skipping hidden entries, tmp/backup remnants, and plain files) */
|
||||
export function listSkillDirsOnDisk(): string[] {
|
||||
const dir = getSkillsDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter((entry) => {
|
||||
if (entry.startsWith(".")) return false;
|
||||
if (entry.includes(".tmp-") || entry.includes(".old-")) return false;
|
||||
try {
|
||||
return statSync(join(dir, entry)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function computeSkillStatuses(
|
||||
index: SkillsIndex,
|
||||
lock: SkillLockFile,
|
||||
diskNames: string[],
|
||||
): SkillStatusRow[] {
|
||||
const disk = new Set(diskNames);
|
||||
const seen = new Set<string>();
|
||||
const rows: SkillStatusRow[] = [];
|
||||
|
||||
// Skills present in remote: derive status from lock/disk
|
||||
for (const [name, entry] of Object.entries(index.skills)) {
|
||||
seen.add(name);
|
||||
const locked = lock.skills[name];
|
||||
if (locked) {
|
||||
const status = !disk.has(name)
|
||||
? "missing" // was installed but dir was deleted; reinstall can fix
|
||||
: locked.contentHash !== entry.contentHash
|
||||
? "outdated"
|
||||
: "installed";
|
||||
rows.push({
|
||||
name,
|
||||
status,
|
||||
publishedAt: entry.publishedAt,
|
||||
description: entry.description,
|
||||
});
|
||||
} else if (disk.has(name)) {
|
||||
// Dir exists but no install record (manually placed, or synced by postinstall/advisor or other channels)
|
||||
rows.push({
|
||||
name,
|
||||
status: "untracked",
|
||||
publishedAt: entry.publishedAt,
|
||||
description: entry.description,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
name,
|
||||
status: "not-installed",
|
||||
publishedAt: entry.publishedAt,
|
||||
description: entry.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// In lock but delisted from remote: still usable locally (installed) or dir also gone (missing)
|
||||
for (const [name, locked] of Object.entries(lock.skills)) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
rows.push({
|
||||
name,
|
||||
status: disk.has(name) ? "installed" : "missing",
|
||||
publishedAt: locked.publishedAt,
|
||||
description: locked.description,
|
||||
});
|
||||
}
|
||||
|
||||
// On disk but in neither lock nor remote → untracked
|
||||
for (const name of diskNames) {
|
||||
if (!seen.has(name)) rows.push({ name, status: "untracked" });
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Data structures for the unified skill publishing protocol (symmetric with FC publisher skills-publish.mjs).
|
||||
*
|
||||
* Remote layout (public-read OSS, the sole data source for `bl skill`):
|
||||
* <registry>/index.json — skill catalog (SkillsIndex)
|
||||
* <registry>/<name>/skill.tar.br — one object per skill (tar + brotli, atomic publish)
|
||||
*
|
||||
* Local layout:
|
||||
* ~/.bailian/skills/<name>/ — canonical install directory
|
||||
* ~/.bailian/skills/skill-lock.json — installation fact records (SkillLockFile)
|
||||
*/
|
||||
|
||||
/** A single skill entry in index.json */
|
||||
export interface SkillIndexEntry {
|
||||
/** Reserved for the skill's own semantic version (x.y.z); not yet populated by the publisher */
|
||||
version?: string;
|
||||
/** Beijing-time publish timestamp; refreshed whenever content changes — the human-facing release marker */
|
||||
publishedAt?: string;
|
||||
/** Extracted by the publisher from README.md first paragraph or SKILL.md frontmatter */
|
||||
description?: string;
|
||||
/** Deterministic content fingerprint; the CLI uses this as the change-detection token (install/outdated) */
|
||||
contentHash?: string;
|
||||
/** Compression format identifier, currently always "tar.br" */
|
||||
compression?: string;
|
||||
}
|
||||
|
||||
export interface SkillsIndex {
|
||||
/** Protocol schema version, currently 1; client should error and prompt upgrade on unrecognized versions */
|
||||
version: number;
|
||||
updatedAt?: string;
|
||||
/** key = skill name (i.e. OSS directory name, download path, local install dir name) */
|
||||
skills: Record<string, SkillIndexEntry>;
|
||||
}
|
||||
|
||||
/** Installation facts for a single skill in skill-lock.json */
|
||||
export interface SkillLockEntry {
|
||||
/** Content fingerprint at install time; compared against the remote index to detect updates */
|
||||
contentHash?: string;
|
||||
/** Publish timestamp of the installed revision (for display) */
|
||||
publishedAt?: string;
|
||||
installedAt: string;
|
||||
/** Reserved: future support for github/gitlab and other sources */
|
||||
sourceType: "oss";
|
||||
description?: string;
|
||||
/** Fan-out link/copy paths to each agent; used for precise reclamation on remove */
|
||||
links?: string[];
|
||||
}
|
||||
|
||||
export interface SkillLockFile {
|
||||
version: 1;
|
||||
skills: Record<string, SkillLockEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill statuses for list:
|
||||
* installed — lock record exists, dir on disk, content fingerprint matches remote
|
||||
* outdated — lock record exists, dir on disk, remote content fingerprint differs
|
||||
* not-installed — present in remote, absent locally
|
||||
* missing — lock record exists but dir was deleted (reinstall can fix)
|
||||
* untracked — dir on disk but no install record (manually placed or synced by other channels)
|
||||
*/
|
||||
export type SkillStatus = "installed" | "outdated" | "not-installed" | "missing" | "untracked";
|
||||
|
||||
export interface SkillStatusRow {
|
||||
name: string;
|
||||
status: SkillStatus;
|
||||
/** Publish timestamp of the remote revision (or local, for delisted skills) */
|
||||
publishedAt?: string;
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
|
||||
/**
|
||||
* Skill validity check (aligned with vercel-labs/skills parseSkillMd semantics):
|
||||
* 1. SKILL.md exists as a regular file at the directory root
|
||||
* 2. frontmatter is valid YAML delimited by `---`
|
||||
* 3. name / description fields exist and are non-empty strings
|
||||
*
|
||||
* Validation happens in the temp dir before writing to canonical — any failure rolls back the entire install.
|
||||
*/
|
||||
export interface SkillMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function fail(skillName: string, reason: string): never {
|
||||
throw new BailianError(
|
||||
`Skill ${skillName} validation failed: ${reason}`,
|
||||
ExitCode.GENERAL,
|
||||
"This skill package does not conform to the SKILL.md spec; contact the skill publisher to fix and republish",
|
||||
);
|
||||
}
|
||||
|
||||
function extractFrontmatter(content: string): string | null {
|
||||
if (!content.startsWith("---")) return null;
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function validateSkillDir(dir: string, skillName: string): SkillMeta {
|
||||
const skillMdPath = join(dir, "SKILL.md");
|
||||
let raw: string;
|
||||
try {
|
||||
if (!statSync(skillMdPath).isFile()) fail(skillName, "SKILL.md is not a regular file");
|
||||
raw = readFileSync(skillMdPath, "utf-8");
|
||||
} catch (err) {
|
||||
if (err instanceof BailianError) throw err;
|
||||
fail(skillName, "missing SKILL.md");
|
||||
}
|
||||
|
||||
const frontmatter = extractFrontmatter(raw);
|
||||
if (frontmatter === null)
|
||||
fail(skillName, "SKILL.md is missing frontmatter (--- delimited YAML header)");
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = parse(frontmatter);
|
||||
} catch {
|
||||
fail(skillName, "frontmatter is not valid YAML");
|
||||
}
|
||||
if (typeof data !== "object" || data === null) {
|
||||
fail(skillName, "frontmatter is not a key-value structure");
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" ? record.name.trim() : "";
|
||||
const description = typeof record.description === "string" ? record.description.trim() : "";
|
||||
if (!name || !description)
|
||||
fail(skillName, "frontmatter is missing non-empty name / description fields");
|
||||
|
||||
return { name, description };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
detectInstalledAgents,
|
||||
getAgentTargets,
|
||||
linkSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
} from "../src/skills/agents.ts";
|
||||
import { getSkillsDir } from "../src/skills/lock.ts";
|
||||
|
||||
/**
|
||||
* Isolated environment: HOME/XDG_CONFIG_HOME/BAILIAN_CONFIG_DIR all point to a temp dir,
|
||||
* so agent detection and the canonical dir never touch the real home.
|
||||
*/
|
||||
async function inFakeHome(fn: (home: string) => Promise<void>): Promise<void> {
|
||||
const saved = {
|
||||
HOME: process.env.HOME,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR,
|
||||
};
|
||||
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");
|
||||
try {
|
||||
await fn(home);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an installed skill in canonical */
|
||||
function seedCanonicalSkill(name: string): string {
|
||||
const dir = join(getSkillsDir(), name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: x\ndescription: y\n---\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("agents: registry has universal + 11 agents, only detects those whose config dir exists", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
expect(getAgentTargets().map((a) => a.id)).toContain("universal");
|
||||
expect(getAgentTargets()).toHaveLength(11);
|
||||
expect(detectInstalledAgents()).toEqual([]);
|
||||
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
mkdirSync(join(home, ".qoder"), { recursive: true });
|
||||
expect(detectInstalledAgents().map((a) => a.id)).toEqual(["claude-code", "qoder"]);
|
||||
|
||||
// Cline config dir exists → hits the universal pseudo-agent
|
||||
mkdirSync(join(home, ".cline"), { recursive: true });
|
||||
expect(detectInstalledAgents().map((a) => a.id)).toEqual(["universal", "claude-code", "qoder"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
const target = seedCanonicalSkill("demo");
|
||||
|
||||
const results = linkSkillToAgents("demo");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({ agent: "claude-code", mode: "symlink" });
|
||||
|
||||
const linkPath = join(home, ".claude", "skills", "demo");
|
||||
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(linkPath)).toBe(target);
|
||||
// Real content is readable through the link
|
||||
expect(readFileSync(join(linkPath, "SKILL.md"), "utf-8")).toContain("name: x");
|
||||
// Uninstalled agent dir was not created out of thin air
|
||||
expect(existsSync(join(home, ".cursor"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: existing unmanaged dir is skipped; managed stale link is rebuilt", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
|
||||
// Real dir placed by the user → skipped, not cleared
|
||||
const foreign = join(home, ".claude", "skills", "demo");
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "user.txt"), "mine");
|
||||
const first = linkSkillToAgents("demo");
|
||||
expect(first[0].mode).toBe("skipped");
|
||||
expect(readFileSync(join(foreign, "user.txt"), "utf-8")).toBe("mine");
|
||||
|
||||
// Replace with our own stale link → rebuilt successfully
|
||||
rmSync(foreign, { recursive: true, force: true });
|
||||
const again = linkSkillToAgents("demo");
|
||||
expect(again[0].mode).toBe("symlink");
|
||||
const rebuilt = linkSkillToAgents("demo");
|
||||
expect(rebuilt[0].mode).toBe("symlink");
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: unlink reclaims managed links, leaves foreign content untouched", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
mkdirSync(join(home, ".agents"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
const links = linkSkillToAgents("demo");
|
||||
expect(links.filter((l) => l.mode === "symlink")).toHaveLength(2);
|
||||
|
||||
// Foreign file with the same name placed in cursor (should be unaffected even if not detected)
|
||||
const removed = unlinkSkillFromAgents(
|
||||
"demo",
|
||||
links.map((l) => l.path),
|
||||
);
|
||||
expect(removed.sort()).toEqual(links.map((l) => l.path).sort());
|
||||
expect(existsSync(join(home, ".claude", "skills", "demo"))).toBe(false);
|
||||
expect(existsSync(join(home, ".agents", "skills", "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
||||
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 { BailianError } from "../src/errors/base.ts";
|
||||
import { installSkillFromBuffer } from "../src/skills/installer.ts";
|
||||
import { getSkillsDir } from "../src/skills/lock.ts";
|
||||
|
||||
/** Run in an isolated temp config dir, restore env afterwards. */
|
||||
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
|
||||
const saved = process.env.BAILIAN_CONFIG_DIR;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-skill-install-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a skill archive the same way as the publisher (tar.pack + brotli) */
|
||||
async function buildTarBr(files: Record<string, string>): Promise<Buffer> {
|
||||
const pack = tar.pack();
|
||||
const chunks: Buffer[] = [];
|
||||
pack.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
const done = new Promise<void>((resolvePromise, reject) => {
|
||||
pack.on("end", resolvePromise);
|
||||
pack.on("error", reject);
|
||||
});
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
pack.entry({ name: rel }, content);
|
||||
}
|
||||
pack.finalize();
|
||||
await done;
|
||||
return brotliCompressSync(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
const VALID_SKILL_MD = "---\nname: demo\ndescription: demo skill\n---\n\n# Demo v1\n";
|
||||
|
||||
test("installer: valid archive installs to canonical and returns metadata", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({
|
||||
"SKILL.md": VALID_SKILL_MD,
|
||||
"references/usage.md": "# usage\n",
|
||||
});
|
||||
const installed = await installSkillFromBuffer("demo", buf);
|
||||
expect(installed).toMatchObject({
|
||||
name: "demo",
|
||||
meta: { name: "demo", description: "demo skill" },
|
||||
});
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD);
|
||||
expect(existsSync(join(getSkillsDir(), "demo", "references", "usage.md"))).toBe(true);
|
||||
// No temp/backup dirs left behind
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: reinstall atomically swaps, no old files left behind", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await installSkillFromBuffer(
|
||||
"demo",
|
||||
await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "old-only.md": "v1\n" }),
|
||||
);
|
||||
const v2 = "---\nname: demo\ndescription: demo skill v2\n---\n";
|
||||
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": v2 }));
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(v2);
|
||||
expect(existsSync(join(getSkillsDir(), "demo", "old-only.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: tar-slip entry → rejected and canonical not written", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "../evil.txt": "pwned\n" });
|
||||
await expect(installSkillFromBuffer("demo", buf)).rejects.toThrow(/unsafe tar entry/);
|
||||
expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false);
|
||||
expect(existsSync(join(process.env.BAILIAN_CONFIG_DIR!, "evil.txt"))).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 }));
|
||||
const bad = await buildTarBr({ "README.md": "no skill md\n" });
|
||||
await expect(installSkillFromBuffer("demo", bad)).rejects.toThrow(BailianError);
|
||||
// Old version untouched, temp dir cleaned up
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD);
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: invalid skill name rejected outright", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD });
|
||||
await expect(installSkillFromBuffer("../escape", buf)).rejects.toThrow(/Invalid skill name/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
emptySkillLock,
|
||||
getSkillLockPath,
|
||||
getSkillsDir,
|
||||
readSkillLock,
|
||||
upsertSkillLockEntry,
|
||||
writeSkillLock,
|
||||
} from "../src/skills/lock.ts";
|
||||
|
||||
/** Run in an isolated temp config dir, restore env afterwards. */
|
||||
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
|
||||
const saved = process.env.BAILIAN_CONFIG_DIR;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-skill-lock-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("skill-lock: paths follow BAILIAN_CONFIG_DIR", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
expect(getSkillsDir()).toBe(join(process.env.BAILIAN_CONFIG_DIR!, "skills"));
|
||||
expect(getSkillLockPath()).toBe(join(getSkillsDir(), "skill-lock.json"));
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: first install (file absent) returns empty table, not an error", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: written data reads back with links/sourceType", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const lock = emptySkillLock();
|
||||
lock.skills["spark-video"] = {
|
||||
contentHash: "sha256:abc",
|
||||
publishedAt: "2026-07-23T00:00:00+08:00",
|
||||
installedAt: "2026-07-23T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
links: ["/tmp/x/.claude/skills/spark-video"],
|
||||
};
|
||||
writeSkillLock(lock);
|
||||
expect(readSkillLock()).toEqual(lock);
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: corrupted JSON / unrecognized version → treated as empty table", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
mkdirSync(getSkillsDir(), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), "{ not json");
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify({ version: 99, skills: {} }));
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify({ version: 1 }));
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: upsert shallow-merge — silent sync channel does not overwrite links written by add", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
// upsert on empty table = create entry (postinstall first-time bookkeeping scenario)
|
||||
upsertSkillLockEntry("bailian-docs-llm-wiki", {
|
||||
contentHash: "sha256:v1",
|
||||
installedAt: "2026-07-23T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
});
|
||||
expect(readSkillLock().skills["bailian-docs-llm-wiki"].contentHash).toBe("sha256:v1");
|
||||
|
||||
// After bl skill add adds links, advisor sync only updates the fingerprint → links preserved
|
||||
upsertSkillLockEntry("bailian-docs-llm-wiki", {
|
||||
contentHash: "sha256:v1",
|
||||
installedAt: "2026-07-23T01:00:00Z",
|
||||
sourceType: "oss",
|
||||
links: ["/tmp/x/.claude/skills/bailian-docs-llm-wiki"],
|
||||
});
|
||||
upsertSkillLockEntry("bailian-docs-llm-wiki", {
|
||||
contentHash: "sha256:v2",
|
||||
installedAt: "2026-07-24T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
});
|
||||
const entry = readSkillLock().skills["bailian-docs-llm-wiki"];
|
||||
expect(entry.contentHash).toBe("sha256:v2");
|
||||
expect(entry.links).toEqual(["/tmp/x/.claude/skills/bailian-docs-llm-wiki"]);
|
||||
// Other skills' entries are unaffected
|
||||
upsertSkillLockEntry("other", {
|
||||
contentHash: "sha256:v9",
|
||||
installedAt: "2026-07-24T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
});
|
||||
expect(readSkillLock().skills["bailian-docs-llm-wiki"].contentHash).toBe("sha256:v2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { sanitizeSkillName, isSafeSkillName } from "../src/skills/sanitize.ts";
|
||||
import { computeSkillStatuses } from "../src/skills/status.ts";
|
||||
import type { SkillLockFile, SkillsIndex } from "../src/skills/types.ts";
|
||||
|
||||
const PUB = "2026-07-23T00:00:00+08:00";
|
||||
|
||||
function makeIndex(skills: Record<string, string>): SkillsIndex {
|
||||
return {
|
||||
version: 1,
|
||||
skills: Object.fromEntries(
|
||||
Object.entries(skills).map(([name, contentHash]) => [
|
||||
name,
|
||||
{ contentHash, publishedAt: PUB },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLock(skills: Record<string, string>): SkillLockFile {
|
||||
return {
|
||||
version: 1,
|
||||
skills: Object.fromEntries(
|
||||
Object.entries(skills).map(([name, contentHash]) => [
|
||||
name,
|
||||
{ contentHash, installedAt: "2026-07-23T00:00:00Z", sourceType: "oss" as const },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test("status: five-state derivation matrix", () => {
|
||||
const index = makeIndex({ a: "h-a2", b: "h-b", c: "h-c", d: "h-d", e: "h-e" });
|
||||
const lock = makeLock({ a: "h-a1", b: "h-b", e: "h-e", zombie: "h-z" });
|
||||
// a: lock h-a1 / remote h-a2 / on disk → outdated
|
||||
// b: lock h-b / remote h-b / on disk → installed
|
||||
// c: no lock / on disk (synced by other channel) → untracked
|
||||
// d: no lock / not on disk → not-installed
|
||||
// e: lock h-e / dir deleted → missing
|
||||
// zombie: in lock / delisted from remote / on disk → installed (retained locally)
|
||||
// stray: on disk / in neither lock nor remote → untracked
|
||||
const rows = computeSkillStatuses(index, lock, ["a", "b", "c", "zombie", "stray"]);
|
||||
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
|
||||
expect(byName.a.status).toBe("outdated");
|
||||
expect(byName.a.publishedAt).toBe(PUB);
|
||||
expect(byName.b.status).toBe("installed");
|
||||
expect(byName.c.status).toBe("untracked");
|
||||
expect(byName.c.publishedAt).toBe(PUB);
|
||||
expect(byName.d.status).toBe("not-installed");
|
||||
expect(byName.e.status).toBe("missing");
|
||||
expect(byName.zombie.status).toBe("installed");
|
||||
expect(byName.zombie.publishedAt).toBeUndefined();
|
||||
expect(byName.stray.status).toBe("untracked");
|
||||
expect(rows.map((r) => r.name)).toEqual(rows.map((r) => r.name).sort());
|
||||
});
|
||||
|
||||
test("status: first use (empty lock + empty disk) → all not-installed", () => {
|
||||
const rows = computeSkillStatuses(makeIndex({ a: "1", b: "2" }), makeLock({}), []);
|
||||
expect(rows.every((r) => r.status === "not-installed")).toBe(true);
|
||||
});
|
||||
|
||||
test("status: empty remote registry + nothing local → empty list", () => {
|
||||
expect(computeSkillStatuses(makeIndex({}), makeLock({}), [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("sanitize: path traversal/illegal chars sanitized, safe names unchanged", () => {
|
||||
expect(sanitizeSkillName("../../.ssh")).toBe("ssh");
|
||||
expect(sanitizeSkillName("My Cool Skill!!")).toBe("My-Cool-Skill!!");
|
||||
expect(sanitizeSkillName("a/b\\c:d")).toBe("a-b-c-d");
|
||||
expect(sanitizeSkillName("...")).toBe("unnamed-skill");
|
||||
expect(isSafeSkillName("spark-video")).toBe(true);
|
||||
expect(isSafeSkillName("bailian.model_v2")).toBe(true);
|
||||
expect(isSafeSkillName("../evil")).toBe(false);
|
||||
expect(isSafeSkillName("a b")).toBe(false);
|
||||
expect(isSafeSkillName("")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { validateSkillDir } from "../src/skills/validate.ts";
|
||||
|
||||
function withSkillDir(fn: (dir: string) => void): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-skill-validate-"));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function expectFail(dir: string, reasonPart: string): void {
|
||||
try {
|
||||
validateSkillDir(dir, "demo");
|
||||
throw new Error("expected validateSkillDir to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BailianError);
|
||||
expect((err as BailianError).message).toContain(reasonPart);
|
||||
}
|
||||
}
|
||||
|
||||
test("validate: valid SKILL.md passes and returns frontmatter metadata", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(
|
||||
join(dir, "SKILL.md"),
|
||||
"---\nname: demo-skill\ndescription: a demo skill\n---\n\n# Demo\n",
|
||||
);
|
||||
expect(validateSkillDir(dir, "demo")).toEqual({
|
||||
name: "demo-skill",
|
||||
description: "a demo skill",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: missing SKILL.md → rejected", () => {
|
||||
withSkillDir((dir) => expectFail(dir, "missing SKILL.md"));
|
||||
});
|
||||
|
||||
test("validate: SKILL.md is a directory → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
mkdirSync(join(dir, "SKILL.md"));
|
||||
expectFail(dir, "SKILL.md is not a regular file");
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: missing frontmatter → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "# no frontmatter\n");
|
||||
expectFail(dir, "missing frontmatter");
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: frontmatter invalid YAML → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: [unclosed\n---\nbody\n");
|
||||
expectFail(dir, "not valid YAML");
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: name/description missing or empty → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: demo\n---\nbody\n");
|
||||
expectFail(dir, "name / description");
|
||||
});
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), '---\nname: demo\ndescription: " "\n---\nbody\n');
|
||||
expectFail(dir, "name / description");
|
||||
});
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: demo\ndescription: 123\n---\nbody\n");
|
||||
expectFail(dir, "name / description");
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,10 @@ Use this index for the full quick index and global flags.
|
||||
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
|
||||
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
|
||||
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
|
||||
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
|
||||
| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) |
|
||||
| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) |
|
||||
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
|
||||
@@ -117,6 +121,7 @@ Use this index for the full quick index and global flags.
|
||||
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
|
||||
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
|
||||
| `search` | `web` | [search.md](search.md) |
|
||||
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
|
||||
| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) |
|
||||
| `text` | `chat` | [text.md](text.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# `bl skill` commands
|
||||
|
||||
> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand.
|
||||
> Regenerate: `pnpm --filter bailian-cli run generate:reference`.
|
||||
|
||||
Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents |
|
||||
| `bl skill list` | List registry skills and diff against local installs |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl skill add`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **Name** | `skill add` |
|
||||
| **Description** | Install skills from the Bailian skill registry into local agents |
|
||||
| **Usage** | `bl skill add [--name <all\|name,...>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | --------------------------------------------------------------- |
|
||||
| `--name <all\|name,...>` | string | no | Skills to install: all (default) or comma-separated skill names |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill add
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill add --name all
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill add --name spark-video,bailian-model-recommend
|
||||
```
|
||||
|
||||
### `bl skill list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| **Name** | `skill list` |
|
||||
| **Description** | List registry skills and diff against local installs |
|
||||
| **Usage** | `bl skill list` |
|
||||
|
||||
#### Flags
|
||||
|
||||
_No command-specific flags._
|
||||
|
||||
#### Notes
|
||||
|
||||
- STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill list
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill list --output json
|
||||
```
|
||||
|
||||
### `bl skill remove`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `skill remove` |
|
||||
| **Description** | Remove locally installed skills (registry is untouched) |
|
||||
| **Usage** | `bl skill remove --name <all\|name,...>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ---------------------------------------------------- |
|
||||
| `--name <all\|name,...>` | string | yes | Skills to remove: all or comma-separated skill names |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill remove --name spark-video
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill remove --name all
|
||||
```
|
||||
|
||||
### `bl skill update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `skill update` |
|
||||
| **Description** | Update installed skills to the latest registry versions |
|
||||
| **Usage** | `bl skill update [--name <all\|name,...>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | --------------------------------------------------------------------------------------------- |
|
||||
| `--name <all\|name,...>` | string | no | Skills to update: all (default, only changed ones) or comma-separated names (force reinstall) |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill update
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill update --name spark-video
|
||||
```
|
||||
Reference in New Issue
Block a user