diff --git a/.gitignore b/.gitignore index f1c88cb7..20dac14d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ packages/cli/scene/**/outputs/ # Environment variables (sensitive data) .env + +# Local scratch / plan drafts (never commit) +.scratch/ diff --git a/packages/cli/package.json b/packages/cli/package.json index 2c50efc3..3b8f5ffd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,8 @@ }, "files": [ "dist", - "README.zh.md" + "README.zh.md", + "postinstall.js" ], "type": "module", "exports": { @@ -45,12 +46,14 @@ "build": "vp pack", "dev": "tsx src/main.ts", "test": "vp test", - "check": "vp check" + "check": "vp check", + "postinstall": "node postinstall.js" }, "dependencies": { "bailian-cli-commands": "workspace:*", "bailian-cli-core": "workspace:*", - "bailian-cli-runtime": "workspace:*" + "bailian-cli-runtime": "workspace:*", + "tar-stream": "catalog:" }, "devDependencies": { "@clack/prompts": "^0.7.0", diff --git a/packages/cli/postinstall.js b/packages/cli/postinstall.js new file mode 100644 index 00000000..a502a0b1 --- /dev/null +++ b/packages/cli/postinstall.js @@ -0,0 +1,213 @@ +/** + * postinstall.js — Wiki data sync (layer 1: triggered by npm install) + * + * 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`. + * + * Flow (unified skill publishing protocol: skills/index.json + one content-addressed object 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/ (sha256-.tar.br, brotli q6, ~2.3MB); + * legacy fallback to skill.tar.br when the entry has no valid object field + * 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) + * + * 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 { + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +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"; + +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 INDEX_KEY = "index.json"; +/** Legacy fixed asset key (entries without a valid content-addressed object field) */ +const LEGACY_ASSET_NAME = "skill.tar.br"; +/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */ +const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/; + +const INDEX_TIMEOUT_MS = 3000; +const DOWNLOAD_TIMEOUT_MS = 30000; + +function getConfigDir() { + if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR; + return join(homedir(), CONFIG_DIR_NAME); +} + +function getCatalogDir() { + return join(getConfigDir(), SKILL_DIR_NAME); +} + +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}`); + return res.json(); +} + +async function downloadBuffer(url) { + const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +/** 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)) { + // Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this + // error; silence the entry stream to avoid its companion error becoming unhandled + 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: tmpDir (same volume) → catalogDir. */ +function atomicSwap(tmpDir, catalogDir) { + 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 }); +} + +async function main() { + // 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. Download the skill archive: content-addressed object first, legacy fixed key as fallback + const assetName = + entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME; + const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`); + + // 3. Extract to same-volume temp dir + atomic swap + const catalogDir = getCatalogDir(); + const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`; + try { + mkdirSync(tmpDir, { recursive: true }); + await extractTarBr(tarBuf, tmpDir); + atomicSwap(tmpDir, catalogDir); + } catch (err) { + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); + throw err; + } + + // 4. Write state + try { + writeFileSync( + getStatePath(), + JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }), + ); + } catch { + /* state write failure has no impact: first recommend will re-check */ + } + + // 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) => { + // 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 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); +}); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 0ef80979..2cbda30d 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -89,6 +89,10 @@ import { pluginLink, pluginList, pluginRemove, + skillAdd, + skillUpdate, + skillRemove, + skillList, managedAgentInit, managedAgentValidate, managedAgentPlan, @@ -203,6 +207,10 @@ export const commands: Record = { "plugin link": pluginLink, "plugin list": pluginList, "plugin remove": pluginRemove, + "skill add": skillAdd, + "skill update": skillUpdate, + "skill remove": skillRemove, + "skill list": skillList, "managed-agent init": managedAgentInit, "managed-agent validate": managedAgentValidate, "managed-agent plan": managedAgentPlan, diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index c848a511..c328b060 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -6,6 +6,7 @@ import { type GetModelsOptions, getModels, type IntentProfile, + maybeSyncWikiData, type PipelineStep, type RecommendedModel, type RecommendResult, @@ -248,6 +249,12 @@ export default defineCommand({ const { settings, flags } = ctx; const userInput = flags.message; const top = 3; + + // Keep the local wiki catalog fresh: throttled (12h) version check against + // the remote manifest, silently replaces data when a newer version exists. + // Never throws — a sync failure must not block recommendation. + await maybeSyncWikiData(); + // Default to JSON for structured output; render boxen cards only when the // user explicitly asked for text output. const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; diff --git a/packages/commands/src/commands/dataset/delete.ts b/packages/commands/src/commands/dataset/delete.ts index e8e5427e..5e18766a 100644 --- a/packages/commands/src/commands/dataset/delete.ts +++ b/packages/commands/src/commands/dataset/delete.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DELETE_FLAGS = { fileId: { @@ -30,6 +30,7 @@ export default defineCommand({ if (settings.quiet || format === "text") { emitBare(`Deleted ${fileId}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/dataset/get.ts b/packages/commands/src/commands/dataset/get.ts index f64080d2..2c1cf714 100644 --- a/packages/commands/src/commands/dataset/get.ts +++ b/packages/commands/src/commands/dataset/get.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const GET_FLAGS = { fileId: { @@ -46,7 +46,7 @@ export default defineCommand({ }; if (format === "json") { - emitResult(item, format); + emitResult({ ...item, request_id: response.request_id }, format); return; } @@ -58,5 +58,6 @@ export default defineCommand({ if (item.purpose) emitBare(`purpose: ${item.purpose}`); if (item.created_at) emitBare(`created_at: ${item.created_at}`); if (item.description) emitBare(`description: ${item.description}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/dataset/list.ts b/packages/commands/src/commands/dataset/list.ts index a34e64ef..7bc858b4 100644 --- a/packages/commands/src/commands/dataset/list.ts +++ b/packages/commands/src/commands/dataset/list.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const LIST_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -55,7 +55,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -68,5 +68,6 @@ export default defineCommand({ const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]); for (const line of formatTable(headers, rows)) emitBare(line); if (total !== undefined) emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/dataset/upload.ts b/packages/commands/src/commands/dataset/upload.ts index f7d805b2..a3c61045 100644 --- a/packages/commands/src/commands/dataset/upload.ts +++ b/packages/commands/src/commands/dataset/upload.ts @@ -9,10 +9,9 @@ import { MAX_MEDIA_ZIP_BYTES, BailianError, ExitCode, - type DatasetFile, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const UPLOAD_FLAGS = { file: { @@ -135,17 +134,19 @@ export default defineCommand({ return; } - const uploaded: DatasetFile = await uploadDataset(ctx.client, { + const uploaded = await uploadDataset(ctx.client, { filePath, purpose, }); + const { request_id, ...file } = uploaded; if (settings.quiet) { - emitBare(uploaded.file_id); + emitBare(file.file_id); } else if (format === "text") { - emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`); + emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`); + emitRequestId(request_id, settings.quiet); } else { - emitResult(uploaded, format); + emitResult({ ...file, request_id }, format); } }, }); diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index 60e18577..96c75d92 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -11,7 +11,7 @@ import { type CommandContext, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const CREATE_FLAGS = { model: { @@ -163,6 +163,7 @@ async function runCreate( emitBare( `\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? ""}`, ); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/deploy/delete.ts b/packages/commands/src/commands/deploy/delete.ts index 8340d099..bd6dd85e 100644 --- a/packages/commands/src/commands/deploy/delete.ts +++ b/packages/commands/src/commands/deploy/delete.ts @@ -7,7 +7,7 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DELETE_FLAGS = { deployedModel: { @@ -71,6 +71,7 @@ export default defineCommand({ emitBare(deployedModel); } else if (format === "text") { emitBare(`Deleted ${deployedModel}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/deploy/get.ts b/packages/commands/src/commands/deploy/get.ts index e8f6040f..9c53e96f 100644 --- a/packages/commands/src/commands/deploy/get.ts +++ b/packages/commands/src/commands/deploy/get.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const GET_FLAGS = { deployedModel: { @@ -58,7 +58,7 @@ export default defineCommand({ if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified; if (format === "json") { - emitResult(item, format); + emitResult({ ...item, request_id: response.request_id }, format); return; } @@ -69,5 +69,6 @@ export default defineCommand({ const display = typeof value === "string" ? value : JSON.stringify(value); emitBare(`${label(key)}${display}`); } + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/deploy/list.ts b/packages/commands/src/commands/deploy/list.ts index d26b4e30..e7c29173 100644 --- a/packages/commands/src/commands/deploy/list.ts +++ b/packages/commands/src/commands/deploy/list.ts @@ -4,7 +4,7 @@ import { listDeployments, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const LIST_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -58,7 +58,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -78,5 +78,6 @@ export default defineCommand({ ]); for (const line of formatTable(headers, rows)) emitBare(line); if (total !== undefined) emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/deploy/models.ts b/packages/commands/src/commands/deploy/models.ts index 6bc8daa3..d1ea972e 100644 --- a/packages/commands/src/commands/deploy/models.ts +++ b/packages/commands/src/commands/deploy/models.ts @@ -4,7 +4,7 @@ import { listDeployableModels, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const MODELS_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -122,7 +122,7 @@ export default defineCommand({ } return out; }); - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -168,5 +168,6 @@ export default defineCommand({ ]); for (const line of formatTable(headers, rows)) emitBare(line); if (total !== undefined) emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/deploy/scale.ts b/packages/commands/src/commands/deploy/scale.ts index d6d731b1..805f9c57 100644 --- a/packages/commands/src/commands/deploy/scale.ts +++ b/packages/commands/src/commands/deploy/scale.ts @@ -4,7 +4,7 @@ import { scaleDeployment, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const SCALE_FLAGS = { deployedModel: { @@ -72,6 +72,7 @@ export default defineCommand({ } else if (format === "text") { const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : ""; emitBare(`Scaled ${deployedModel}${cap}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/deploy/update.ts b/packages/commands/src/commands/deploy/update.ts index fd582c9b..0bbb29d2 100644 --- a/packages/commands/src/commands/deploy/update.ts +++ b/packages/commands/src/commands/deploy/update.ts @@ -4,7 +4,7 @@ import { updateDeployment, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const UPDATE_FLAGS = { deployedModel: { @@ -70,6 +70,7 @@ export default defineCommand({ if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`); const summary = parts.length ? ` (${parts.join(", ")})` : ""; emitBare(`Updated ${deployedModel}${summary}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/cancel.ts b/packages/commands/src/commands/finetune/cancel.ts index bc511ad9..789a3b03 100644 --- a/packages/commands/src/commands/finetune/cancel.ts +++ b/packages/commands/src/commands/finetune/cancel.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const CANCEL_FLAGS = { jobId: { @@ -38,6 +38,7 @@ export default defineCommand({ } else if (format === "text") { const status = job?.status ? ` (status=${job.status})` : ""; emitBare(`Cancelled ${jobId}${status}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/checkpoints.ts b/packages/commands/src/commands/finetune/checkpoints.ts index ca110001..7c05a363 100644 --- a/packages/commands/src/commands/finetune/checkpoints.ts +++ b/packages/commands/src/commands/finetune/checkpoints.ts @@ -4,7 +4,7 @@ import { listCheckpoints, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const CHECKPOINTS_FLAGS = { jobId: { @@ -47,7 +47,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -60,5 +60,6 @@ export default defineCommand({ const rows = items.map((i) => [i.checkpoint, i.step, i.status]); for (const line of formatTable(headers, rows)) emitBare(line); emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index 8e72375e..6fbb54f0 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -27,7 +27,7 @@ import { } from "bailian-cli-core"; import { existsSync, statSync } from "fs"; import { basename } from "path"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; /** * A `--datasets` / `--validations` token is treated as a local file to upload @@ -631,6 +631,7 @@ async function runCreate( if (job?.job_id) { emitBare(`Created fine-tune job: ${job.job_id}`); if (job.status) emitBare(`Status: ${job.status}`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/delete.ts b/packages/commands/src/commands/finetune/delete.ts index f5ab6fc1..102fda61 100644 --- a/packages/commands/src/commands/finetune/delete.ts +++ b/packages/commands/src/commands/finetune/delete.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DELETE_FLAGS = { jobId: { @@ -36,6 +36,7 @@ export default defineCommand({ emitBare(jobId); } else if (format === "text") { emitBare(`Deleted ${jobId}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/export.ts b/packages/commands/src/commands/finetune/export.ts index 7f3857e1..7e52de0b 100644 --- a/packages/commands/src/commands/finetune/export.ts +++ b/packages/commands/src/commands/finetune/export.ts @@ -4,7 +4,7 @@ import { exportCheckpoint, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const EXPORT_FLAGS = { jobId: { @@ -69,6 +69,7 @@ export default defineCommand({ emitBare( `Next: ${identity.binName} deploy text create --model ${exported} --name `, ); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/get.ts b/packages/commands/src/commands/finetune/get.ts index d661c797..0ba01147 100644 --- a/packages/commands/src/commands/finetune/get.ts +++ b/packages/commands/src/commands/finetune/get.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const GET_FLAGS = { jobId: { @@ -56,7 +56,7 @@ export default defineCommand({ }; if (format === "json") { - emitResult(item, format); + emitResult({ ...item, request_id: response.request_id }, format); return; } @@ -76,5 +76,6 @@ export default defineCommand({ if (item.model_name) emitBare(`model_name: ${item.model_name}`); if (item.created_at) emitBare(`created_at: ${item.created_at}`); if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/finetune/list.ts b/packages/commands/src/commands/finetune/list.ts index d8d822dd..ca4edddf 100644 --- a/packages/commands/src/commands/finetune/list.ts +++ b/packages/commands/src/commands/finetune/list.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const LIST_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -48,7 +48,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -78,5 +78,6 @@ export default defineCommand({ emitBare( `Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``, ); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/finetune/logs.ts b/packages/commands/src/commands/finetune/logs.ts index 322327be..bd0802ed 100644 --- a/packages/commands/src/commands/finetune/logs.ts +++ b/packages/commands/src/commands/finetune/logs.ts @@ -6,7 +6,7 @@ import { type FineTuneLogEntry, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; /** * Render a single log entry as a single line (mirrors the flatten logic used @@ -187,6 +187,7 @@ export default defineCommand({ emitBare(renderEntry(entry)); } if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/watch.ts b/packages/commands/src/commands/finetune/watch.ts index 1eb589ae..c02b2079 100644 --- a/packages/commands/src/commands/finetune/watch.ts +++ b/packages/commands/src/commands/finetune/watch.ts @@ -6,7 +6,7 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DEFAULT_INTERVAL_SEC = 10; const MIN_INTERVAL_SEC = 1; @@ -135,9 +135,13 @@ export default defineCommand({ } else if (format === "text") { emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`); if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`); + emitRequestId(response.request_id, settings.quiet); } else { // json: a compact, purpose-built status probe. - emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format); + emitResult( + { job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id }, + format, + ); } if (terminal && status !== "SUCCEEDED") { @@ -175,6 +179,7 @@ export default defineCommand({ emitResult(response, format); } else if (status === "SUCCEEDED") { emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`); + emitRequestId(response.request_id, settings.quiet); } if (status !== "SUCCEEDED") { throw new BailianError( diff --git a/packages/commands/src/commands/skill/add.ts b/packages/commands/src/commands/skill/add.ts new file mode 100644 index 00000000..5cb6f97b --- /dev/null +++ b/packages/commands/src/commands/skill/add.ts @@ -0,0 +1,110 @@ +import { + BailianError, + ExitCode, + defineCommand, + detectOutputFormat, + detectInstalledAgents, + fetchSkillsIndex, + getSkillRegistryBaseUrl, + installSkillWithFanout, + parseSkillNames, + readSkillLock, + runWithConcurrency, + writeSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +interface AddOutcome { + name: string; + status: "installed" | "failed"; + publishedAt?: string; + agents?: string[]; + reason?: string; +} + +/** Max number of skills downloading/installing at the same time. */ +const INSTALL_CONCURRENCY = 3; + +export default defineCommand({ + description: "Install skills from the Bailian skill registry into local agents", + auth: "none", + usageArgs: "--name ", + flags: { + name: { + type: "string", + valueHint: "", + description: "Skills to install: all or comma-separated skill names", + required: true, + }, + }, + exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + 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(); + + // 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 => { + const entry = index.skills[name]; + if (!entry) { + return { name, status: "failed", reason: "skill not found in registry" }; + } + try { + const record = await installSkillWithFanout(name, entry, agents); + lock.skills[name] = record.lockEntry; + return { + name, + status: "installed", + publishedAt: entry.publishedAt, + agents: record.linkedAgents, + }; + } catch (err) { + return { + name, + status: "failed", + reason: err instanceof Error ? err.message : String(err), + }; + } + }); + const results = await runWithConcurrency(tasks, INSTALL_CONCURRENCY); + writeSkillLock(lock); + + if (format === "json") { + emitResult( + { + registry: getSkillRegistryBaseUrl(), + agents: agents.map((agent) => agent.id), + skills: results, + }, + format, + ); + } else if (results.length === 0) { + emitBare("Skill registry is empty; no skills to install."); + } else { + const rows = results.map((result) => [ + result.name, + result.status, + result.publishedAt ? result.publishedAt.slice(0, 10) : "-", + result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"), + ]); + for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) { + emitBare(line); + } + } + + const failed = results.filter((result) => result.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", + ); + } + }, +}); diff --git a/packages/commands/src/commands/skill/list.ts b/packages/commands/src/commands/skill/list.ts new file mode 100644 index 00000000..aa6552cc --- /dev/null +++ b/packages/commands/src/commands/skill/list.ts @@ -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, 19).replace("T", " ") : "-", + truncate(row.description), + ]); + for (const line of formatTable(["NAME", "STATUS", "UPDATEDAT", "DESCRIPTION"], table)) { + emitBare(line); + } + }, +}); diff --git a/packages/commands/src/commands/skill/remove.ts b/packages/commands/src/commands/skill/remove.ts new file mode 100644 index 00000000..e51106c2 --- /dev/null +++ b/packages/commands/src/commands/skill/remove.ts @@ -0,0 +1,100 @@ +import { + BailianError, + ExitCode, + defineCommand, + detectOutputFormat, + listSkillDirsOnDisk, + parseSkillNames, + readSkillLock, + removeSkillDir, + unlinkSkillFromAgents, + writeSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +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 ", + flags: { + name: { + type: "string", + valueHint: "", + 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", + ); + } + }, +}); diff --git a/packages/commands/src/commands/skill/update.ts b/packages/commands/src/commands/skill/update.ts new file mode 100644 index 00000000..127e6419 --- /dev/null +++ b/packages/commands/src/commands/skill/update.ts @@ -0,0 +1,140 @@ +import { + BailianError, + ExitCode, + defineCommand, + detectOutputFormat, + detectInstalledAgents, + fetchSkillsIndex, + getSkillRegistryBaseUrl, + installSkillWithFanout, + listSkillDirsOnDisk, + parseSkillNames, + readSkillLock, + runWithConcurrency, + writeSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +interface UpdateOutcome { + name: string; + status: "updated" | "up-to-date" | "skipped" | "failed"; + publishedAt?: string; + reason?: string; +} + +/** Max number of skills downloading/installing at the same time. */ +const UPDATE_CONCURRENCY = 3; + +export default defineCommand({ + description: "Update installed skills to the latest registry versions", + auth: "none", + usageArgs: "[--name ]", + flags: { + name: { + type: "string", + valueHint: "", + description: + "Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills)", + }, + }, + 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: only update skills that are already installed; reject uninstalled ones + for (const name of requested) { + if (!lock.skills[name]) { + results.push({ + name, + status: "failed", + reason: "not installed; run bl skill add --name " + name + " first", + }); + continue; + } + targets.push(name); + } + } + + const agents = detectInstalledAgents(); + const tasks = targets.map((name) => async (): Promise => { + const entry = index.skills[name]; + if (!entry) { + return { name, status: "failed", reason: "skill not found in registry" }; + } + try { + const record = await installSkillWithFanout(name, entry, agents); + lock.skills[name] = record.lockEntry; + return { name, status: "updated", publishedAt: entry.publishedAt }; + } catch (err) { + return { + name, + status: "failed", + reason: err instanceof Error ? err.message : String(err), + }; + } + }); + const updateResults = await runWithConcurrency(tasks, UPDATE_CONCURRENCY); + results.push(...updateResults); + 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((result) => [ + result.name, + result.status, + result.publishedAt ? result.publishedAt.slice(0, 10) : "-", + ]); + for (const line of formatTable(["NAME", "STATUS", "PUBLISHED"], rows)) { + emitBare(line); + } + + // Footnotes for skipped / failed entries + const annotated = results.filter( + (result) => (result.status === "skipped" || result.status === "failed") && result.reason, + ); + if (annotated.length > 0) { + emitBare(""); + for (const result of annotated) { + emitBare(` ${result.name}: ${result.reason}`); + } + } + } + + const failed = results.filter((result) => result.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", + ); + } + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index ab112c37..cba8b321 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -113,3 +113,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"; diff --git a/packages/commands/tests/e2e/skill.e2e.test.ts b/packages/commands/tests/e2e/skill.e2e.test.ts new file mode 100644 index 00000000..d0af753d --- /dev/null +++ b/packages/commands/tests/e2e/skill.e2e.test.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { isBailianE2EEnabled, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { SKILL_ROUTES } from "./topic-routes.ts"; + +/** Canonical always-published skill; also the backbone of advisor wiki sync */ +const WIKI_SKILL = "bailian-docs-llm-wiki"; + +/** Redirect ~/.bailian into a throwaway dir so lock/skill writes never touch the real user config */ +function makeTempConfigDir(): string { + return mkdtempSync(join(tmpdir(), "bl-skill-e2e-")); +} + +describe("e2e: skill", () => { + test("skill add --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--name/); + }); + + test("skill update --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--name/); + }); + + test("skill remove --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "remove", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--name/); + }); + + test("skill list --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "list", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/list|registry/i); + }); +}); + +// Local-only cases: auth "none" + validation happens before any network access, no gating needed +describe("e2e: skill (local, no credentials)", () => { + test("skill add without --name errors as usage error (2)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [ + "skill", + "add", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i); + }); + + test("skill remove without --name errors as usage error (2)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [ + "skill", + "remove", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i); + }); + + test("skill add rejects mixing all with specific names (2)", async () => { + // parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe + const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [ + "skill", + "add", + "--name", + "all,spark-video", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(`${stdout}\n${stderr}`).toMatch(/all/i); + }); + + test("skill remove of a not-installed skill fails with reason (1)", async () => { + const configDir = makeTempConfigDir(); + const { stdout, exitCode } = await runCommandE2e( + SKILL_ROUTES, + ["skill", "remove", "--name", "definitely-not-installed", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(exitCode).toBe(1); + const data = parseStdoutJson<{ + skills?: Array<{ name?: string; status?: string; reason?: string }>; + }>(stdout); + expect(data.skills?.[0]?.status).toBe("failed"); + expect(data.skills?.[0]?.reason).toMatch(/not installed/i); + }); +}); + +describe.skipIf(!isBailianE2EEnabled())("e2e: skill (real registry)", () => { + test("skill list --output json returns registry and status rows", async () => { + const configDir = makeTempConfigDir(); + const { stdout, stderr, exitCode } = await runCommandE2e( + SKILL_ROUTES, + ["skill", "list", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + registry?: string; + skills?: Array<{ name?: string; status?: string }>; + }>(stdout); + expect(data.registry).toMatch(/^https?:\/\//); + expect(Array.isArray(data.skills)).toBe(true); + }, 60_000); + + test("skill add + remove full lifecycle in isolated dirs", async () => { + const configDir = makeTempConfigDir(); + // Empty fake home → no agents detected → fan-out never leaves the sandbox + const fakeHome = makeTempConfigDir(); + const env = { BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome }; + + const added = await runCommandE2e( + SKILL_ROUTES, + ["skill", "add", "--name", WIKI_SKILL, "--output", "json"], + env, + ); + expect(added.exitCode, added.stderr).toBe(0); + const addData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>( + added.stdout, + ); + expect(addData.skills?.[0]?.status).toBe("installed"); + expect(existsSync(join(configDir, "skills", WIKI_SKILL, "SKILL.md"))).toBe(true); + + const removed = await runCommandE2e( + SKILL_ROUTES, + ["skill", "remove", "--name", WIKI_SKILL, "--output", "json"], + env, + ); + expect(removed.exitCode, removed.stderr).toBe(0); + const removeData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>( + removed.stdout, + ); + expect(removeData.skills?.[0]?.status).toBe("removed"); + expect(existsSync(join(configDir, "skills", WIKI_SKILL))).toBe(false); + }, 300_000); +}); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index f010ec9d..510f70ec 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -156,6 +156,13 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = { "token-plan add-member": "tokenPlanAddMember", }; +export const SKILL_ROUTES: E2eRouteExports = { + "skill add": "skillAdd", + "skill update": "skillUpdate", + "skill remove": "skillRemove", + "skill list": "skillList", +}; + export const MANAGED_AGENT_ROUTES: E2eRouteExports = { "managed-agent init": "managedAgentInit", "managed-agent validate": "managedAgentValidate", diff --git a/packages/core/package.json b/packages/core/package.json index 4d1289c1..a198d5b1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,11 +40,13 @@ "check": "vp check" }, "dependencies": { + "tar-stream": "catalog:", "yaml": "^2.8.3", "yauzl": "catalog:" }, "devDependencies": { "@types/node": "catalog:", + "@types/tar-stream": "catalog:", "@types/yauzl": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260328.1", "typescript": "^6.0.2", diff --git a/packages/core/src/advisor/index.ts b/packages/core/src/advisor/index.ts index 78e85ec6..1d78f0b0 100644 --- a/packages/core/src/advisor/index.ts +++ b/packages/core/src/advisor/index.ts @@ -7,6 +7,7 @@ export { recallCandidates } from "./recall.ts"; export { recallSemantic, isSemanticAvailable } from "./recall-semantic.ts"; export type { RecommendOptions } from "./recommend.ts"; export { buildDocLink, rankModels } from "./recommend.ts"; +export { maybeSyncWikiData } from "./sync.ts"; export type { ModelSource } from "./sources/types.ts"; export type { Budget, diff --git a/packages/core/src/advisor/sources/catalog.ts b/packages/core/src/advisor/sources/catalog.ts index 7a90fa5f..723c757f 100644 --- a/packages/core/src/advisor/sources/catalog.ts +++ b/packages/core/src/advisor/sources/catalog.ts @@ -1,6 +1,5 @@ -import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { getConfigDir } from "../../config/paths.ts"; import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts"; import type { ModelSource } from "./types.ts"; @@ -13,12 +12,9 @@ function getCatalogDir(): string { } function getCatalogPath(): string { - return join(getCatalogDir(), MODELS_FILE); -} - -function getMonorepoModelsDir(): string { - const coreDir = dirname(fileURLToPath(import.meta.url)); - return join(coreDir, "../../../../../skills/bailian-docs-llm-wiki/models"); + // Full-package layout keeps the `models/` subdir (raw/, wiki/, models/, …), + // so models.jsonl lives at /models/models.jsonl — not at the skill root. + return join(getCatalogDir(), "models", MODELS_FILE); } function fromJsonlRecord(raw: Record): ModelProfile | null { @@ -62,41 +58,24 @@ function readJsonlModels(filePath: string): ModelProfile[] { return models; } -function installFromMonorepo(): boolean { - const src = getMonorepoModelsDir(); - if (!existsSync(join(src, MODELS_FILE))) return false; - const dest = getCatalogDir(); - try { - mkdirSync(dest, { recursive: true }); - cpSync(src, dest, { recursive: true }); - return true; - } catch { - return false; - } -} - export interface CatalogSourceOptions { onPrepareStart?: () => void; } export class CatalogSource implements ModelSource { readonly name = "catalog"; - private options: CatalogSourceOptions; - constructor(options?: CatalogSourceOptions) { - this.options = options ?? {}; - } + // Options retained for API compatibility. Data is now always provisioned by + // the CLI postinstall hook and refreshed by advisor sync, so the previous + // `onPrepareStart` install callback is obsolete. + constructor(_options?: CatalogSourceOptions) {} available(): boolean { return existsSync(getCatalogPath()); } async load(): Promise { - if (!this.available()) { - this.options.onPrepareStart?.(); - const installed = installFromMonorepo(); - if (!installed) return []; - } + if (!this.available()) return []; return readJsonlModels(getCatalogPath()); } } diff --git a/packages/core/src/advisor/sync.ts b/packages/core/src/advisor/sync.ts new file mode 100644 index 00000000..6e36ce78 --- /dev/null +++ b/packages/core/src/advisor/sync.ts @@ -0,0 +1,157 @@ +/** + * sync.ts — Wiki data sync (layer 2: triggered by recommend) + * + * 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 → 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 content-addressed object per skill (sha256-.tar.br, brotli q6; + * legacy fallback skill.tar.br). + * + * 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, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths.ts"; +import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts"; +import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts"; +import { fetchSkillsIndex } from "../skills/registry.ts"; +import type { SkillIndexEntry, SkillLockEntry } from "../skills/types.ts"; + +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 THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h +/** Tighter than the interactive default: the silent channel must not stall `bl advisor recommend` */ +const INDEX_TIMEOUT_MS = 3000; + +interface SyncState { + lastChecked: number; + /** Content fingerprint of the last synced revision; the change-detection token */ + contentHash: string; +} + +function getCatalogDir(): string { + return join(getConfigDir(), SKILL_DIR_NAME); +} + +/** + * 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)); +} + +function getStatePath(): string { + return join(getConfigDir(), STATE_FILE_NAME); +} + +function readState(): SyncState | null { + try { + return JSON.parse(readFileSync(getStatePath(), "utf-8")) as SyncState; + } catch { + return null; + } +} + +function writeState(state: SyncState): void { + try { + writeFileSync(getStatePath(), JSON.stringify(state)); + } catch { + /* Non-critical: if state write fails, next run will re-check */ + } +} + +/** + * 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/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(lockEntry: SkillLockEntry): void { + try { + upsertSkillLockEntry(WIKI_SKILL_NAME, lockEntry); + } 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 via the shared registry client and extract the wiki skill entry; returns null on any failure */ +async function fetchIndexEntry(): Promise { + try { + const index = await fetchSkillsIndex(INDEX_TIMEOUT_MS); + return index.skills[WIKI_SKILL_NAME] ?? null; + } catch { + return null; + } +} + +/** + * Check and sync Wiki data. Runs silently; never throws. + * @returns Whether data was actually updated (for testing/debugging) + */ +export async function maybeSyncWikiData(): Promise { + const state = readState(); + const now = Date.now(); + + // 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. 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. 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.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(buildSkillLockEntry(entry, [])); + return false; + } + + // 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 { + const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry); + recordWikiInLock(record.lockEntry); + } catch { + // Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries + return false; + } + + // 5. Success: write state + writeState({ lastChecked: now, contentHash: entry.contentHash }); + return true; +} diff --git a/packages/core/src/dataset/api.ts b/packages/core/src/dataset/api.ts index 8bb9d51a..c7cb1d68 100644 --- a/packages/core/src/dataset/api.ts +++ b/packages/core/src/dataset/api.ts @@ -47,7 +47,7 @@ export interface DatasetUploadParams { export async function uploadDataset( client: Client, params: DatasetUploadParams, -): Promise { +): Promise { const { filePath, purpose = "fine-tune", signal } = params; const stat = statSync(filePath); const fileName = basename(filePath); @@ -75,6 +75,7 @@ export async function uploadDataset( size: body.bytes ?? stat.size, purpose: body.purpose ?? purpose, gmt_create: body.created_at ? new Date(body.created_at * 1000).toISOString() : undefined, + request_id: body.request_id, }; } // No id in response → upload reported HTTP 200 but produced no usable record diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e49ee6c4..6db11999 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/skills/agents.ts b/packages/core/src/skills/agents.ts new file mode 100644 index 00000000..135fa0e8 --- /dev/null +++ b/packages/core/src/skills/agents.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/), + * 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; +} diff --git a/packages/core/src/skills/extract.ts b/packages/core/src/skills/extract.ts new file mode 100644 index 00000000..48a8a995 --- /dev/null +++ b/packages/core/src/skills/extract.ts @@ -0,0 +1,99 @@ +/** + * 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, + readdirSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +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 { + 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); +} + +/** + * Recompute the publisher's deterministic content hash over an extracted directory: + * regular files sorted by "/"-separated relative path (code-unit order, same as the + * publisher's byte-order sort for ASCII paths), sha256 accumulating relPath + bytes. + * Symmetric with computeContentHash in FC skills-publish.mjs. + */ +export function computeDirContentHash(dir: string): string { + const relPaths: string[] = []; + const walk = (sub: string): void => { + for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) { + const rel = sub ? `${sub}/${dirent.name}` : dirent.name; + if (dirent.isDirectory()) walk(rel); + else if (dirent.isFile()) relPaths.push(rel); + } + }; + walk(""); + relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + const hash = createHash("sha256"); + for (const rel of relPaths) { + hash.update(rel); + hash.update(readFileSync(join(dir, rel))); + } + return `sha256:${hash.digest("hex")}`; +} + +/** + * 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 }); +} diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts new file mode 100644 index 00000000..58529b22 --- /dev/null +++ b/packages/core/src/skills/index.ts @@ -0,0 +1,45 @@ +// 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, + resolveAssetFileName, +} from "./registry.ts"; +export { + getSkillsDir, + getSkillLockPath, + emptySkillLock, + readSkillLock, + writeSkillLock, + upsertSkillLockEntry, +} from "./lock.ts"; +export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts"; +export { parseSkillNames } from "./names.ts"; +export { validateSkillDir, type SkillMeta } from "./validate.ts"; +export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from "./extract.ts"; +export { + getAgentTargets, + detectInstalledAgents, + linkSkillToAgents, + unlinkSkillFromAgents, + type AgentTarget, + type LinkResult, +} from "./agents.ts"; +export { + installSkill, + installSkillFromBuffer, + installSkillWithFanout, + buildSkillLockEntry, + removeSkillDir, + type InstalledSkill, + type SkillInstallRecord, +} from "./installer.ts"; +export { listSkillDirsOnDisk, computeSkillStatuses } from "./status.ts"; diff --git a/packages/core/src/skills/installer.ts b/packages/core/src/skills/installer.ts new file mode 100644 index 00000000..0bcba40a --- /dev/null +++ b/packages/core/src/skills/installer.ts @@ -0,0 +1,133 @@ +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 { detectInstalledAgents, linkSkillToAgents, type AgentTarget } from "./agents.ts"; +import { atomicSwap, computeDirContentHash, 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, SkillLockEntry } 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, + expectedContentHash?: string, +): Promise { + 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); + // Integrity check before touching canonical: recompute the publisher fingerprint over + // the extracted files; on mismatch the current installation is left untouched + if (expectedContentHash?.startsWith("sha256:")) { + const actualContentHash = computeDirContentHash(tmpDir); + if (actualContentHash !== expectedContentHash) { + throw new BailianError( + `Skill ${name} failed integrity check: index says ${expectedContentHash}, archive is ${actualContentHash}`, + ExitCode.GENERAL, + "Downloaded archive does not match the index fingerprint (registry may be mid-publish); retry later", + ); + } + } + 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 { + 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, entry); + return installSkillFromBuffer(name, buffer, entry.contentHash); +} + +/** 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; +} + +/** + * Build a skill-lock entry from an index entry + effective fan-out link paths. + * Single source of truth for the "installation fact" shape shared by bl skill add/update, + * advisor wiki sync, and any future install channel. + */ +export function buildSkillLockEntry(entry: SkillIndexEntry, links: string[]): SkillLockEntry { + return { + ...(entry.contentHash ? { contentHash: entry.contentHash } : {}), + ...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}), + installedAt: new Date().toISOString(), + sourceType: "oss", + ...(entry.description ? { description: entry.description } : {}), + links, + }; +} + +export interface SkillInstallRecord { + /** Ready-to-persist lock entry (links = effective fan-out paths) */ + lockEntry: SkillLockEntry; + /** Ids of agents that actually received a link/copy (skipped ones excluded) */ + linkedAgents: string[]; +} + +/** + * Full install workflow for one skill: install into canonical, fan out to agents, and build + * the lock entry recording effective links. Callers decide how to persist the lock entry + * (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels). + */ +export async function installSkillWithFanout( + name: string, + entry: SkillIndexEntry, + agents: AgentTarget[] = detectInstalledAgents(), +): Promise { + await installSkill(name, entry); + const links = linkSkillToAgents(name, agents); + const effective = links.filter((link) => link.mode !== "skipped"); + return { + lockEntry: buildSkillLockEntry( + entry, + effective.map((link) => link.path), + ), + linkedAgents: effective.map((link) => link.agent), + }; +} diff --git a/packages/core/src/skills/lock.ts b/packages/core/src/skills/lock.ts new file mode 100644 index 00000000..f87d97c0 --- /dev/null +++ b/packages/core/src/skills/lock.ts @@ -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); +} diff --git a/packages/core/src/skills/names.ts b/packages/core/src/skills/names.ts new file mode 100644 index 00000000..397664c0 --- /dev/null +++ b/packages/core/src/skills/names.ts @@ -0,0 +1,30 @@ +import { UsageError } from "../errors/base.ts"; + +/** + * 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; +} diff --git a/packages/core/src/skills/registry.ts b/packages/core/src/skills/registry.ts new file mode 100644 index 00000000..9bbf2602 --- /dev/null +++ b/packages/core/src/skills/registry.ts @@ -0,0 +1,112 @@ +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import type { SkillIndexEntry, 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"; + +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". + * Silent background channels (advisor sync) may pass a tighter timeout than the interactive default. + */ +export async function fetchSkillsIndex(timeoutMs: number = INDEX_TIMEOUT_MS): Promise { + const url = `${getSkillRegistryBaseUrl()}/index.json`; + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + } 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", + ); + } + return index; +} + +/** + * Strict shape check for entry.object (defense against a hostile/corrupted index — + * anything not matching falls back to the legacy fixed key, never into the URL path). + */ +const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/; + +/** Resolve which file to download for a skill: content-addressed object, else legacy fixed key */ +export function resolveAssetFileName(entry?: SkillIndexEntry): string { + const object = entry?.object; + return object && OBJECT_FILE_RE.test(object) ? object : "skill.tar.br"; +} + +/** Download the tar.br archive for a single skill (one skill = one GET) */ +export async function downloadSkillAsset(name: string, entry?: SkillIndexEntry): Promise { + const url = `${getSkillRegistryBaseUrl()}/${name}/${resolveAssetFileName(entry)}`; + 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()); +} diff --git a/packages/core/src/skills/sanitize.ts b/packages/core/src/skills/sanitize.ts new file mode 100644 index 00000000..b36e5fbd --- /dev/null +++ b/packages/core/src/skills/sanitize.ts @@ -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; +} diff --git a/packages/core/src/skills/status.ts b/packages/core/src/skills/status.ts new file mode 100644 index 00000000..073130ed --- /dev/null +++ b/packages/core/src/skills/status.ts @@ -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(); + 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)); +} diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts new file mode 100644 index 00000000..b6fa9aa0 --- /dev/null +++ b/packages/core/src/skills/types.ts @@ -0,0 +1,75 @@ +/** + * 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`): + * /index.json — skill catalog (SkillsIndex) + * //sha256-.tar.br — content-addressed skill object (tar + brotli); + * entry.object names the exact file, so index.json is the single atomic commit point. + * Legacy fallback: //skill.tar.br (entries without object) + * + * Local layout: + * ~/.bailian/skills// — 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; + /** + * Content-addressed object file name under //, e.g. "sha256-.tar.br". + * Absent on legacy entries — client falls back to the fixed key "skill.tar.br". + */ + object?: string; +} + +export interface SkillsIndex { + updatedAt?: string; + /** key = skill name (i.e. OSS directory name, download path, local install dir name) */ + skills: Record; +} + +/** 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; +} + +/** + * 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; +} diff --git a/packages/core/src/skills/validate.ts b/packages/core/src/skills/validate.ts new file mode 100644 index 00000000..37b0a112 --- /dev/null +++ b/packages/core/src/skills/validate.ts @@ -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; + 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 }; +} diff --git a/packages/core/src/utils/concurrency.ts b/packages/core/src/utils/concurrency.ts new file mode 100644 index 00000000..40a68ed4 --- /dev/null +++ b/packages/core/src/utils/concurrency.ts @@ -0,0 +1,22 @@ +/** + * Run async task factories with a bounded concurrency pool. + * Returns results in the same order as the input tasks array. + */ +export async function runWithConcurrency( + tasks: Array<() => Promise>, + limit: number, +): Promise { + const results: T[] = []; + let nextIndex = 0; + + async function worker(): Promise { + 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; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 4d285951..38943a06 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -3,6 +3,7 @@ export { resolveOutputDir } from "./output-dir.ts"; export { maskToken } from "./token.ts"; export { stripUndefined } from "./object.ts"; export { readTextFromPathOrStdin } from "./fs.ts"; +export { runWithConcurrency } from "./concurrency.ts"; export { parseBooleanValue, parseOptionalBooleanValue, diff --git a/packages/core/tests/skills-agents.test.ts b/packages/core/tests/skills-agents.test.ts new file mode 100644 index 00000000..eee877a1 --- /dev/null +++ b/packages/core/tests/skills-agents.test.ts @@ -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): Promise { + 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); + }); +}); diff --git a/packages/core/tests/skills-installer.test.ts b/packages/core/tests/skills-installer.test.ts new file mode 100644 index 00000000..63dd3d0b --- /dev/null +++ b/packages/core/tests/skills-installer.test.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs"; +import { createHash } from "crypto"; +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): Promise { + 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): Promise { + const pack = tar.pack(); + const chunks: Buffer[] = []; + pack.on("data", (chunk: Buffer) => chunks.push(chunk)); + const done = new Promise((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/); + }); +}); + +/** Same accumulation as publisher computeContentHash: sorted rel path + bytes */ +function expectedHashOf(files: Record): string { + const hash = createHash("sha256"); + for (const rel of Object.keys(files).sort()) { + hash.update(rel); + hash.update(Buffer.from(files[rel])); + } + return `sha256:${hash.digest("hex")}`; +} + +test("installer: matching contentHash passes integrity check", async () => { + await inTempConfigDir(async () => { + const files = { "SKILL.md": VALID_SKILL_MD, "references/usage.md": "# usage\n" }; + const installed = await installSkillFromBuffer( + "demo", + await buildTarBr(files), + expectedHashOf(files), + ); + expect(installed.name).toBe("demo"); + expect(existsSync(join(getSkillsDir(), "demo", "SKILL.md"))).toBe(true); + }); +}); + +test("installer: contentHash mismatch → rejected, previous install preserved", async () => { + await inTempConfigDir(async () => { + await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD })); + const tampered = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "extra.md": "tampered\n" }); + await expect( + installSkillFromBuffer("demo", tampered, expectedHashOf({ "SKILL.md": VALID_SKILL_MD })), + ).rejects.toThrow(/integrity check/); + // Old version untouched, temp dir cleaned up + expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD); + expect(existsSync(join(getSkillsDir(), "demo", "extra.md"))).toBe(false); + expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]); + }); +}); diff --git a/packages/core/tests/skills-lock.test.ts b/packages/core/tests/skills-lock.test.ts new file mode 100644 index 00000000..4818e1cd --- /dev/null +++ b/packages/core/tests/skills-lock.test.ts @@ -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): Promise { + 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"); + }); +}); diff --git a/packages/core/tests/skills-status.test.ts b/packages/core/tests/skills-status.test.ts new file mode 100644 index 00000000..c84cec81 --- /dev/null +++ b/packages/core/tests/skills-status.test.ts @@ -0,0 +1,75 @@ +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): SkillsIndex { + return { + skills: Object.fromEntries( + Object.entries(skills).map(([name, contentHash]) => [ + name, + { contentHash, publishedAt: PUB }, + ]), + ), + }; +} + +function makeLock(skills: Record): 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); +}); diff --git a/packages/core/tests/skills-validate.test.ts b/packages/core/tests/skills-validate.test.ts new file mode 100644 index 00000000..d22bfeed --- /dev/null +++ b/packages/core/tests/skills-validate.test.ts @@ -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"); + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 83c13181..d8cadb9b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -40,7 +40,7 @@ export { } from "./urls.ts"; // Output facilities consumed by commands -export { emitResult, emitBare } from "./output/output.ts"; +export { emitResult, emitBare, emitRequestId } from "./output/output.ts"; export { formatTable } from "./output/table.ts"; export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts"; export { createSpinner, createProgressBar } from "./output/progress.ts"; diff --git a/packages/runtime/src/output/output.ts b/packages/runtime/src/output/output.ts index 04c30373..9b6707f5 100644 --- a/packages/runtime/src/output/output.ts +++ b/packages/runtime/src/output/output.ts @@ -16,3 +16,15 @@ export function emitResult(data: unknown, format: OutputFormat): void { export function emitBare(value: string): void { process.stdout.write(value + "\n"); } + +/** + * Surface a server request id for text-mode output. Written to stderr (the + * diagnostic channel) so it never corrupts the primary stdout result, mirroring + * the request_id line the verbose HTTP logger prints. No-op when the id is + * absent (e.g. dry-run) or in --quiet mode (which owes callers a bare scalar). + * JSON output surfaces request_id inside the payload instead of calling this. + */ +export function emitRequestId(requestId: string | undefined, quiet: boolean): void { + if (!requestId || quiet) return; + process.stderr.write(`request_id: ${requestId}\n`); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7266539..eb698a35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@types/node': specifier: ^24 version: 24.12.2 + '@types/tar-stream': + specifier: ^3.1.4 + version: 3.1.4 '@types/yauzl': specifier: ^3.4.0 version: 3.4.0 @@ -21,6 +24,9 @@ catalogs: chalk: specifier: ^5.6.2 version: 5.6.2 + tar-stream: + specifier: ^3.2.0 + version: 3.2.0 smol-toml: specifier: ^1.4.2 version: 1.7.0 @@ -66,6 +72,9 @@ importers: bailian-cli-runtime: specifier: workspace:* version: link:../runtime + tar-stream: + specifier: 'catalog:' + version: 3.2.0 devDependencies: '@clack/prompts': specifier: ^0.7.0 @@ -143,6 +152,9 @@ importers: packages/core: dependencies: + tar-stream: + specifier: 'catalog:' + version: 3.2.0 yaml: specifier: ^2.8.3 version: 2.8.3 @@ -153,6 +165,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.12.2 + '@types/tar-stream': + specifier: 'catalog:' + version: 3.1.4 '@types/yauzl': specifier: 'catalog:' version: 3.4.0 @@ -860,6 +875,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/yauzl@3.4.0': resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} @@ -1070,6 +1088,51 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + boxen@8.0.1: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} @@ -1107,9 +1170,15 @@ packages: engines: {node: '>=18'} hasBin: true + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -1326,6 +1395,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1345,6 +1417,15 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1814,6 +1895,10 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 25.6.0 + '@types/yauzl@3.4.0': dependencies: '@types/node': 25.6.0 @@ -2057,6 +2142,37 @@ snapshots: assertion-error@2.0.1: {} + b4a@1.8.1: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.1.1 + boxen@8.0.1: dependencies: ansi-align: 3.0.1 @@ -2113,8 +2229,16 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-uri@3.1.2: {} fdir@6.5.0(picomatch@4.0.4): @@ -2334,6 +2458,15 @@ snapshots: std-env@4.1.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2358,6 +2491,30 @@ snapshots: dependencies: ansi-regex: 6.2.2 + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + tinybench@2.9.0: {} tinyexec@1.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 513bddce..611df313 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,10 +4,12 @@ packages: catalog: "@types/node": ^24 + "@types/tar-stream": ^3.1.4 "@types/yauzl": ^3.4.0 ajv: ^8.20.0 boxen: ^8.0.1 chalk: ^5.6.2 + tar-stream: ^3.2.0 smol-toml: ^1.4.2 tsx: ^4.23.0 typescript: ^5 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..a1ffcc78 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,251 @@ +{ + "version": 1, + "skills": { + "ask-matt": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/ask-matt/SKILL.md", + "computedHash": "0f843160e34a24f5bd12cdc7de7d40951e77fbdc05ce8f891b37ca32eac2c44d" + }, + "batch-grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/batch-grill-me/SKILL.md", + "computedHash": "f448831b8f04518b1527408f1d5024384ad5f543bdbaac6fabb7aa053ad60489" + }, + "claude-handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/claude-handoff/SKILL.md", + "computedHash": "40a7f4ec80b9826ad7d0c47b0778c99686d6d9d662112b3e175a816f3aca4c39" + }, + "code-review": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/code-review/SKILL.md", + "computedHash": "31d149a480eaa68c11e32f5ee77f0fd0b98a906834d531d881d502352edd0b8e" + }, + "codebase-design": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/codebase-design/SKILL.md", + "computedHash": "6d9d51d8caa01633fd00cf87089c5618c982321f942ac69f62565dc001c6b22f" + }, + "design-an-interface": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/design-an-interface/SKILL.md", + "computedHash": "aef348a9af675b771635623b5aa7790da4d40adc1ee88b58b6fd116eda2047d8" + }, + "diagnosing-bugs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnosing-bugs/SKILL.md", + "computedHash": "fd6c99466b7ba43be624e6e66ed6d7af2796ded7218f24c83820823977819e22" + }, + "domain-modeling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/domain-modeling/SKILL.md", + "computedHash": "363cb0f53b0b431e7c00086ad1f823500b7e1b70b5616ee969c979f0934e9e6e" + }, + "edit-article": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/personal/edit-article/SKILL.md", + "computedHash": "cb2a13b2acb6c8a0706eb3a973a2681f30fd74c00784c74ceaa98e12eb2b2fc3" + }, + "git-guardrails-claude-code": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md", + "computedHash": "8c29552c621da0121427306afd92f8e02f47f446cdb0b55ceae01de6879fcb3a" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "f361db4e15e6bfd562a9282b1dccda513910a50061f9e838ce017be9c69dde3f" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78" + }, + "grilling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grilling/SKILL.md", + "computedHash": "368d3dd7251247c69f3656d93dc83c8f1577792eacac2111f1e7981db2ece49b" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "ad03e8d4ea3cbbff66420eb7ba3cc375b5cbe1821a2449b53e863256cf5b5cde" + }, + "implement": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/implement/SKILL.md", + "computedHash": "2139cfedf24791adbc839aaab6019cff158af1e28bfead020ec6e0ce01b3e74d" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "66e8a50c83c3c724fcfe0769701b665c56cec220cc4f49cc1aee8bdfc07de94a" + }, + "loop-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/loop-me/SKILL.md", + "computedHash": "e1fcab9b531b338eb540c9d57ee089e2660e4bba1d7623ec566c6f32f77f2d26" + }, + "migrate-to-shoehorn": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", + "computedHash": "6397731ced114f3657aa88b55ed13d1344a56d77ca449c568e3200c21740fa99" + }, + "obsidian-vault": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/personal/obsidian-vault/SKILL.md", + "computedHash": "5c08eda96fd76a71793c0a8cb91bef41d518586654a14af4f2edf8f8fd0f96a7" + }, + "prototype": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/prototype/SKILL.md", + "computedHash": "faba901c53a6ca245174c4ba5db3929d14253232b3050fdbd46539720adf1ab8" + }, + "qa": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/qa/SKILL.md", + "computedHash": "223de2b02aa9ab36cfa4fe93e80e527b56e912e7ae725f9d596f6d2457afab77" + }, + "request-refactor-plan": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/request-refactor-plan/SKILL.md", + "computedHash": "08c06e587d0f979b8731f6a00c67fb8d00e563614daf80bf648d41148e553473" + }, + "research": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/research/SKILL.md", + "computedHash": "bd3e2c6826671d82c86ed0da3dac3370ebcf63b0fe847f91bb444e1fb7dac21b" + }, + "resolving-merge-conflicts": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md", + "computedHash": "28aad6f8b1b7025abc8892fa8890f68fe1533499b28a565f416b159131d22fad" + }, + "scaffold-exercises": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/scaffold-exercises/SKILL.md", + "computedHash": "354c91f6dbc9b058632f30594aacb4edc6d25012596585abb00402af8d7ec5e9" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "74e894a3509e2676d4cdb771c8eace087092430635e845e02a9cc2f757c552a4" + }, + "setup-pre-commit": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/setup-pre-commit/SKILL.md", + "computedHash": "1b140af948d0a6330c4cf52d46ca2e06a0e96fdc34d22ec8888d2b9751b99b62" + }, + "setup-ts-deep-modules": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/setup-ts-deep-modules/SKILL.md", + "computedHash": "bd5cbaa455454c2d6c27a1b734e0d820606da6c482bf3c79ec55fbc3f3e03ef3" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "81eca2a5b53a63f481c0849be7a663a8cd43d5cf53f32b644ec0a2f50cf91aa2" + }, + "teach": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/teach/SKILL.md", + "computedHash": "68999bb1a241384b2f921f3321d779b7d05eb6089077b70d88cee2aee3d75ccc" + }, + "to-questionnaire": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/to-questionnaire/SKILL.md", + "computedHash": "d8938509f3400d9977343e11e876fc6e1982b0b9c133a2f2e9d3d730a67a103e" + }, + "to-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-spec/SKILL.md", + "computedHash": "0f544cd0c099c06f0dd0b7b9ee98b4237218e7e95fd3d3c02e791efbaf74bacb" + }, + "to-tickets": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-tickets/SKILL.md", + "computedHash": "5d79577541b5cf6dade61c69844432ea20ce7b12527cdda1dd4d6fe8d59a135d" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "7c923b6a677cfe689500721f08307e2a4c46797ff169dc55ef6c34a431a0d533" + }, + "ubiquitous-language": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/ubiquitous-language/SKILL.md", + "computedHash": "0395170031b57ea63f2eb22561542894c03e3047430a2f1118a06b411054429b" + }, + "wayfinder": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wayfinder/SKILL.md", + "computedHash": "c9e18cefd77b6b5b0ee35f59ce2fd96359aa2d2f02e3479e12c873c5402d9d43" + }, + "wizard": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/wizard/SKILL.md", + "computedHash": "dab67fb9fdcc70f3a06cf2facecf58f405f478504812103b53d4c5a84765254d" + }, + "writing-beats": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-beats/SKILL.md", + "computedHash": "7581e49a991e45e4e486b128c6391b82bd112c143cea3cc4426c23530dd545af" + }, + "writing-fragments": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-fragments/SKILL.md", + "computedHash": "972ab831c7a39dab971439fa4718b668114d3063d347d8ba29b2a19765b3f911" + }, + "writing-great-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/writing-great-skills/SKILL.md", + "computedHash": "4deb21855fb4deeeb1b8217b041faff003fd0c915d24a22f636851c520bc9c5c" + }, + "writing-shape": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-shape/SKILL.md", + "computedHash": "90899c7b3853a80da0d9ef634844b22871cc3ea6aa1bc9e4299f40484ac05392" + } + } +} diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index b2dddc32..ec2e22d3 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -96,6 +96,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) | @@ -141,6 +145,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) | diff --git a/skills/bailian-cli/reference/skill.md b/skills/bailian-cli/reference/skill.md new file mode 100644 index 00000000..7020f0fb --- /dev/null +++ b/skills/bailian-cli/reference/skill.md @@ -0,0 +1,115 @@ +# `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 ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ----------------------------------------------------- | +| `--name ` | string | yes | Skills to install: all or comma-separated skill names | + +#### Examples + +```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 ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ---------------------------------------------------- | +| `--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 ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | +| `--name ` | string | no | Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills) | + +#### Examples + +```bash +bl skill update +``` + +```bash +bl skill update --name spark-video +``` diff --git a/vite.config.ts b/vite.config.ts index cb7b7ac5..f486f067 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,7 +36,11 @@ export default defineConfig({ hookTimeout: 60_000, }, staged: { - "*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,yaml,yml,md}": "vp check --fix", + // 用函数形式返回命令:不把匹配到的文件名插值进 argv,改为跑一次全量检查。 + // 逐文件传参会让 `vp check` 的 node 进程 argv 携带仓库内的文件名, + // 命中终端安全软件按 argv 子串匹配的进程管控规则时整个进程被 SIGKILL, + // 导致 pre-commit 无法完成。全量检查覆盖面更广,也不依赖文件名。 + "*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,yaml,yml,md}": () => "vp check --fix", }, lint: { options: { typeAware: true, typeCheck: true },