Skip to content

Commit b4c2b43

Browse files
committed
fix(skill): align dry-run exit codes and path preview
1 parent 101c126 commit b4c2b43

6 files changed

Lines changed: 324 additions & 113 deletions

File tree

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

Lines changed: 33 additions & 7 deletions
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;
@@ -57,26 +58,51 @@ export default defineCommand({
5758
exampleArgs: ["--all", "--name spark-video,bailian-model-recommend"],
5859
async run(ctx) {
5960
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
60-
const agents = detectInstalledAgents();
61+
const index = await fetchSkillsIndex();
62+
const remoteNames = Object.keys(index.skills);
6163
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
64+
const names = parsed === "all" ? remoteNames : parsed;
65+
const agents = detectInstalledAgents();
6266

6367
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+
6485
emitResult(
6586
{
6687
action: "skill.add",
6788
registry: getSkillRegistryBaseUrl(),
68-
agents: agents.map((agent) => agent.id),
69-
skills: parsed,
89+
agents: summarizeAgents(agents),
90+
skills,
7091
},
7192
format,
7293
);
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+
}
73103
return;
74104
}
75105

76-
const index = await fetchSkillsIndex();
77-
const remoteNames = Object.keys(index.skills);
78-
const names = parsed === "all" ? remoteNames : parsed;
79-
80106
const lock = readSkillLock();
81107

82108
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
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: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import {
44
defineCommand,
55
detectInstalledAgents,
66
fetchSkillsIndex,
7-
getSkillRegistryBaseUrl,
87
installSkillWithFanout,
98
readSkillLock,
109
runWithConcurrency,
1110
writeSkillLock,
1211
} from "bailian-cli-core";
1312
import { emitBare, emitResult } from "bailian-cli-runtime";
13+
import { planFanoutLinks, summarizeAgents } from "./dry-run-plan.ts";
1414

1515
/** Prefix used to identify first-party Bailian skills in the registry. */
1616
const BAILIAN_PREFIX = "bailian-";
@@ -55,26 +55,27 @@ export default defineCommand({
5555
],
5656
async run(ctx) {
5757
const format = ctx.settings.outputExplicit ? ctx.settings.output : DEFAULT_FORMAT;
58+
const index = await fetchSkillsIndex();
59+
60+
// Discover all bailian-* skills from the live registry index
61+
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));
5862
const agents = detectInstalledAgents();
5963

6064
if (ctx.settings.dryRun) {
6165
emitResult(
6266
{
6367
action: "skill.init",
64-
registry: getSkillRegistryBaseUrl(),
65-
skills: `${BAILIAN_PREFIX}*`,
66-
agents: agents.map((agent) => agent.id),
68+
skills: names.map((name) => ({
69+
name,
70+
links: planFanoutLinks(name, agents),
71+
})),
72+
agents: summarizeAgents(agents),
6773
},
6874
format,
6975
);
7076
return;
7177
}
7278

73-
const index = await fetchSkillsIndex();
74-
75-
// Discover all bailian-* skills from the live registry index
76-
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));
77-
7879
const lock = readSkillLock();
7980

8081
const tasks = names.map((name) => async (): Promise<InitOutcome> => {

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

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
BailianError,
33
ExitCode,
44
defineCommand,
5+
getSkillsDir,
56
listSkillDirsOnDisk,
67
parseSkillNames,
78
readSkillLock,
@@ -10,6 +11,7 @@ import {
1011
writeSkillLock,
1112
} from "bailian-cli-core";
1213
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
14+
import { join } from "node:path";
1315

1416
interface RemoveOutcome {
1517
name: string;
@@ -18,6 +20,14 @@ interface RemoveOutcome {
1820
reason?: string;
1921
}
2022

23+
interface RemovePlanItem {
24+
name: string;
25+
status: "remove" | "failed";
26+
canonical?: string;
27+
links?: string[];
28+
reason?: string;
29+
}
30+
2131
export default defineCommand({
2232
description: {
2333
"en-US": "Remove locally installed skills (registry is untouched)",
@@ -50,21 +60,42 @@ export default defineCommand({
5060
return;
5161
}
5262

63+
const diskDirs = new Set(listSkillDirsOnDisk());
64+
const skillsDir = getSkillsDir();
65+
5366
if (ctx.settings.dryRun) {
54-
emitResult(
55-
{
56-
action: "skill.remove",
57-
skills: names.map((name) => ({
67+
const results: RemovePlanItem[] = names.map((name) => {
68+
const locked = lock.skills[name];
69+
if (!locked) {
70+
return {
5871
name,
59-
removedLinks: (lock.skills[name]?.links ?? []).length,
60-
})),
61-
},
62-
format,
63-
);
72+
status: "failed",
73+
reason: diskDirs.has(name)
74+
? "directory not managed by bl skill (untracked); remove manually if needed"
75+
: "not installed",
76+
};
77+
}
78+
return {
79+
name,
80+
status: "remove",
81+
canonical: join(skillsDir, name),
82+
links: locked.links ?? [],
83+
};
84+
});
85+
86+
emitResult({ action: "skill.remove", skills: results }, format);
87+
88+
const failed = results.filter((result) => result.status === "failed");
89+
if (failed.length > 0) {
90+
throw new BailianError(
91+
`${failed.length}/${results.length} skill(s) failed to remove`,
92+
ExitCode.GENERAL,
93+
"Check the reason for failed skills in the output; use bl skill list to verify local install status",
94+
);
95+
}
6496
return;
6597
}
6698

67-
const diskDirs = new Set(listSkillDirsOnDisk());
6899
const results: RemoveOutcome[] = [];
69100
for (const name of names) {
70101
const locked = lock.skills[name];

0 commit comments

Comments
 (0)