Skip to content

Commit 8dd7862

Browse files
committed
feat: add skill commend
1 parent 90a44d7 commit 8dd7862

27 files changed

Lines changed: 2003 additions & 152 deletions

packages/cli/postinstall.js

Lines changed: 90 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
/**
2-
* postinstall.js — Wiki 数据同步(第一层:npm install 触发)
2+
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
33
*
4-
* npm/pnpm 装完 bailian-cli 后自动执行:无条件下载全量 Wiki 数据包并覆盖本地目录,
5-
* 保证用户首次使用 `bl advisor recommend` 时数据已就位。
4+
* Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
5+
* package and overwrites the local directory, ensuring data is in place the first time the user runs
6+
* `bl advisor recommend`.
67
*
7-
* 流程:
8-
* 1. 从公共读 OSS 直链下载 manifest.json + wiki-doc-full.tar.br(~2.15MB)
9-
* 2. 校验 sha256
10-
* 3. Node 原生 brotli 解压 + tar-stream 解包到同盘临时目录
11-
* 4. renameSync 原子替换到 ~/.bailian/skills/bailian-docs-llm-wiki/
12-
* 5. 写 ~/.bailian/wiki-sync-state.json
8+
* Flow (unified skill publishing protocol: skills/index.json + one skill.tar.br per skill):
9+
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
10+
* 2. Download skills/bailian-docs-llm-wiki/skill.tar.br (brotli q6, ~2.3MB)
11+
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
12+
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
13+
* 5. Write ~/.bailian/wiki-sync-state.json
14+
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
1315
*
14-
* 设计约束:
15-
* - 无条件覆盖:每次 install 都全量替换,不比对已有版本
16-
* - 失败静默:任何一步失败 → console.warn → process.exit(0),绝不阻塞安装
17-
* - 独立实现:不 import bailian-cli-core,避免打包后 ESM 路径问题
18-
* - 依赖 Node 原生模块 + tar-stream(与 sync.ts / Crawler oss-upload.mjs 一致)
16+
* Design constraints:
17+
* - Unconditional overwrite: every install fully replaces, no version comparison
18+
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
19+
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
20+
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
1921
*/
20-
import { createHash } from "node:crypto";
2122
import {
2223
createWriteStream,
2324
existsSync,
2425
mkdirSync,
26+
readFileSync,
2527
renameSync,
2628
rmSync,
2729
writeFileSync,
@@ -33,14 +35,15 @@ import { pipeline } from "node:stream/promises";
3335
import { createBrotliDecompress } from "node:zlib";
3436
import tar from "tar-stream";
3537

36-
const OSS_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/bailian-docs-llm-wiki";
38+
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
39+
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
3740
const CONFIG_DIR_NAME = ".bailian";
3841
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
3942
const STATE_FILE_NAME = "wiki-sync-state.json";
40-
const MANIFEST_KEY = "manifest.json";
41-
const ASSET_KEY = "wiki-doc-full.tar.br";
43+
const INDEX_KEY = "index.json";
44+
const ASSET_NAME = "skill.tar.br";
4245

43-
const MANIFEST_TIMEOUT_MS = 3000;
46+
const INDEX_TIMEOUT_MS = 3000;
4447
const DOWNLOAD_TIMEOUT_MS = 30000;
4548

4649
function getConfigDir() {
@@ -56,6 +59,35 @@ function getStatePath() {
5659
return join(getConfigDir(), STATE_FILE_NAME);
5760
}
5861

62+
function getSkillLockPath() {
63+
return join(getConfigDir(), "skills", "skill-lock.json");
64+
}
65+
66+
/**
67+
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
68+
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
69+
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
70+
* best-effort: failure does not affect data sync results.
71+
*/
72+
function upsertSkillLock(name, entry) {
73+
try {
74+
let lock = { version: 1, skills: {} };
75+
try {
76+
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
77+
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
78+
lock = parsed;
79+
}
80+
} catch {
81+
/* absent/corrupted → empty table */
82+
}
83+
lock.skills[name] = { ...lock.skills[name], ...entry };
84+
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
85+
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
86+
} catch {
87+
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
88+
}
89+
}
90+
5991
async function fetchJson(url, timeoutMs) {
6092
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
6193
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -68,11 +100,21 @@ async function downloadBuffer(url) {
68100
return Buffer.from(await res.arrayBuffer());
69101
}
70102

71-
/** brotli 解压 + tar-stream 解包到 destDir(与 Crawler tar.pack() 对称)。 */
103+
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
104+
function isSafeEntryName(name) {
105+
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
106+
return !name.split("/").includes("..");
107+
}
108+
109+
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
72110
async function extractTarBr(tarBrBuffer, destDir) {
73111
const extract = tar.extract();
74112

75113
extract.on("entry", (header, stream, next) => {
114+
if (!isSafeEntryName(header.name)) {
115+
next(new Error(`unsafe tar entry: ${header.name}`));
116+
return;
117+
}
76118
const filePath = join(destDir, header.name);
77119
if (header.type === "directory") {
78120
mkdirSync(filePath, { recursive: true });
@@ -90,7 +132,7 @@ async function extractTarBr(tarBrBuffer, destDir) {
90132
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
91133
}
92134

93-
/** 原子替换:tmpDir(同盘)→ catalogDir */
135+
/** Atomic swap: tmpDir (same volume) → catalogDir. */
94136
function atomicSwap(tmpDir, catalogDir) {
95137
mkdirSync(dirname(catalogDir), { recursive: true });
96138
const backup = `${catalogDir}.old-${Date.now()}`;
@@ -105,18 +147,16 @@ function atomicSwap(tmpDir, catalogDir) {
105147
}
106148

107149
async function main() {
108-
// 1. 下载 manifest
109-
const manifest = await fetchJson(`${OSS_BASE_URL}/${MANIFEST_KEY}`, MANIFEST_TIMEOUT_MS);
110-
if (!manifest?.version) throw new Error("manifest 无 version");
111-
112-
// 2. 下载 tar.br + 校验
113-
const tarBuf = await downloadBuffer(`${OSS_BASE_URL}/${ASSET_KEY}`);
114-
const sha256 = createHash("sha256").update(tarBuf).digest("hex");
115-
if (manifest.asset?.sha256 && sha256 !== manifest.asset.sha256) {
116-
throw new Error("sha256 校验失败");
117-
}
150+
// 1. Download skills/index.json and get the wiki entry
151+
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
152+
const entry = index?.skills?.[WIKI_SKILL_NAME];
153+
if (!entry?.contentHash)
154+
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
155+
156+
// 2. Download skill.tar.br (per-entry path safety check during extraction)
157+
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${ASSET_NAME}`);
118158

119-
// 3. 解包到同盘临时目录 + 原子替换
159+
// 3. Extract to same-volume temp dir + atomic swap
120160
const catalogDir = getCatalogDir();
121161
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
122162
try {
@@ -128,24 +168,35 @@ async function main() {
128168
throw err;
129169
}
130170

131-
// 4. state
171+
// 4. Write state
132172
try {
133173
writeFileSync(
134174
getStatePath(),
135-
JSON.stringify({ lastChecked: Date.now(), version: manifest.version }),
175+
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
136176
);
137177
} catch {
138-
/* state 写失败不影响:首次 recommend 会重新检查 */
178+
/* state write failure has no impact: first recommend will re-check */
139179
}
140180

141-
process.stdout.write(`bailian-cli: wiki 数据已就绪 (v${manifest.version})\n`);
181+
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
182+
upsertSkillLock(WIKI_SKILL_NAME, {
183+
contentHash: entry.contentHash,
184+
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
185+
installedAt: new Date().toISOString(),
186+
sourceType: "oss",
187+
...(entry.description ? { description: entry.description } : {}),
188+
});
189+
190+
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
142191
}
143192

144193
main().catch((err) => {
145-
// 无条件放行:安装期网络/权限问题不应阻塞 npm install
146-
// 首次 `bl advisor recommend` 时 sync.ts 会兜底同步。
194+
// Unconditional pass-through: install-time network/permission issues should not block npm install;
195+
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
147196
const msg = err instanceof Error ? err.message : String(err);
148-
process.stderr.write(`bailian-cli: wiki 数据预下载跳过 (${msg}),首次使用时将自动同步。\n`);
197+
process.stderr.write(
198+
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
199+
);
149200
// Force a success exit code so a download failure never fails `npm install`.
150201
// eslint-disable-next-line unicorn/no-process-exit
151202
process.exit(0);

packages/cli/src/commands.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ import {
8383
pluginLink,
8484
pluginList,
8585
pluginRemove,
86+
skillAdd,
87+
skillUpdate,
88+
skillRemove,
89+
skillList,
8690
} from "bailian-cli-commands";
8791

8892
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -174,4 +178,8 @@ export const commands: Record<string, AnyCommand> = {
174178
"plugin link": pluginLink,
175179
"plugin list": pluginList,
176180
"plugin remove": pluginRemove,
181+
"skill add": skillAdd,
182+
"skill update": skillUpdate,
183+
"skill remove": skillRemove,
184+
"skill list": skillList,
177185
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
BailianError,
3+
ExitCode,
4+
defineCommand,
5+
detectOutputFormat,
6+
detectInstalledAgents,
7+
fetchSkillsIndex,
8+
getSkillRegistryBaseUrl,
9+
installSkill,
10+
linkSkillToAgents,
11+
readSkillLock,
12+
writeSkillLock,
13+
} from "bailian-cli-core";
14+
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
15+
import { parseSkillNames } from "./shared.ts";
16+
17+
interface AddOutcome {
18+
name: string;
19+
status: "installed" | "failed";
20+
publishedAt?: string;
21+
agents?: string[];
22+
reason?: string;
23+
}
24+
25+
export default defineCommand({
26+
description: "Install skills from the Bailian skill registry into local agents",
27+
auth: "none",
28+
usageArgs: "[--name <all|name,...>]",
29+
flags: {
30+
name: {
31+
type: "string",
32+
valueHint: "<all|name,...>",
33+
description: "Skills to install: all (default) or comma-separated skill names",
34+
},
35+
},
36+
exampleArgs: ["", "--name all", "--name spark-video,bailian-model-recommend"],
37+
async run(ctx) {
38+
const format = detectOutputFormat(ctx.settings.output);
39+
const requested = parseSkillNames(ctx.flags.name, true);
40+
const index = await fetchSkillsIndex();
41+
const remoteNames = Object.keys(index.skills);
42+
const names = requested === "all" ? remoteNames : requested;
43+
44+
const lock = readSkillLock();
45+
const agents = detectInstalledAgents();
46+
const results: AddOutcome[] = [];
47+
48+
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual
49+
for (const name of names) {
50+
const entry = index.skills[name];
51+
if (!entry) {
52+
results.push({ name, status: "failed", reason: "skill not found in registry" });
53+
continue;
54+
}
55+
try {
56+
await installSkill(name, entry);
57+
const links = linkSkillToAgents(name, agents);
58+
const effective = links.filter((link) => link.mode !== "skipped");
59+
lock.skills[name] = {
60+
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
61+
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
62+
installedAt: new Date().toISOString(),
63+
sourceType: "oss",
64+
...(entry.description ? { description: entry.description } : {}),
65+
links: effective.map((link) => link.path),
66+
};
67+
results.push({
68+
name,
69+
status: "installed",
70+
publishedAt: entry.publishedAt,
71+
agents: effective.map((link) => link.agent),
72+
});
73+
} catch (err) {
74+
results.push({
75+
name,
76+
status: "failed",
77+
reason: err instanceof Error ? err.message : String(err),
78+
});
79+
}
80+
}
81+
writeSkillLock(lock);
82+
83+
if (format === "json") {
84+
emitResult(
85+
{ registry: getSkillRegistryBaseUrl(), agents: agents.map((a) => a.id), skills: results },
86+
format,
87+
);
88+
} else if (results.length === 0) {
89+
emitBare("Skill registry is empty; no skills to install.");
90+
} else {
91+
const rows = results.map((r) => [
92+
r.name,
93+
r.status,
94+
r.publishedAt ? r.publishedAt.slice(0, 10) : "-",
95+
r.status === "installed" ? r.agents?.join(", ") || "-" : (r.reason ?? "-"),
96+
]);
97+
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
98+
emitBare(line);
99+
}
100+
}
101+
102+
const failed = results.filter((r) => r.status === "failed");
103+
if (failed.length > 0) {
104+
throw new BailianError(
105+
`${failed.length}/${results.length} skill(s) failed to install`,
106+
ExitCode.GENERAL,
107+
"Check the reason for failed skills in the output; network failures can be retried with bl skill add",
108+
);
109+
}
110+
},
111+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
defineCommand,
3+
detectOutputFormat,
4+
computeSkillStatuses,
5+
fetchSkillsIndex,
6+
getSkillRegistryBaseUrl,
7+
listSkillDirsOnDisk,
8+
readSkillLock,
9+
} from "bailian-cli-core";
10+
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
11+
12+
const DESCRIPTION_MAX = 60;
13+
14+
function truncate(text: string | undefined): string {
15+
if (!text) return "-";
16+
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1)}…` : text;
17+
}
18+
19+
export default defineCommand({
20+
description: "List registry skills and diff against local installs",
21+
auth: "none",
22+
exampleArgs: ["", "--output json"],
23+
notes: [
24+
"STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)",
25+
],
26+
async run(ctx) {
27+
const format = detectOutputFormat(ctx.settings.output);
28+
// Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk
29+
const index = await fetchSkillsIndex();
30+
const lock = readSkillLock();
31+
const rows = computeSkillStatuses(index, lock, listSkillDirsOnDisk());
32+
33+
if (format === "json") {
34+
emitResult(
35+
{
36+
registry: getSkillRegistryBaseUrl(),
37+
...(index.updatedAt ? { updatedAt: index.updatedAt } : {}),
38+
skills: rows,
39+
},
40+
format,
41+
);
42+
return;
43+
}
44+
if (rows.length === 0) {
45+
emitBare("Skill registry is empty and no skills are installed locally.");
46+
return;
47+
}
48+
const table = rows.map((row) => [
49+
row.name,
50+
row.status,
51+
row.publishedAt ? row.publishedAt.slice(0, 10) : "-",
52+
truncate(row.description),
53+
]);
54+
for (const line of formatTable(["NAME", "STATUS", "UpdatedAt", "DESCRIPTION"], table)) {
55+
emitBare(line);
56+
}
57+
},
58+
});

0 commit comments

Comments
 (0)