Skip to content

Commit 743fc7d

Browse files
Merge pull request #216 from modelstudioai/fix/skill-dry-run
Fix skill commands ignoring --dry-run
2 parents f78c869 + ac8cb1b commit 743fc7d

9 files changed

Lines changed: 488 additions & 21 deletions

File tree

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
writeSkillLock,
1313
} from "bailian-cli-core";
1414
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
15+
import { planFanoutLinks, summarizeAgents } from "./dry-run-plan.ts";
1516

1617
interface AddOutcome {
1718
name: string;
@@ -61,9 +62,48 @@ export default defineCommand({
6162
const remoteNames = Object.keys(index.skills);
6263
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
6364
const names = parsed === "all" ? remoteNames : parsed;
65+
const agents = detectInstalledAgents();
66+
67+
if (ctx.settings.dryRun) {
68+
const skills = names.map((name) => {
69+
const entry = index.skills[name];
70+
if (!entry) {
71+
return {
72+
name,
73+
status: "failed" as const,
74+
reason: "skill not found in registry",
75+
};
76+
}
77+
return {
78+
name,
79+
status: "install" as const,
80+
publishedAt: entry.publishedAt,
81+
links: planFanoutLinks(name, agents),
82+
};
83+
});
84+
85+
emitResult(
86+
{
87+
action: "skill.add",
88+
registry: getSkillRegistryBaseUrl(),
89+
agents: summarizeAgents(agents),
90+
skills,
91+
},
92+
format,
93+
);
94+
95+
const failed = skills.filter((skill) => skill.status === "failed");
96+
if (failed.length > 0) {
97+
throw new BailianError(
98+
`${failed.length}/${skills.length} skill(s) failed to install`,
99+
ExitCode.GENERAL,
100+
"Check the reason for failed skills in the output; network failures can be retried with bl skill add",
101+
);
102+
}
103+
return;
104+
}
64105

65106
const lock = readSkillLock();
66-
const agents = detectInstalledAgents();
67107

68108
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
69109
// Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { existsSync, lstatSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { getSkillsDir, type AgentTarget } from "bailian-cli-core";
4+
5+
/** dry-run 用的 agent 摘要(含目标目录) */
6+
export function summarizeAgents(agents: AgentTarget[]): Array<{ id: string; skillsDir: string }> {
7+
return agents.map((agent) => ({
8+
id: agent.id,
9+
skillsDir: agent.skillsDir,
10+
}));
11+
}
12+
13+
export type PathKind = "absent" | "symlink" | "directory" | "file";
14+
15+
/** 预计 fan-out 路径及当前存在性(只读 lstat,不预测 replace/skip) */
16+
export interface PlannedLink {
17+
agent: string;
18+
path: string;
19+
exists: boolean;
20+
kind: PathKind;
21+
hasSkillMd: boolean;
22+
}
23+
24+
function inspectPath(path: string): Pick<PlannedLink, "exists" | "kind" | "hasSkillMd"> {
25+
try {
26+
const stat = lstatSync(path);
27+
if (stat.isSymbolicLink()) {
28+
return { exists: true, kind: "symlink", hasSkillMd: false };
29+
}
30+
if (stat.isDirectory()) {
31+
return {
32+
exists: true,
33+
kind: "directory",
34+
hasSkillMd: existsSync(join(path, "SKILL.md")),
35+
};
36+
}
37+
return { exists: true, kind: "file", hasSkillMd: false };
38+
} catch {
39+
return { exists: false, kind: "absent", hasSkillMd: false };
40+
}
41+
}
42+
43+
/** 按 agent skillsDir 拼出预计链接路径,并标注是否已存在 */
44+
export function planFanoutLinks(skillName: string, agents: AgentTarget[]): PlannedLink[] {
45+
return agents.map((agent) => {
46+
const path = join(agent.skillsDir, skillName);
47+
return { agent: agent.id, path, ...inspectPath(path) };
48+
});
49+
}
50+
51+
/** Canonical skill 目录路径 */
52+
export function canonicalSkillPath(skillName: string): string {
53+
return join(getSkillsDir(), skillName);
54+
}

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
writeSkillLock,
1111
} from "bailian-cli-core";
1212
import { emitBare, emitResult } from "bailian-cli-runtime";
13+
import { planFanoutLinks, summarizeAgents } from "./dry-run-plan.ts";
1314

1415
/** Prefix used to identify first-party Bailian skills in the registry. */
1516
const BAILIAN_PREFIX = "bailian-";
@@ -58,9 +59,24 @@ export default defineCommand({
5859

5960
// Discover all bailian-* skills from the live registry index
6061
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));
62+
const agents = detectInstalledAgents();
63+
64+
if (ctx.settings.dryRun) {
65+
emitResult(
66+
{
67+
action: "skill.init",
68+
skills: names.map((name) => ({
69+
name,
70+
links: planFanoutLinks(name, agents),
71+
})),
72+
agents: summarizeAgents(agents),
73+
},
74+
format,
75+
);
76+
return;
77+
}
6178

6279
const lock = readSkillLock();
63-
const agents = detectInstalledAgents();
6480

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

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ import {
22
BailianError,
33
ExitCode,
44
defineCommand,
5+
getSkillsDir,
56
listSkillDirsOnDisk,
67
parseSkillNames,
8+
planUnlinkSkillFromAgents,
79
readSkillLock,
810
removeSkillDir,
911
unlinkSkillFromAgents,
1012
writeSkillLock,
1113
} from "bailian-cli-core";
1214
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
15+
import { join } from "node:path";
1316

1417
interface RemoveOutcome {
1518
name: string;
@@ -18,6 +21,14 @@ interface RemoveOutcome {
1821
reason?: string;
1922
}
2023

24+
interface RemovePlanItem {
25+
name: string;
26+
status: "remove" | "failed";
27+
canonical?: string;
28+
links?: string[];
29+
reason?: string;
30+
}
31+
2132
export default defineCommand({
2233
description: {
2334
"en-US": "Remove locally installed skills (registry is untouched)",
@@ -51,6 +62,42 @@ export default defineCommand({
5162
}
5263

5364
const diskDirs = new Set(listSkillDirsOnDisk());
65+
const skillsDir = getSkillsDir();
66+
67+
if (ctx.settings.dryRun) {
68+
const results: RemovePlanItem[] = names.map((name) => {
69+
const locked = lock.skills[name];
70+
if (!locked) {
71+
return {
72+
name,
73+
status: "failed",
74+
reason: diskDirs.has(name)
75+
? "directory not managed by bl skill (untracked); remove manually if needed"
76+
: "not installed",
77+
};
78+
}
79+
return {
80+
name,
81+
status: "remove",
82+
canonical: join(skillsDir, name),
83+
// 与真实 unlink 同一候选集:含 lock 外的历史托管 symlink
84+
links: planUnlinkSkillFromAgents(name, locked.links ?? []),
85+
};
86+
});
87+
88+
emitResult({ action: "skill.remove", skills: results }, format);
89+
90+
const failed = results.filter((result) => result.status === "failed");
91+
if (failed.length > 0) {
92+
throw new BailianError(
93+
`${failed.length}/${results.length} skill(s) failed to remove`,
94+
ExitCode.GENERAL,
95+
"Check the reason for failed skills in the output; use bl skill list to verify local install status",
96+
);
97+
}
98+
return;
99+
}
100+
54101
const results: RemoveOutcome[] = [];
55102
for (const name of names) {
56103
const locked = lock.skills[name];
@@ -83,17 +130,19 @@ export default defineCommand({
83130
if (format === "json") {
84131
emitResult({ skills: results }, format);
85132
} 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 ?? "-"),
133+
const rows = results.map((result) => [
134+
result.name,
135+
result.status,
136+
result.status === "removed"
137+
? `reclaimed ${result.removedLinks} agent link(s)`
138+
: (result.reason ?? "-"),
90139
]);
91140
for (const line of formatTable(["NAME", "STATUS", "DETAIL"], rows)) {
92141
emitBare(line);
93142
}
94143
}
95144

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

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

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
writeSkillLock,
1515
} from "bailian-cli-core";
1616
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
17+
import { planFanoutLinks, summarizeAgents } from "./dry-run-plan.ts";
1718

1819
interface UpdateOutcome {
1920
name: string;
@@ -22,6 +23,14 @@ interface UpdateOutcome {
2223
reason?: string;
2324
}
2425

26+
interface UpdatePlanItem {
27+
name: string;
28+
status: "update" | "up-to-date" | "skipped" | "failed";
29+
publishedAt?: string;
30+
reason?: string;
31+
links?: ReturnType<typeof planFanoutLinks>;
32+
}
33+
2534
/** Max number of skills downloading/installing at the same time. */
2635
const UPDATE_CONCURRENCY = 3;
2736

@@ -61,10 +70,11 @@ export default defineCommand({
6170
const index = await fetchSkillsIndex();
6271
const lock = readSkillLock();
6372
const disk = new Set(listSkillDirsOnDisk());
64-
6573
const agents = detectInstalledAgents();
74+
6675
const results: UpdateOutcome[] = [];
6776
const targets: string[] = [];
77+
6878
if (requested === "all") {
6979
// Default: only process skills already installed in lock; reinstall only if version changed or local dir is missing
7080
for (const [name, locked] of Object.entries(lock.skills)) {
@@ -78,6 +88,11 @@ export default defineCommand({
7888
continue;
7989
}
8090
if (entry.contentHash === locked.contentHash && disk.has(name)) {
91+
if (ctx.settings.dryRun) {
92+
// 真实路径仍会 fan-out 自愈;dry-run 只展示目标路径与存在性,不写盘
93+
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
94+
continue;
95+
}
8196
// Self-healing: content unchanged, but still fill fan-out links for agents
8297
// detected since the last install (and refresh recorded copies); the merged
8398
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
@@ -103,6 +118,67 @@ export default defineCommand({
103118
}
104119
}
105120

121+
if (ctx.settings.dryRun) {
122+
const plan: UpdatePlanItem[] = results.map((result) => {
123+
if (result.status === "up-to-date") {
124+
return {
125+
name: result.name,
126+
status: "up-to-date" as const,
127+
publishedAt: result.publishedAt,
128+
links: planFanoutLinks(result.name, agents),
129+
};
130+
}
131+
if (result.status === "skipped") {
132+
return {
133+
name: result.name,
134+
status: "skipped" as const,
135+
publishedAt: result.publishedAt,
136+
reason: result.reason,
137+
};
138+
}
139+
return {
140+
name: result.name,
141+
status: "failed" as const,
142+
publishedAt: result.publishedAt,
143+
reason: result.reason,
144+
};
145+
});
146+
147+
for (const name of targets) {
148+
const entry = index.skills[name];
149+
if (!entry) {
150+
plan.push({ name, status: "failed", reason: "skill not found in registry" });
151+
continue;
152+
}
153+
plan.push({
154+
name,
155+
status: "update",
156+
publishedAt: entry.publishedAt,
157+
links: planFanoutLinks(name, agents),
158+
});
159+
}
160+
161+
emitResult(
162+
{
163+
action: "skill.update",
164+
registry: getSkillRegistryBaseUrl(),
165+
agents: summarizeAgents(agents),
166+
skills: plan,
167+
},
168+
format,
169+
);
170+
171+
const failed = plan.filter((item) => item.status === "failed");
172+
if (failed.length > 0) {
173+
throw new BailianError(
174+
`${failed.length} skill(s) failed to update`,
175+
ExitCode.GENERAL,
176+
"Check the reason for failed skills in the output; network failures can be retried with bl skill update",
177+
);
178+
}
179+
return;
180+
}
181+
106182
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
107183
const entry = index.skills[name];
108184
if (!entry) {

0 commit comments

Comments
 (0)