Skip to content

Commit cf2592c

Browse files
authored
Merge pull request #133 from modelstudioai/feat/bailian-wiki-doc-sync
feat: add skill commend & wiki sync
2 parents 6d61afc + 3766b6d commit cf2592c

64 files changed

Lines changed: 3025 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ packages/cli/scene/**/outputs/
4646

4747
# Environment variables (sensitive data)
4848
.env
49+
50+
# Local scratch / plan drafts (never commit)
51+
.scratch/

packages/cli/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
},
2626
"files": [
2727
"dist",
28-
"README.zh.md"
28+
"README.zh.md",
29+
"postinstall.js"
2930
],
3031
"type": "module",
3132
"exports": {
@@ -45,12 +46,14 @@
4546
"build": "vp pack",
4647
"dev": "tsx src/main.ts",
4748
"test": "vp test",
48-
"check": "vp check"
49+
"check": "vp check",
50+
"postinstall": "node postinstall.js"
4951
},
5052
"dependencies": {
5153
"bailian-cli-commands": "workspace:*",
5254
"bailian-cli-core": "workspace:*",
53-
"bailian-cli-runtime": "workspace:*"
55+
"bailian-cli-runtime": "workspace:*",
56+
"tar-stream": "catalog:"
5457
},
5558
"devDependencies": {
5659
"@clack/prompts": "^0.7.0",

packages/cli/postinstall.js

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
3+
*
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`.
7+
*
8+
* Flow (unified skill publishing protocol: skills/index.json + one content-addressed object 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/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
11+
* legacy fallback to skill.tar.br when the entry has no valid object field
12+
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
13+
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
14+
* 5. Write ~/.bailian/wiki-sync-state.json
15+
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
16+
*
17+
* Design constraints:
18+
* - Unconditional overwrite: every install fully replaces, no version comparison
19+
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
20+
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
21+
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
22+
*/
23+
import {
24+
createWriteStream,
25+
existsSync,
26+
mkdirSync,
27+
readFileSync,
28+
renameSync,
29+
rmSync,
30+
writeFileSync,
31+
} from "node:fs";
32+
import { homedir } from "node:os";
33+
import { dirname, join } from "node:path";
34+
import { Readable } from "node:stream";
35+
import { pipeline } from "node:stream/promises";
36+
import { createBrotliDecompress } from "node:zlib";
37+
import tar from "tar-stream";
38+
39+
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
40+
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
41+
const CONFIG_DIR_NAME = ".bailian";
42+
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
43+
const STATE_FILE_NAME = "wiki-sync-state.json";
44+
const INDEX_KEY = "index.json";
45+
/** Legacy fixed asset key (entries without a valid content-addressed object field) */
46+
const LEGACY_ASSET_NAME = "skill.tar.br";
47+
/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */
48+
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
49+
50+
const INDEX_TIMEOUT_MS = 3000;
51+
const DOWNLOAD_TIMEOUT_MS = 30000;
52+
53+
function getConfigDir() {
54+
if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR;
55+
return join(homedir(), CONFIG_DIR_NAME);
56+
}
57+
58+
function getCatalogDir() {
59+
return join(getConfigDir(), SKILL_DIR_NAME);
60+
}
61+
62+
function getStatePath() {
63+
return join(getConfigDir(), STATE_FILE_NAME);
64+
}
65+
66+
function getSkillLockPath() {
67+
return join(getConfigDir(), "skills", "skill-lock.json");
68+
}
69+
70+
/**
71+
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
72+
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
73+
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
74+
* best-effort: failure does not affect data sync results.
75+
*/
76+
function upsertSkillLock(name, entry) {
77+
try {
78+
let lock = { version: 1, skills: {} };
79+
try {
80+
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
81+
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
82+
lock = parsed;
83+
}
84+
} catch {
85+
/* absent/corrupted → empty table */
86+
}
87+
lock.skills[name] = { ...lock.skills[name], ...entry };
88+
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
89+
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
90+
} catch {
91+
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
92+
}
93+
}
94+
95+
async function fetchJson(url, timeoutMs) {
96+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
97+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
98+
return res.json();
99+
}
100+
101+
async function downloadBuffer(url) {
102+
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
103+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
104+
return Buffer.from(await res.arrayBuffer());
105+
}
106+
107+
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
108+
function isSafeEntryName(name) {
109+
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
110+
return !name.split("/").includes("..");
111+
}
112+
113+
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
114+
async function extractTarBr(tarBrBuffer, destDir) {
115+
const extract = tar.extract();
116+
117+
extract.on("entry", (header, stream, next) => {
118+
if (!isSafeEntryName(header.name)) {
119+
// Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this
120+
// error; silence the entry stream to avoid its companion error becoming unhandled
121+
stream.on("error", () => {});
122+
stream.resume();
123+
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
124+
return;
125+
}
126+
const filePath = join(destDir, header.name);
127+
if (header.type === "directory") {
128+
mkdirSync(filePath, { recursive: true });
129+
stream.resume();
130+
stream.on("end", next);
131+
return;
132+
}
133+
mkdirSync(dirname(filePath), { recursive: true });
134+
const ws = createWriteStream(filePath);
135+
stream.pipe(ws);
136+
ws.on("finish", next);
137+
ws.on("error", next);
138+
});
139+
140+
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
141+
}
142+
143+
/** Atomic swap: tmpDir (same volume) → catalogDir. */
144+
function atomicSwap(tmpDir, catalogDir) {
145+
mkdirSync(dirname(catalogDir), { recursive: true });
146+
const backup = `${catalogDir}.old-${Date.now()}`;
147+
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
148+
try {
149+
renameSync(tmpDir, catalogDir);
150+
} catch (err) {
151+
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
152+
throw err;
153+
}
154+
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
155+
}
156+
157+
async function main() {
158+
// 1. Download skills/index.json and get the wiki entry
159+
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
160+
const entry = index?.skills?.[WIKI_SKILL_NAME];
161+
if (!entry?.contentHash)
162+
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
163+
164+
// 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback
165+
const assetName =
166+
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
167+
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
168+
169+
// 3. Extract to same-volume temp dir + atomic swap
170+
const catalogDir = getCatalogDir();
171+
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
172+
try {
173+
mkdirSync(tmpDir, { recursive: true });
174+
await extractTarBr(tarBuf, tmpDir);
175+
atomicSwap(tmpDir, catalogDir);
176+
} catch (err) {
177+
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
178+
throw err;
179+
}
180+
181+
// 4. Write state
182+
try {
183+
writeFileSync(
184+
getStatePath(),
185+
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
186+
);
187+
} catch {
188+
/* state write failure has no impact: first recommend will re-check */
189+
}
190+
191+
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
192+
upsertSkillLock(WIKI_SKILL_NAME, {
193+
contentHash: entry.contentHash,
194+
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
195+
installedAt: new Date().toISOString(),
196+
sourceType: "oss",
197+
...(entry.description ? { description: entry.description } : {}),
198+
});
199+
200+
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
201+
}
202+
203+
main().catch((err) => {
204+
// Unconditional pass-through: install-time network/permission issues should not block npm install;
205+
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
206+
const msg = err instanceof Error ? err.message : String(err);
207+
process.stderr.write(
208+
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
209+
);
210+
// Force a success exit code so a download failure never fails `npm install`.
211+
// eslint-disable-next-line unicorn/no-process-exit
212+
process.exit(0);
213+
});

packages/cli/src/commands.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ import {
8989
pluginLink,
9090
pluginList,
9191
pluginRemove,
92+
skillAdd,
93+
skillUpdate,
94+
skillRemove,
95+
skillList,
9296
managedAgentInit,
9397
managedAgentValidate,
9498
managedAgentPlan,
@@ -203,6 +207,10 @@ export const commands: Record<string, AnyCommand> = {
203207
"plugin link": pluginLink,
204208
"plugin list": pluginList,
205209
"plugin remove": pluginRemove,
210+
"skill add": skillAdd,
211+
"skill update": skillUpdate,
212+
"skill remove": skillRemove,
213+
"skill list": skillList,
206214
"managed-agent init": managedAgentInit,
207215
"managed-agent validate": managedAgentValidate,
208216
"managed-agent plan": managedAgentPlan,

packages/commands/src/commands/advisor/recommend.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type GetModelsOptions,
77
getModels,
88
type IntentProfile,
9+
maybeSyncWikiData,
910
type PipelineStep,
1011
type RecommendedModel,
1112
type RecommendResult,
@@ -248,6 +249,12 @@ export default defineCommand({
248249
const { settings, flags } = ctx;
249250
const userInput = flags.message;
250251
const top = 3;
252+
253+
// Keep the local wiki catalog fresh: throttled (12h) version check against
254+
// the remote manifest, silently replaces data when a newer version exists.
255+
// Never throws — a sync failure must not block recommendation.
256+
await maybeSyncWikiData();
257+
251258
// Default to JSON for structured output; render boxen cards only when the
252259
// user explicitly asked for text output.
253260
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";

packages/commands/src/commands/dataset/delete.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
2-
import { emitResult, emitBare } from "bailian-cli-runtime";
2+
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
33

44
const DELETE_FLAGS = {
55
fileId: {
@@ -30,6 +30,7 @@ export default defineCommand({
3030

3131
if (settings.quiet || format === "text") {
3232
emitBare(`Deleted ${fileId}.`);
33+
emitRequestId(response.request_id, settings.quiet);
3334
} else {
3435
emitResult(response, format);
3536
}

packages/commands/src/commands/dataset/get.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
2-
import { emitResult, emitBare } from "bailian-cli-runtime";
2+
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
33

44
const GET_FLAGS = {
55
fileId: {
@@ -46,7 +46,7 @@ export default defineCommand({
4646
};
4747

4848
if (format === "json") {
49-
emitResult(item, format);
49+
emitResult({ ...item, request_id: response.request_id }, format);
5050
return;
5151
}
5252

@@ -58,5 +58,6 @@ export default defineCommand({
5858
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
5959
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
6060
if (item.description) emitBare(`description: ${item.description}`);
61+
emitRequestId(response.request_id, settings.quiet);
6162
},
6263
});

packages/commands/src/commands/dataset/list.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
2-
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
2+
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
33

44
const LIST_FLAGS = {
55
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -55,7 +55,7 @@ export default defineCommand({
5555
}));
5656

5757
if (format === "json") {
58-
emitResult({ items, total }, format);
58+
emitResult({ items, total, request_id: response.request_id }, format);
5959
return;
6060
}
6161

@@ -68,5 +68,6 @@ export default defineCommand({
6868
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
6969
for (const line of formatTable(headers, rows)) emitBare(line);
7070
if (total !== undefined) emitBare(`\nTotal: ${total}`);
71+
emitRequestId(response.request_id, settings.quiet);
7172
},
7273
});

packages/commands/src/commands/dataset/upload.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@ import {
99
MAX_MEDIA_ZIP_BYTES,
1010
BailianError,
1111
ExitCode,
12-
type DatasetFile,
1312
type FlagsDef,
1413
} from "bailian-cli-core";
15-
import { emitResult, emitBare } from "bailian-cli-runtime";
14+
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
1615

1716
const UPLOAD_FLAGS = {
1817
file: {
@@ -135,17 +134,19 @@ export default defineCommand({
135134
return;
136135
}
137136

138-
const uploaded: DatasetFile = await uploadDataset(ctx.client, {
137+
const uploaded = await uploadDataset(ctx.client, {
139138
filePath,
140139
purpose,
141140
});
141+
const { request_id, ...file } = uploaded;
142142

143143
if (settings.quiet) {
144-
emitBare(uploaded.file_id);
144+
emitBare(file.file_id);
145145
} else if (format === "text") {
146-
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
146+
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
147+
emitRequestId(request_id, settings.quiet);
147148
} else {
148-
emitResult(uploaded, format);
149+
emitResult({ ...file, request_id }, format);
149150
}
150151
},
151152
});

0 commit comments

Comments
 (0)