Skip to content

Commit 101c126

Browse files
committed
fix(skill): honor --dry-run for skill init/add/update/remove
1 parent 73405f1 commit 101c126

5 files changed

Lines changed: 210 additions & 12 deletions

File tree

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,27 @@ export default defineCommand({
5757
exampleArgs: ["--all", "--name spark-video,bailian-model-recommend"],
5858
async run(ctx) {
5959
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
60+
const agents = detectInstalledAgents();
61+
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
62+
63+
if (ctx.settings.dryRun) {
64+
emitResult(
65+
{
66+
action: "skill.add",
67+
registry: getSkillRegistryBaseUrl(),
68+
agents: agents.map((agent) => agent.id),
69+
skills: parsed,
70+
},
71+
format,
72+
);
73+
return;
74+
}
75+
6076
const index = await fetchSkillsIndex();
6177
const remoteNames = Object.keys(index.skills);
62-
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
6378
const names = parsed === "all" ? remoteNames : parsed;
6479

6580
const lock = readSkillLock();
66-
const agents = detectInstalledAgents();
6781

6882
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
6983
// Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key.

‎packages/commands/src/commands/skill/init.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
defineCommand,
55
detectInstalledAgents,
66
fetchSkillsIndex,
7+
getSkillRegistryBaseUrl,
78
installSkillWithFanout,
89
readSkillLock,
910
runWithConcurrency,
@@ -54,13 +55,27 @@ export default defineCommand({
5455
],
5556
async run(ctx) {
5657
const format = ctx.settings.outputExplicit ? ctx.settings.output : DEFAULT_FORMAT;
58+
const agents = detectInstalledAgents();
59+
60+
if (ctx.settings.dryRun) {
61+
emitResult(
62+
{
63+
action: "skill.init",
64+
registry: getSkillRegistryBaseUrl(),
65+
skills: `${BAILIAN_PREFIX}*`,
66+
agents: agents.map((agent) => agent.id),
67+
},
68+
format,
69+
);
70+
return;
71+
}
72+
5773
const index = await fetchSkillsIndex();
5874

5975
// Discover all bailian-* skills from the live registry index
6076
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));
6177

6278
const lock = readSkillLock();
63-
const agents = detectInstalledAgents();
6479

6580
const tasks = names.map((name) => async (): Promise<InitOutcome> => {
6681
const entry = index.skills[name];

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ export default defineCommand({
5050
return;
5151
}
5252

53+
if (ctx.settings.dryRun) {
54+
emitResult(
55+
{
56+
action: "skill.remove",
57+
skills: names.map((name) => ({
58+
name,
59+
removedLinks: (lock.skills[name]?.links ?? []).length,
60+
})),
61+
},
62+
format,
63+
);
64+
return;
65+
}
66+
5367
const diskDirs = new Set(listSkillDirsOnDisk());
5468
const results: RemoveOutcome[] = [];
5569
for (const name of names) {
@@ -83,17 +97,19 @@ export default defineCommand({
8397
if (format === "json") {
8498
emitResult({ skills: results }, format);
8599
} else {
86-
const rows = results.map((r) => [
87-
r.name,
88-
r.status,
89-
r.status === "removed" ? `reclaimed ${r.removedLinks} agent link(s)` : (r.reason ?? "-"),
100+
const rows = results.map((result) => [
101+
result.name,
102+
result.status,
103+
result.status === "removed"
104+
? `reclaimed ${result.removedLinks} agent link(s)`
105+
: (result.reason ?? "-"),
90106
]);
91107
for (const line of formatTable(["NAME", "STATUS", "DETAIL"], rows)) {
92108
emitBare(line);
93109
}
94110
}
95111

96-
const failed = results.filter((r) => r.status === "failed");
112+
const failed = results.filter((result) => result.status === "failed");
97113
if (failed.length > 0) {
98114
throw new BailianError(
99115
`${failed.length}/${results.length} skill(s) failed to remove`,

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,43 @@ export default defineCommand({
5858
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
5959
const updateAll = ctx.flags.all || !ctx.flags.name;
6060
const requested = updateAll ? "all" : parseSkillNames(ctx.flags.name, false);
61-
const index = await fetchSkillsIndex();
6261
const lock = readSkillLock();
62+
const agents = detectInstalledAgents();
63+
64+
if (ctx.settings.dryRun) {
65+
const failed: UpdateOutcome[] = [];
66+
let skills: string[];
67+
if (requested === "all") {
68+
skills = Object.keys(lock.skills);
69+
} else {
70+
skills = [];
71+
for (const name of requested) {
72+
if (!lock.skills[name]) {
73+
failed.push({
74+
name,
75+
status: "failed",
76+
reason: "not installed; run bl skill add --name " + name + " first",
77+
});
78+
continue;
79+
}
80+
skills.push(name);
81+
}
82+
}
83+
emitResult(
84+
{
85+
action: "skill.update",
86+
registry: getSkillRegistryBaseUrl(),
87+
skills,
88+
...(failed.length > 0 ? { failed } : {}),
89+
},
90+
format,
91+
);
92+
return;
93+
}
94+
95+
const index = await fetchSkillsIndex();
6396
const disk = new Set(listSkillDirsOnDisk());
6497

65-
const agents = detectInstalledAgents();
6698
const results: UpdateOutcome[] = [];
6799
const targets: string[] = [];
68100
if (requested === "all") {

‎packages/commands/tests/e2e/skill.e2e.test.ts‎

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdtempSync } from "node:fs";
1+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { describe, expect, test } from "vite-plus/test";
@@ -13,6 +13,31 @@ function makeTempConfigDir(): string {
1313
return mkdtempSync(join(tmpdir(), "bl-skill-e2e-"));
1414
}
1515

16+
/** 在隔离 configDir 里种一个假 skill + lock,供 remove/update --dry-run 本地断言 */
17+
function seedInstalledSkill(configDir: string, skillName: string, links: string[]): void {
18+
const skillDir = join(configDir, "skills", skillName);
19+
mkdirSync(skillDir, { recursive: true });
20+
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: seeded\n---\n");
21+
writeFileSync(
22+
join(configDir, "skills", "skill-lock.json"),
23+
`${JSON.stringify(
24+
{
25+
version: 1,
26+
skills: {
27+
[skillName]: {
28+
contentHash: "sha256:seeded-for-dry-run",
29+
installedAt: new Date().toISOString(),
30+
sourceType: "oss",
31+
links,
32+
},
33+
},
34+
},
35+
null,
36+
2,
37+
)}\n`,
38+
);
39+
}
40+
1641
describe("e2e: skill", () => {
1742
test("skill add --help exits successfully", async () => {
1843
const { stderr, exitCode } = await runCommandHelp(SKILL_ROUTES, ["skill", "add", "--help"]);
@@ -47,7 +72,7 @@ describe("e2e: skill", () => {
4772
});
4873
});
4974

50-
// Local-only cases: auth "none" + validation happens before any network access, no gating needed
75+
// Local-only cases: auth "none" + dry-run / validation happen before any network access
5176
describe("e2e: skill (local, no credentials)", () => {
5277
test("skill add without --all or --name errors as usage error (2)", async () => {
5378
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
@@ -96,6 +121,102 @@ describe("e2e: skill (local, no credentials)", () => {
96121
expect(data.skills?.[0]?.status).toBe("failed");
97122
expect(data.skills?.[0]?.reason).toMatch(/not installed/i);
98123
});
124+
125+
test("skill remove --dry-run 仅输出计划且不删盘", async () => {
126+
const configDir = makeTempConfigDir();
127+
const fakeHome = makeTempConfigDir();
128+
const linkPath = join(fakeHome, ".claude", "skills", "seeded-skill");
129+
seedInstalledSkill(configDir, "seeded-skill", [linkPath]);
130+
mkdirSync(join(fakeHome, ".claude", "skills"), { recursive: true });
131+
writeFileSync(linkPath, "link-placeholder");
132+
133+
const { stdout, stderr, exitCode } = await runCommandE2e(
134+
SKILL_ROUTES,
135+
["skill", "remove", "--name", "seeded-skill", "--dry-run", "--output", "json"],
136+
{ BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome },
137+
);
138+
expect(exitCode, stderr).toBe(0);
139+
const data = parseStdoutJson<{
140+
action?: string;
141+
skills?: Array<{ name?: string; removedLinks?: number }>;
142+
}>(stdout);
143+
expect(data.action).toBe("skill.remove");
144+
expect(data.skills?.[0]?.name).toBe("seeded-skill");
145+
expect(data.skills?.[0]?.removedLinks).toBe(1);
146+
expect(existsSync(join(configDir, "skills", "seeded-skill", "SKILL.md"))).toBe(true);
147+
expect(existsSync(join(configDir, "skills", "skill-lock.json"))).toBe(true);
148+
expect(existsSync(linkPath)).toBe(true);
149+
});
150+
151+
test("skill add --dry-run 仅输出计划且不写盘、不联网", async () => {
152+
const configDir = makeTempConfigDir();
153+
const fakeHome = makeTempConfigDir();
154+
const { stdout, stderr, exitCode } = await runCommandE2e(
155+
SKILL_ROUTES,
156+
["skill", "add", "--name", WIKI_SKILL, "--dry-run", "--output", "json"],
157+
{ BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome },
158+
);
159+
expect(exitCode, stderr).toBe(0);
160+
const data = parseStdoutJson<{
161+
action?: string;
162+
skills?: string[] | string;
163+
registry?: string;
164+
}>(stdout);
165+
expect(data.action).toBe("skill.add");
166+
expect(data.registry).toMatch(/^https?:\/\//);
167+
expect(data.skills).toEqual([WIKI_SKILL]);
168+
expect(existsSync(join(configDir, "skills", WIKI_SKILL))).toBe(false);
169+
expect(existsSync(join(configDir, "skills", "skill-lock.json"))).toBe(false);
170+
});
171+
172+
test("skill init --dry-run 仅输出筛选意图且不写盘、不联网", async () => {
173+
const configDir = makeTempConfigDir();
174+
const fakeHome = makeTempConfigDir();
175+
const { stdout, stderr, exitCode } = await runCommandE2e(
176+
SKILL_ROUTES,
177+
["skill", "init", "--dry-run", "--output", "json"],
178+
{ BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome },
179+
);
180+
expect(exitCode, stderr).toBe(0);
181+
const data = parseStdoutJson<{
182+
action?: string;
183+
skills?: string;
184+
registry?: string;
185+
}>(stdout);
186+
expect(data.action).toBe("skill.init");
187+
expect(data.registry).toMatch(/^https?:\/\//);
188+
expect(data.skills).toBe("bailian-*");
189+
expect(existsSync(join(configDir, "skills"))).toBe(false);
190+
});
191+
192+
test("skill update --dry-run 仅输出计划且不 fan-out、不写 lock", async () => {
193+
const configDir = makeTempConfigDir();
194+
const fakeHome = makeTempConfigDir();
195+
const linkPath = join(fakeHome, ".claude", "skills", "seeded-skill");
196+
seedInstalledSkill(configDir, "seeded-skill", [linkPath]);
197+
mkdirSync(join(fakeHome, ".claude", "skills"), { recursive: true });
198+
writeFileSync(linkPath, "link-placeholder");
199+
const lockBefore = readFileSync(join(configDir, "skills", "skill-lock.json"), "utf-8");
200+
201+
const { stdout, stderr, exitCode } = await runCommandE2e(
202+
SKILL_ROUTES,
203+
["skill", "update", "--name", "seeded-skill", "--dry-run", "--output", "json"],
204+
{ BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome },
205+
);
206+
expect(exitCode, stderr).toBe(0);
207+
const data = parseStdoutJson<{
208+
action?: string;
209+
skills?: string[];
210+
registry?: string;
211+
}>(stdout);
212+
expect(data.action).toBe("skill.update");
213+
expect(data.registry).toMatch(/^https?:\/\//);
214+
expect(data.skills).toEqual(["seeded-skill"]);
215+
expect(readFileSync(join(configDir, "skills", "skill-lock.json"), "utf-8")).toBe(lockBefore);
216+
expect(existsSync(join(configDir, "skills", "seeded-skill", "SKILL.md"))).toBe(true);
217+
expect(existsSync(linkPath)).toBe(true);
218+
expect(readFileSync(linkPath, "utf-8")).toBe("link-placeholder");
219+
});
99220
});
100221

101222
describe.skipIf(!isBailianE2EEnabled())("e2e: skill (real registry)", () => {

0 commit comments

Comments
 (0)