feat: update skill commend group

This commit is contained in:
故璃
2026-07-27 12:30:20 +08:00
parent 8dd786287f
commit 87c37994f2
4 changed files with 73 additions and 72 deletions
+38 -14
View File
@@ -22,35 +22,58 @@ interface AddOutcome {
reason?: string;
}
/** Max number of skills downloading/installing at the same time. */
const INSTALL_CONCURRENCY = 3;
/**
* Run async task factories with a bounded concurrency pool.
* Returns results in the same order as the input tasks array.
*/
async function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = new Array(tasks.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < tasks.length) {
const currentIndex = nextIndex++;
results[currentIndex] = await tasks[currentIndex]();
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
await Promise.all(workers);
return results;
}
export default defineCommand({
description: "Install skills from the Bailian skill registry into local agents",
auth: "none",
usageArgs: "[--name <all|name,...>]",
usageArgs: "--name <all|name,...>",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
description: "Skills to install: all (default) or comma-separated skill names",
description: "Skills to install: all or comma-separated skill names",
required: true,
},
},
exampleArgs: ["", "--name all", "--name spark-video,bailian-model-recommend"],
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 requested = parseSkillNames(ctx.flags.name, false);
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) {
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
// Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key.
const tasks = names.map((name) => async (): Promise<AddOutcome> => {
const entry = index.skills[name];
if (!entry) {
results.push({ name, status: "failed", reason: "skill not found in registry" });
continue;
return { name, status: "failed", reason: "skill not found in registry" };
}
try {
await installSkill(name, entry);
@@ -64,20 +87,21 @@ export default defineCommand({
...(entry.description ? { description: entry.description } : {}),
links: effective.map((link) => link.path),
};
results.push({
return {
name,
status: "installed",
publishedAt: entry.publishedAt,
agents: effective.map((link) => link.agent),
});
};
} catch (err) {
results.push({
return {
name,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
});
};
}
}
});
const results = await runWithConcurrency(tasks, INSTALL_CONCURRENCY);
writeSkillLock(lock);
if (format === "json") {
+2 -2
View File
@@ -48,10 +48,10 @@ export default defineCommand({
const table = rows.map((row) => [
row.name,
row.status,
row.publishedAt ? row.publishedAt.slice(0, 10) : "-",
row.publishedAt ? row.publishedAt.slice(0, 19).replace("T", " ") : "-",
truncate(row.description),
]);
for (const line of formatTable(["NAME", "STATUS", "UpdatedAt", "DESCRIPTION"], table)) {
for (const line of formatTable(["NAME", "STATUS", "UPDATEDAT", "DESCRIPTION"], table)) {
emitBare(line);
}
},
+29 -48
View File
@@ -5,23 +5,27 @@
* 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)
* 4. Different version → delegate to the shared skill install pipeline
* (installSkill: download + extract + SKILL.md validate + atomic swap;
* linkSkillToAgents: fan-out symlinks to detected agents;
* upsertSkillLockEntry: write lock WITH links so bl skill remove can reclaim correctly)
*
* 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).
*
* 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().
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Install, extraction,
* validation, fan-out and lock writing all reuse the skills/ module (same as bl skill add), symmetric
* with publisher tar.pack().
*
* Failure strategy: any step failure silently returns without updating lastChecked; next recommend retries immediately.
*/
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
import { atomicSwap, extractTarBr } from "../skills/extract.ts";
import { detectInstalledAgents, linkSkillToAgents } from "../skills/agents.ts";
import { installSkill } from "../skills/installer.ts";
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
import type { SkillIndexEntry } from "../skills/types.ts";
/** Public-read OSS skill registry root (hardcoded, does not use env). */
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
@@ -30,11 +34,9 @@ const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
const STATE_FILE_NAME = "wiki-sync-state.json";
const MODELS_FILE = "models.jsonl";
const INDEX_KEY = "index.json";
const ASSET_NAME = "skill.tar.br";
const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h
const INDEX_TIMEOUT_MS = 3000;
const DOWNLOAD_TIMEOUT_MS = 30000;
interface SyncState {
lastChecked: number;
@@ -42,20 +44,10 @@ interface SyncState {
contentHash: 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;
description?: string;
contentHash?: string;
compression?: string;
}
interface SkillsIndex {
version: number;
updatedAt?: string;
skills: Record<string, IndexSkillEntry>;
skills: Record<string, SkillIndexEntry>;
}
function getCatalogDir(): string {
@@ -93,10 +85,11 @@ function writeState(state: SyncState): void {
/**
* 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).
* (list shows installed instead of untracked; update/remove can manage it correctly).
* Includes fan-out links so bl skill remove can reclaim agent symlinks.
* Bookkeeping in the silent channel must be best-effort: failure does not affect sync results.
*/
function recordWikiInLock(entry: IndexSkillEntry): void {
function recordWikiInLock(entry: SkillIndexEntry, links: string[]): void {
try {
upsertSkillLockEntry(WIKI_SKILL_NAME, {
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
@@ -104,6 +97,7 @@ function recordWikiInLock(entry: IndexSkillEntry): void {
installedAt: new Date().toISOString(),
sourceType: "oss",
...(entry.description ? { description: entry.description } : {}),
links,
});
} catch {
/* 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 {
}
/** Fetch skills/index.json and extract the wiki skill entry; returns null on any failure */
async function fetchIndexEntry(): Promise<IndexSkillEntry | null> {
async function fetchIndexEntry(): Promise<SkillIndexEntry | null> {
try {
const res = await fetch(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, {
signal: AbortSignal.timeout(INDEX_TIMEOUT_MS),
@@ -134,16 +128,6 @@ async function fetchIndexEntry(): Promise<IndexSkillEntry | null> {
}
}
async function downloadBuffer(url: string): Promise<Buffer | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
if (!res.ok) return null;
return Buffer.from(await res.arrayBuffer());
} catch {
return null;
}
}
/**
* Check and sync Wiki data. Runs silently; never throws.
* @returns Whether data was actually updated (for testing/debugging)
@@ -172,29 +156,26 @@ export async function maybeSyncWikiData(): Promise<boolean> {
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);
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(entry, []);
return false;
}
// 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;
const catalogDir = getCatalogDir();
// Same-volume temp dir: extract here then rename; cross-device rename would EXDEV
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
// 4. Different content or missing data: delegate to the shared skill install pipeline
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
try {
mkdirSync(tmpDir, { recursive: true });
await extractTarBr(tarBuf, tmpDir);
atomicSwap(tmpDir, catalogDir);
await installSkill(WIKI_SKILL_NAME, entry);
const agents = detectInstalledAgents();
const linkResults = linkSkillToAgents(WIKI_SKILL_NAME, agents);
const effectiveLinks = linkResults
.filter((link) => link.mode !== "skipped")
.map((link) => link.path);
recordWikiInLock(entry, effectiveLinks);
} catch {
// Extract/swap failed → clean up temp dir, leave existing data untouched, do not write state
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
return false;
}
// 5. Success: write state + skill-lock.json record (unified bl skill ledger)
// 5. Success: write state
writeState({ lastChecked: now, contentHash: entry.contentHash });
recordWikiInLock(entry);
return true;
}
+4 -8
View File
@@ -22,20 +22,16 @@ Index: [index.md](index.md)
| --------------- | ---------------------------------------------------------------- |
| **Name** | `skill add` |
| **Description** | Install skills from the Bailian skill registry into local agents |
| **Usage** | `bl skill add [--name <all\|name,...>]` |
| **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 |
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ----------------------------------------------------- |
| `--name <all\|name,...>` | string | yes | Skills to install: all or comma-separated skill names |
#### Examples
```bash
bl skill add
```
```bash
bl skill add --name all
```