Skip to content

Commit 87c3799

Browse files
committed
feat: update skill commend group
1 parent 8dd7862 commit 87c3799

4 files changed

Lines changed: 73 additions & 72 deletions

File tree

packages/commands/src/commands/skill/add.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,58 @@ interface AddOutcome {
2222
reason?: string;
2323
}
2424

25+
/** Max number of skills downloading/installing at the same time. */
26+
const INSTALL_CONCURRENCY = 3;
27+
28+
/**
29+
* Run async task factories with a bounded concurrency pool.
30+
* Returns results in the same order as the input tasks array.
31+
*/
32+
async function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
33+
const results: T[] = new Array(tasks.length);
34+
let nextIndex = 0;
35+
36+
async function worker(): Promise<void> {
37+
while (nextIndex < tasks.length) {
38+
const currentIndex = nextIndex++;
39+
results[currentIndex] = await tasks[currentIndex]();
40+
}
41+
}
42+
43+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
44+
await Promise.all(workers);
45+
return results;
46+
}
47+
2548
export default defineCommand({
2649
description: "Install skills from the Bailian skill registry into local agents",
2750
auth: "none",
28-
usageArgs: "[--name <all|name,...>]",
51+
usageArgs: "--name <all|name,...>",
2952
flags: {
3053
name: {
3154
type: "string",
3255
valueHint: "<all|name,...>",
33-
description: "Skills to install: all (default) or comma-separated skill names",
56+
description: "Skills to install: all or comma-separated skill names",
57+
required: true,
3458
},
3559
},
36-
exampleArgs: ["", "--name all", "--name spark-video,bailian-model-recommend"],
60+
exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"],
3761
async run(ctx) {
3862
const format = detectOutputFormat(ctx.settings.output);
39-
const requested = parseSkillNames(ctx.flags.name, true);
63+
const requested = parseSkillNames(ctx.flags.name, false);
4064
const index = await fetchSkillsIndex();
4165
const remoteNames = Object.keys(index.skills);
4266
const names = requested === "all" ? remoteNames : requested;
4367

4468
const lock = readSkillLock();
4569
const agents = detectInstalledAgents();
46-
const results: AddOutcome[] = [];
4770

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) {
71+
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
72+
// Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key.
73+
const tasks = names.map((name) => async (): Promise<AddOutcome> => {
5074
const entry = index.skills[name];
5175
if (!entry) {
52-
results.push({ name, status: "failed", reason: "skill not found in registry" });
53-
continue;
76+
return { name, status: "failed", reason: "skill not found in registry" };
5477
}
5578
try {
5679
await installSkill(name, entry);
@@ -64,20 +87,21 @@ export default defineCommand({
6487
...(entry.description ? { description: entry.description } : {}),
6588
links: effective.map((link) => link.path),
6689
};
67-
results.push({
90+
return {
6891
name,
6992
status: "installed",
7093
publishedAt: entry.publishedAt,
7194
agents: effective.map((link) => link.agent),
72-
});
95+
};
7396
} catch (err) {
74-
results.push({
97+
return {
7598
name,
7699
status: "failed",
77100
reason: err instanceof Error ? err.message : String(err),
78-
});
101+
};
79102
}
80-
}
103+
});
104+
const results = await runWithConcurrency(tasks, INSTALL_CONCURRENCY);
81105
writeSkillLock(lock);
82106

83107
if (format === "json") {

packages/commands/src/commands/skill/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ export default defineCommand({
4848
const table = rows.map((row) => [
4949
row.name,
5050
row.status,
51-
row.publishedAt ? row.publishedAt.slice(0, 10) : "-",
51+
row.publishedAt ? row.publishedAt.slice(0, 19).replace("T", " ") : "-",
5252
truncate(row.description),
5353
]);
54-
for (const line of formatTable(["NAME", "STATUS", "UpdatedAt", "DESCRIPTION"], table)) {
54+
for (const line of formatTable(["NAME", "STATUS", "UPDATEDAT", "DESCRIPTION"], table)) {
5555
emitBare(line);
5656
}
5757
},

packages/core/src/advisor/sync.ts

Lines changed: 29 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,27 @@
55
* 1. 12h throttle: skip if last check was less than 12h ago
66
* 2. Download skills/index.json from public-read OSS, compare bailian-docs-llm-wiki entry version
77
* 3. Same version → only refresh lastChecked
8-
* 4. Different version → download skills/bailian-docs-llm-wiki/skill.tar.br → brotli decompress +
9-
* tar-stream extract (per-entry path safety check) to same-volume temp dir → renameSync atomic swap
10-
* → write state + skill-lock.json record (same ledger as bl skill add; list shows installed)
8+
* 4. Different version → delegate to the shared skill install pipeline
9+
* (installSkill: download + extract + SKILL.md validate + atomic swap;
10+
* linkSkillToAgents: fan-out symlinks to detected agents;
11+
* upsertSkillLockEntry: write lock WITH links so bl skill remove can reclaim correctly)
1112
*
1213
* Protocol: unified skill publishing protocol (FC publish-skills, all skills are isomorphic), entry point is
1314
* skills/index.json, one skill.tar.br per skill (brotli q6).
1415
*
15-
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Extraction and atomic swap
16-
* reuse skills/extract.ts (same as bl skill installer), symmetric with publisher tar.pack().
16+
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Install, extraction,
17+
* validation, fan-out and lock writing all reuse the skills/ module (same as bl skill add), symmetric
18+
* with publisher tar.pack().
1719
*
1820
* Failure strategy: any step failure silently returns without updating lastChecked; next recommend retries immediately.
1921
*/
20-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
2123
import { join } from "node:path";
2224
import { getConfigDir } from "../config/paths.ts";
23-
import { atomicSwap, extractTarBr } from "../skills/extract.ts";
25+
import { detectInstalledAgents, linkSkillToAgents } from "../skills/agents.ts";
26+
import { installSkill } from "../skills/installer.ts";
2427
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
28+
import type { SkillIndexEntry } from "../skills/types.ts";
2529

2630
/** Public-read OSS skill registry root (hardcoded, does not use env). */
2731
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
@@ -30,32 +34,20 @@ const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
3034
const STATE_FILE_NAME = "wiki-sync-state.json";
3135
const MODELS_FILE = "models.jsonl";
3236
const INDEX_KEY = "index.json";
33-
const ASSET_NAME = "skill.tar.br";
3437

3538
const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h
3639
const INDEX_TIMEOUT_MS = 3000;
37-
const DOWNLOAD_TIMEOUT_MS = 30000;
3840

3941
interface SyncState {
4042
lastChecked: number;
4143
/** Content fingerprint of the last synced revision; the change-detection token */
4244
contentHash: string;
4345
}
4446

45-
/** A single skill entry in skills/index.json (unified publishing protocol) */
46-
interface IndexSkillEntry {
47-
/** Reserved for the skill's own semantic version; not used for change detection */
48-
version?: string;
49-
publishedAt?: string;
50-
description?: string;
51-
contentHash?: string;
52-
compression?: string;
53-
}
54-
5547
interface SkillsIndex {
5648
version: number;
5749
updatedAt?: string;
58-
skills: Record<string, IndexSkillEntry>;
50+
skills: Record<string, SkillIndexEntry>;
5951
}
6052

6153
function getCatalogDir(): string {
@@ -93,17 +85,19 @@ function writeState(state: SyncState): void {
9385

9486
/**
9587
* Record this sync in skill-lock.json so the wiki skill shares the same ledger as bl skill
96-
* (list shows installed instead of untracked; update can manage subsequent upgrades).
88+
* (list shows installed instead of untracked; update/remove can manage it correctly).
89+
* Includes fan-out links so bl skill remove can reclaim agent symlinks.
9790
* Bookkeeping in the silent channel must be best-effort: failure does not affect sync results.
9891
*/
99-
function recordWikiInLock(entry: IndexSkillEntry): void {
92+
function recordWikiInLock(entry: SkillIndexEntry, links: string[]): void {
10093
try {
10194
upsertSkillLockEntry(WIKI_SKILL_NAME, {
10295
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
10396
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
10497
installedAt: new Date().toISOString(),
10598
sourceType: "oss",
10699
...(entry.description ? { description: entry.description } : {}),
100+
links,
107101
});
108102
} catch {
109103
/* Bookkeeping failure does not block sync; next sync or bl skill add will fill it in */
@@ -120,7 +114,7 @@ function wikiLockUpToDate(contentHash: string): boolean {
120114
}
121115

122116
/** Fetch skills/index.json and extract the wiki skill entry; returns null on any failure */
123-
async function fetchIndexEntry(): Promise<IndexSkillEntry | null> {
117+
async function fetchIndexEntry(): Promise<SkillIndexEntry | null> {
124118
try {
125119
const res = await fetch(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, {
126120
signal: AbortSignal.timeout(INDEX_TIMEOUT_MS),
@@ -134,16 +128,6 @@ async function fetchIndexEntry(): Promise<IndexSkillEntry | null> {
134128
}
135129
}
136130

137-
async function downloadBuffer(url: string): Promise<Buffer | null> {
138-
try {
139-
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
140-
if (!res.ok) return null;
141-
return Buffer.from(await res.arrayBuffer());
142-
} catch {
143-
return null;
144-
}
145-
}
146-
147131
/**
148132
* Check and sync Wiki data. Runs silently; never throws.
149133
* @returns Whether data was actually updated (for testing/debugging)
@@ -172,29 +156,26 @@ export async function maybeSyncWikiData(): Promise<boolean> {
172156
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
173157
writeState({ lastChecked: now, contentHash: entry.contentHash });
174158
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
175-
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(entry);
159+
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(entry, []);
176160
return false;
177161
}
178162

179-
// 4. Different content: download + extract (with entry path safety check) + atomic swap
180-
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${ASSET_NAME}`);
181-
if (!tarBuf) return false;
182-
183-
const catalogDir = getCatalogDir();
184-
// Same-volume temp dir: extract here then rename; cross-device rename would EXDEV
185-
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
163+
// 4. Different content or missing data: delegate to the shared skill install pipeline
164+
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
186165
try {
187-
mkdirSync(tmpDir, { recursive: true });
188-
await extractTarBr(tarBuf, tmpDir);
189-
atomicSwap(tmpDir, catalogDir);
166+
await installSkill(WIKI_SKILL_NAME, entry);
167+
const agents = detectInstalledAgents();
168+
const linkResults = linkSkillToAgents(WIKI_SKILL_NAME, agents);
169+
const effectiveLinks = linkResults
170+
.filter((link) => link.mode !== "skipped")
171+
.map((link) => link.path);
172+
recordWikiInLock(entry, effectiveLinks);
190173
} catch {
191-
// Extract/swap failed → clean up temp dir, leave existing data untouched, do not write state
192-
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
174+
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
193175
return false;
194176
}
195177

196-
// 5. Success: write state + skill-lock.json record (unified bl skill ledger)
178+
// 5. Success: write state
197179
writeState({ lastChecked: now, contentHash: entry.contentHash });
198-
recordWikiInLock(entry);
199180
return true;
200181
}

skills/bailian-cli/reference/skill.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,16 @@ Index: [index.md](index.md)
2222
| --------------- | ---------------------------------------------------------------- |
2323
| **Name** | `skill add` |
2424
| **Description** | Install skills from the Bailian skill registry into local agents |
25-
| **Usage** | `bl skill add [--name <all\|name,...>]` |
25+
| **Usage** | `bl skill add --name <all\|name,...>` |
2626

2727
#### Flags
2828

29-
| Flag | Type | Required | Description |
30-
| ------------------------ | ------ | -------- | --------------------------------------------------------------- |
31-
| `--name <all\|name,...>` | string | no | Skills to install: all (default) or comma-separated skill names |
29+
| Flag | Type | Required | Description |
30+
| ------------------------ | ------ | -------- | ----------------------------------------------------- |
31+
| `--name <all\|name,...>` | string | yes | Skills to install: all or comma-separated skill names |
3232

3333
#### Examples
3434

35-
```bash
36-
bl skill add
37-
```
38-
3935
```bash
4036
bl skill add --name all
4137
```

0 commit comments

Comments
 (0)