Merge pull request #133 from modelstudioai/feat/bailian-wiki-doc-sync

feat: add skill commend & wiki sync
This commit is contained in:
gujieye
2026-08-03 20:07:35 +08:00
committed by GitHub
64 changed files with 3025 additions and 71 deletions
+3
View File
@@ -46,3 +46,6 @@ packages/cli/scene/**/outputs/
# Environment variables (sensitive data)
.env
# Local scratch / plan drafts (never commit)
.scratch/
+6 -3
View File
@@ -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",
+213
View File
@@ -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/<entry.object> (sha256-<hex>.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);
});
+8
View File
@@ -89,6 +89,10 @@ import {
pluginLink,
pluginList,
pluginRemove,
skillAdd,
skillUpdate,
skillRemove,
skillList,
managedAgentInit,
managedAgentValidate,
managedAgentPlan,
@@ -203,6 +207,10 @@ export const commands: Record<string, AnyCommand> = {
"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,
@@ -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";
@@ -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);
}
@@ -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);
},
});
@@ -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: "<n>", 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);
},
});
@@ -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);
}
},
});
@@ -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 ?? "<id>"}`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -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);
}
+3 -2
View File
@@ -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);
},
});
@@ -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: "<n>", 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);
},
});
@@ -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: "<n>", 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);
},
});
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
},
});
@@ -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<F extends FlagsDef>(
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);
}
@@ -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);
}
@@ -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 <display-name>`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -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);
},
});
@@ -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: "<n>", 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);
},
});
@@ -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);
}
@@ -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(
+110
View File
@@ -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 <all|name,...>",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
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<AddOutcome> => {
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",
);
}
},
});
@@ -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);
}
},
});
@@ -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 <all|name,...>",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
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",
);
}
},
});
@@ -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 <all|name,...>]",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
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<UpdateOutcome> => {
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",
);
}
},
});
+4
View File
@@ -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";
@@ -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);
});
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -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,
+10 -31
View File
@@ -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 <skill>/models/models.jsonl — not at the skill root.
return join(getCatalogDir(), "models", MODELS_FILE);
}
function fromJsonlRecord(raw: Record<string, unknown>): 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<ModelProfile[]> {
if (!this.available()) {
this.options.onPrepareStart?.();
const installed = installFromMonorepo();
if (!installed) return [];
}
if (!this.available()) return [];
return readJsonlModels(getCatalogPath());
}
}
+157
View File
@@ -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-<hex>.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<SkillIndexEntry | null> {
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<boolean> {
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;
}
+2 -1
View File
@@ -47,7 +47,7 @@ export interface DatasetUploadParams {
export async function uploadDataset(
client: Client,
params: DatasetUploadParams,
): Promise<DatasetFile> {
): Promise<DatasetFile & { request_id?: string }> {
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
+1
View File
@@ -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";
+178
View File
@@ -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/<name>),
* 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;
}
+99
View File
@@ -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<void> {
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 });
}
+45
View File
@@ -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";
+133
View File
@@ -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<InstalledSkill> {
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<InstalledSkill> {
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<SkillInstallRecord> {
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),
};
}
+59
View File
@@ -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);
}
+30
View File
@@ -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;
}
+112
View File
@@ -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<SkillsIndex> {
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<Buffer> {
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());
}
+21
View File
@@ -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;
}
+86
View File
@@ -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<string>();
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));
}
+75
View File
@@ -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`):
* <registry>/index.json — skill catalog (SkillsIndex)
* <registry>/<name>/sha256-<hex>.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: <registry>/<name>/skill.tar.br (entries without object)
*
* Local layout:
* ~/.bailian/skills/<name>/ — 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 <registry>/<name>/, e.g. "sha256-<hex>.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<string, SkillIndexEntry>;
}
/** 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<string, SkillLockEntry>;
}
/**
* 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;
}
+66
View File
@@ -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<string, unknown>;
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 };
}
+22
View File
@@ -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<T>(
tasks: Array<() => Promise<T>>,
limit: number,
): Promise<T[]> {
const results: T[] = [];
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < tasks.length) {
const currentIndex = nextIndex++;
results[currentIndex] = await tasks[currentIndex]();
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
await Promise.all(workers);
return results;
}
+1
View File
@@ -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,
+129
View File
@@ -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<void>): Promise<void> {
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);
});
});
@@ -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<void>): Promise<void> {
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<string, string>): Promise<Buffer> {
const pack = tar.pack();
const chunks: Buffer[] = [];
pack.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<void>((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, string>): 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([]);
});
});
+103
View File
@@ -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<void>): Promise<void> {
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");
});
});
+75
View File
@@ -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<string, string>): SkillsIndex {
return {
skills: Object.fromEntries(
Object.entries(skills).map(([name, contentHash]) => [
name,
{ contentHash, publishedAt: PUB },
]),
),
};
}
function makeLock(skills: Record<string, string>): 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);
});
@@ -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");
});
});
+1 -1
View File
@@ -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";
+12
View File
@@ -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`);
}
+157
View File
@@ -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: {}
+2
View File
@@ -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
+251
View File
@@ -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"
}
}
}
+5
View File
@@ -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) |
+115
View File
@@ -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 <all\|name,...>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ----------------------------------------------------- |
| `--name <all\|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 <all\|name,...>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ---------------------------------------------------- |
| `--name <all\|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 <all\|name,...>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- |
| `--name <all\|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
```
+5 -1
View File
@@ -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 },