Skip to content

Commit 65c0fe9

Browse files
committed
feat: add skill commend & skill install
1 parent 51ed695 commit 65c0fe9

14 files changed

Lines changed: 256 additions & 93 deletions

File tree

.gitignore

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

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

packages/cli/postinstall.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,11 @@ async function extractTarBr(tarBrBuffer, destDir) {
116116

117117
extract.on("entry", (header, stream, next) => {
118118
if (!isSafeEntryName(header.name)) {
119-
next(new Error(`unsafe tar entry: ${header.name}`));
119+
// Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this
120+
// error; silence the entry stream to avoid its companion error becoming unhandled
121+
stream.on("error", () => {});
122+
stream.resume();
123+
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
120124
return;
121125
}
122126
const filePath = join(destDir, header.name);

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

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import {
66
detectInstalledAgents,
77
fetchSkillsIndex,
88
getSkillRegistryBaseUrl,
9-
installSkill,
10-
linkSkillToAgents,
9+
installSkillWithFanout,
10+
parseSkillNames,
1111
readSkillLock,
12+
runWithConcurrency,
1213
writeSkillLock,
1314
} from "bailian-cli-core";
1415
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
15-
import { parseSkillNames, runWithConcurrency } from "./shared.ts";
1616

1717
interface AddOutcome {
1818
name: string;
@@ -56,22 +56,13 @@ export default defineCommand({
5656
return { name, status: "failed", reason: "skill not found in registry" };
5757
}
5858
try {
59-
await installSkill(name, entry);
60-
const links = linkSkillToAgents(name, agents);
61-
const effective = links.filter((link) => link.mode !== "skipped");
62-
lock.skills[name] = {
63-
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
64-
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
65-
installedAt: new Date().toISOString(),
66-
sourceType: "oss",
67-
...(entry.description ? { description: entry.description } : {}),
68-
links: effective.map((link) => link.path),
69-
};
59+
const record = await installSkillWithFanout(name, entry, agents);
60+
lock.skills[name] = record.lockEntry;
7061
return {
7162
name,
7263
status: "installed",
7364
publishedAt: entry.publishedAt,
74-
agents: effective.map((link) => link.agent),
65+
agents: record.linkedAgents,
7566
};
7667
} catch (err) {
7768
return {

packages/commands/src/commands/skill/remove.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import {
44
defineCommand,
55
detectOutputFormat,
66
listSkillDirsOnDisk,
7+
parseSkillNames,
78
readSkillLock,
89
removeSkillDir,
910
unlinkSkillFromAgents,
1011
writeSkillLock,
1112
} from "bailian-cli-core";
1213
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
13-
import { parseSkillNames } from "./shared.ts";
1414

1515
interface RemoveOutcome {
1616
name: string;

packages/commands/src/commands/skill/update.ts

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import {
66
detectInstalledAgents,
77
fetchSkillsIndex,
88
getSkillRegistryBaseUrl,
9-
installSkill,
10-
linkSkillToAgents,
9+
installSkillWithFanout,
1110
listSkillDirsOnDisk,
11+
parseSkillNames,
1212
readSkillLock,
13+
runWithConcurrency,
1314
writeSkillLock,
1415
} from "bailian-cli-core";
1516
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
16-
import { parseSkillNames, runWithConcurrency } from "./shared.ts";
1717

1818
interface UpdateOutcome {
1919
name: string;
@@ -87,17 +87,8 @@ export default defineCommand({
8787
return { name, status: "failed", reason: "skill not found in registry" };
8888
}
8989
try {
90-
await installSkill(name, entry);
91-
const links = linkSkillToAgents(name, agents);
92-
const effective = links.filter((link) => link.mode !== "skipped");
93-
lock.skills[name] = {
94-
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
95-
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
96-
installedAt: new Date().toISOString(),
97-
sourceType: "oss",
98-
...(entry.description ? { description: entry.description } : {}),
99-
links: effective.map((link) => link.path),
100-
};
90+
const record = await installSkillWithFanout(name, entry, agents);
91+
lock.skills[name] = record.lockEntry;
10192
return { name, status: "updated", publishedAt: entry.publishedAt };
10293
} catch (err) {
10394
return {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { existsSync, mkdtempSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, test } from "vite-plus/test";
5+
import { isBailianE2EEnabled, parseStdoutJson, runCommandE2e } from "./helpers.ts";
6+
import { SKILL_ROUTES } from "./topic-routes.ts";
7+
8+
/** Canonical always-published skill; also the backbone of advisor wiki sync */
9+
const WIKI_SKILL = "bailian-docs-llm-wiki";
10+
11+
/** Redirect ~/.bailian into a throwaway dir so lock/skill writes never touch the real user config */
12+
function makeTempConfigDir(): string {
13+
return mkdtempSync(join(tmpdir(), "bl-skill-e2e-"));
14+
}
15+
16+
describe("e2e: skill", () => {
17+
test("skill add --help exits successfully", async () => {
18+
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]);
19+
expect(exitCode, stderr).toBe(0);
20+
expect(stderr).toMatch(/--name/);
21+
});
22+
23+
test("skill update --help exits successfully", async () => {
24+
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]);
25+
expect(exitCode, stderr).toBe(0);
26+
expect(stderr).toMatch(/--name/);
27+
});
28+
29+
test("skill remove --help exits successfully", async () => {
30+
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "remove", "--help"]);
31+
expect(exitCode, stderr).toBe(0);
32+
expect(stderr).toMatch(/--name/);
33+
});
34+
35+
test("skill list --help exits successfully", async () => {
36+
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "list", "--help"]);
37+
expect(exitCode, stderr).toBe(0);
38+
expect(stderr).toMatch(/list|registry/i);
39+
});
40+
});
41+
42+
// Local-only cases: auth "none" + validation happens before any network access, no gating needed
43+
describe("e2e: skill (local, no credentials)", () => {
44+
test("skill add without --name errors as usage error (2)", async () => {
45+
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
46+
"skill",
47+
"add",
48+
"--quiet",
49+
]);
50+
expect(exitCode).toBe(2);
51+
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
52+
});
53+
54+
test("skill remove without --name errors as usage error (2)", async () => {
55+
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
56+
"skill",
57+
"remove",
58+
"--quiet",
59+
]);
60+
expect(exitCode).toBe(2);
61+
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
62+
});
63+
64+
test("skill add rejects mixing all with specific names (2)", async () => {
65+
// parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe
66+
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
67+
"skill",
68+
"add",
69+
"--name",
70+
"all,spark-video",
71+
"--quiet",
72+
]);
73+
expect(exitCode).toBe(2);
74+
expect(`${stdout}\n${stderr}`).toMatch(/all/i);
75+
});
76+
77+
test("skill remove of a not-installed skill fails with reason (1)", async () => {
78+
const configDir = makeTempConfigDir();
79+
const { stdout, exitCode } = await runCommandE2e(
80+
SKILL_ROUTES,
81+
["skill", "remove", "--name", "definitely-not-installed", "--output", "json"],
82+
{ BAILIAN_CONFIG_DIR: configDir },
83+
);
84+
expect(exitCode).toBe(1);
85+
const data = parseStdoutJson<{
86+
skills?: Array<{ name?: string; status?: string; reason?: string }>;
87+
}>(stdout);
88+
expect(data.skills?.[0]?.status).toBe("failed");
89+
expect(data.skills?.[0]?.reason).toMatch(/not installed/i);
90+
});
91+
});
92+
93+
describe.skipIf(!isBailianE2EEnabled())("e2e: skill (real registry)", () => {
94+
test("skill list --output json returns registry and status rows", async () => {
95+
const configDir = makeTempConfigDir();
96+
const { stdout, stderr, exitCode } = await runCommandE2e(
97+
SKILL_ROUTES,
98+
["skill", "list", "--output", "json"],
99+
{ BAILIAN_CONFIG_DIR: configDir },
100+
);
101+
expect(exitCode, stderr).toBe(0);
102+
const data = parseStdoutJson<{
103+
registry?: string;
104+
skills?: Array<{ name?: string; status?: string }>;
105+
}>(stdout);
106+
expect(data.registry).toMatch(/^https?:\/\//);
107+
expect(Array.isArray(data.skills)).toBe(true);
108+
}, 60_000);
109+
110+
test("skill add + remove full lifecycle in isolated dirs", async () => {
111+
const configDir = makeTempConfigDir();
112+
// Empty fake home → no agents detected → fan-out never leaves the sandbox
113+
const fakeHome = makeTempConfigDir();
114+
const env = { BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome };
115+
116+
const added = await runCommandE2e(
117+
SKILL_ROUTES,
118+
["skill", "add", "--name", WIKI_SKILL, "--output", "json"],
119+
env,
120+
);
121+
expect(added.exitCode, added.stderr).toBe(0);
122+
const addData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>(
123+
added.stdout,
124+
);
125+
expect(addData.skills?.[0]?.status).toBe("installed");
126+
expect(existsSync(join(configDir, "skills", WIKI_SKILL, "SKILL.md"))).toBe(true);
127+
128+
const removed = await runCommandE2e(
129+
SKILL_ROUTES,
130+
["skill", "remove", "--name", WIKI_SKILL, "--output", "json"],
131+
env,
132+
);
133+
expect(removed.exitCode, removed.stderr).toBe(0);
134+
const removeData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>(
135+
removed.stdout,
136+
);
137+
expect(removeData.skills?.[0]?.status).toBe("removed");
138+
expect(existsSync(join(configDir, "skills", WIKI_SKILL))).toBe(false);
139+
}, 300_000);
140+
});

packages/commands/tests/e2e/topic-routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,10 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
143143
"token-plan assign-seats": "tokenPlanAssignSeats",
144144
"token-plan add-member": "tokenPlanAddMember",
145145
};
146+
147+
export const SKILL_ROUTES: E2eRouteExports = {
148+
"skill add": "skillAdd",
149+
"skill update": "skillUpdate",
150+
"skill remove": "skillRemove",
151+
"skill list": "skillList",
152+
};

packages/core/src/advisor/sync.ts

Lines changed: 11 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,18 @@
2323
import { existsSync, readFileSync, writeFileSync } from "node:fs";
2424
import { join } from "node:path";
2525
import { getConfigDir } from "../config/paths.ts";
26-
import { detectInstalledAgents, linkSkillToAgents } from "../skills/agents.ts";
27-
import { installSkill } from "../skills/installer.ts";
26+
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
2827
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
29-
import type { SkillIndexEntry } from "../skills/types.ts";
28+
import { fetchSkillsIndex } from "../skills/registry.ts";
29+
import type { SkillIndexEntry, SkillLockEntry } from "../skills/types.ts";
3030

31-
/** Public-read OSS skill registry root (hardcoded, does not use env). */
32-
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
3331
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
3432
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
3533
const STATE_FILE_NAME = "wiki-sync-state.json";
3634
const MODELS_FILE = "models.jsonl";
37-
const INDEX_KEY = "index.json";
3835

3936
const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h
37+
/** Tighter than the interactive default: the silent channel must not stall `bl advisor recommend` */
4038
const INDEX_TIMEOUT_MS = 3000;
4139

4240
interface SyncState {
@@ -45,11 +43,6 @@ interface SyncState {
4543
contentHash: string;
4644
}
4745

48-
interface SkillsIndex {
49-
updatedAt?: string;
50-
skills: Record<string, SkillIndexEntry>;
51-
}
52-
5346
function getCatalogDir(): string {
5447
return join(getConfigDir(), SKILL_DIR_NAME);
5548
}
@@ -89,16 +82,9 @@ function writeState(state: SyncState): void {
8982
* Includes fan-out links so bl skill remove can reclaim agent symlinks.
9083
* Bookkeeping in the silent channel must be best-effort: failure does not affect sync results.
9184
*/
92-
function recordWikiInLock(entry: SkillIndexEntry, links: string[]): void {
85+
function recordWikiInLock(lockEntry: SkillLockEntry): void {
9386
try {
94-
upsertSkillLockEntry(WIKI_SKILL_NAME, {
95-
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
96-
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
97-
installedAt: new Date().toISOString(),
98-
sourceType: "oss",
99-
...(entry.description ? { description: entry.description } : {}),
100-
links,
101-
});
87+
upsertSkillLockEntry(WIKI_SKILL_NAME, lockEntry);
10288
} catch {
10389
/* Bookkeeping failure does not block sync; next sync or bl skill add will fill it in */
10490
}
@@ -113,15 +99,10 @@ function wikiLockUpToDate(contentHash: string): boolean {
11399
}
114100
}
115101

116-
/** Fetch skills/index.json and extract the wiki skill entry; returns null on any failure */
102+
/** Fetch skills/index.json via the shared registry client and extract the wiki skill entry; returns null on any failure */
117103
async function fetchIndexEntry(): Promise<SkillIndexEntry | null> {
118104
try {
119-
const res = await fetch(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, {
120-
signal: AbortSignal.timeout(INDEX_TIMEOUT_MS),
121-
});
122-
if (!res.ok) return null;
123-
const index = (await res.json()) as SkillsIndex;
124-
if (!index?.skills || typeof index.skills !== "object") return null;
105+
const index = await fetchSkillsIndex(INDEX_TIMEOUT_MS);
125106
return index.skills[WIKI_SKILL_NAME] ?? null;
126107
} catch {
127108
return null;
@@ -156,20 +137,15 @@ export async function maybeSyncWikiData(): Promise<boolean> {
156137
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
157138
writeState({ lastChecked: now, contentHash: entry.contentHash });
158139
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
159-
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(entry, []);
140+
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, []));
160141
return false;
161142
}
162143

163144
// 4. Different content or missing data: delegate to the shared skill install pipeline
164145
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
165146
try {
166-
await installSkill(WIKI_SKILL_NAME, entry);
167-
const agents = detectInstalledAgents();
168-
const linkResults = linkSkillToAgents(WIKI_SKILL_NAME, agents);
169-
const effectiveLinks = linkResults
170-
.filter((link) => link.mode !== "skipped")
171-
.map((link) => link.path);
172-
recordWikiInLock(entry, effectiveLinks);
147+
const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry);
148+
recordWikiInLock(record.lockEntry);
173149
} catch {
174150
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
175151
return false;

packages/core/src/skills/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
upsertSkillLockEntry,
2323
} from "./lock.ts";
2424
export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts";
25+
export { parseSkillNames } from "./names.ts";
2526
export { validateSkillDir, type SkillMeta } from "./validate.ts";
2627
export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from "./extract.ts";
2728
export {
@@ -35,7 +36,10 @@ export {
3536
export {
3637
installSkill,
3738
installSkillFromBuffer,
39+
installSkillWithFanout,
40+
buildSkillLockEntry,
3841
removeSkillDir,
3942
type InstalledSkill,
43+
type SkillInstallRecord,
4044
} from "./installer.ts";
4145
export { listSkillDirsOnDisk, computeSkillStatuses } from "./status.ts";

0 commit comments

Comments
 (0)