Skip to content

Commit 43a08d4

Browse files
committed
fix(skill): align remove dry-run links with real unlink scan
1 parent b4c2b43 commit 43a08d4

5 files changed

Lines changed: 72 additions & 18 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getSkillsDir,
66
listSkillDirsOnDisk,
77
parseSkillNames,
8+
planUnlinkSkillFromAgents,
89
readSkillLock,
910
removeSkillDir,
1011
unlinkSkillFromAgents,
@@ -79,7 +80,8 @@ export default defineCommand({
7980
name,
8081
status: "remove",
8182
canonical: join(skillsDir, name),
82-
links: locked.links ?? [],
83+
// 与真实 unlink 同一候选集:含 lock 外的历史托管 symlink
84+
links: planUnlinkSkillFromAgents(name, locked.links ?? []),
8385
};
8486
});
8587

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
1+
import { existsSync, mkdirSync, mkdtempSync, symlinkSync, 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";
@@ -142,10 +142,14 @@ describe("e2e: skill (local, no credentials)", () => {
142142
test("skill remove --dry-run 仅输出计划且不删盘", async () => {
143143
const configDir = makeTempConfigDir();
144144
const fakeHome = makeTempConfigDir();
145-
const linkPath = join(fakeHome, ".claude", "skills", "seeded-skill");
146-
seedInstalledSkill(configDir, "seeded-skill", [linkPath]);
145+
const recordedLink = join(fakeHome, ".claude", "skills", "seeded-skill");
146+
const historicalLink = join(fakeHome, ".agents", "skills", "seeded-skill");
147+
seedInstalledSkill(configDir, "seeded-skill", [recordedLink]);
147148
mkdirSync(join(fakeHome, ".claude", "skills"), { recursive: true });
148-
writeFileSync(linkPath, "link-placeholder");
149+
mkdirSync(join(fakeHome, ".agents", "skills"), { recursive: true });
150+
// recorded:普通占位文件;historical:指向 canonical 的托管 symlink(不在 lock 里)
151+
writeFileSync(recordedLink, "link-placeholder");
152+
symlinkSync(join(configDir, "skills", "seeded-skill"), historicalLink, "dir");
149153

150154
const { stdout, stderr, exitCode } = await runCommandE2e(
151155
SKILL_ROUTES,
@@ -166,10 +170,11 @@ describe("e2e: skill (local, no credentials)", () => {
166170
expect(data.skills?.[0]?.status).toBe("remove");
167171
expect(data.skills?.[0]?.name).toBe("seeded-skill");
168172
expect(data.skills?.[0]?.canonical).toBe(join(configDir, "skills", "seeded-skill"));
169-
expect(data.skills?.[0]?.links).toEqual([linkPath]);
173+
expect(data.skills?.[0]?.links?.sort()).toEqual([recordedLink, historicalLink].sort());
170174
expect(existsSync(join(configDir, "skills", "seeded-skill", "SKILL.md"))).toBe(true);
171175
expect(existsSync(join(configDir, "skills", "skill-lock.json"))).toBe(true);
172-
expect(existsSync(linkPath)).toBe(true);
176+
expect(existsSync(recordedLink)).toBe(true);
177+
expect(existsSync(historicalLink)).toBe(true);
173178
});
174179

175180
test("skill update --dry-run 未安装时退出码与真实一致 (1)", async () => {

‎packages/core/src/skills/agents.ts‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -423,13 +423,13 @@ export function fanOutSkillToAgents(
423423
}
424424

425425
/**
426-
* Reclaim fan-out artifacts for a skill across all agent dirs.
427-
* Symlinks pointing to canonical are removed (including historical links not in lock,
428-
* via defensive scan of the full registry); real directories are only removed if recorded
429-
* in lock (copy-fallback artifacts). A single failure does not block the rest.
426+
* Collect paths that unlinkSkillFromAgents would reclaim (read-only).
427+
* Same candidate set and eligibility rules as the mutating unlink: managed
428+
* symlinks anywhere under the agent registry (including historical links not
429+
* in lock), plus recorded copy-fallback directories.
430430
*/
431-
export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] {
432-
const removed: string[] = [];
431+
export function planUnlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] {
432+
const planned: string[] = [];
433433
const candidates = new Set(recordedLinks);
434434
for (const agent of getAgentTargets()) candidates.add(join(agent.skillsDir, name));
435435
for (const linkPath of candidates) {
@@ -441,14 +441,34 @@ export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []
441441
continue;
442442
}
443443
if (stat.isSymbolicLink()) {
444-
if (isManagedLink(linkPath)) {
445-
rmSync(linkPath);
446-
removed.push(linkPath);
447-
}
444+
if (isManagedLink(linkPath)) planned.push(linkPath);
448445
} else if (recordedLinks.some((recorded) => samePath(recorded, linkPath))) {
446+
planned.push(linkPath);
447+
}
448+
} catch {
449+
/* single failure does not block remaining scan */
450+
}
451+
}
452+
return planned;
453+
}
454+
455+
/**
456+
* Reclaim fan-out artifacts for a skill across all agent dirs.
457+
* Symlinks pointing to canonical are removed (including historical links not in lock,
458+
* via defensive scan of the full registry); real directories are only removed if recorded
459+
* in lock (copy-fallback artifacts). A single failure does not block the rest.
460+
*/
461+
export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] {
462+
const removed: string[] = [];
463+
for (const linkPath of planUnlinkSkillFromAgents(name, recordedLinks)) {
464+
try {
465+
const stat = lstatSync(linkPath);
466+
if (stat.isSymbolicLink()) {
467+
rmSync(linkPath);
468+
} else {
449469
rmSync(linkPath, { recursive: true, force: true });
450-
removed.push(linkPath);
451470
}
471+
removed.push(linkPath);
452472
} catch {
453473
/* single failure does not block remaining cleanup */
454474
}

‎packages/core/src/skills/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
detectInstalledAgents,
3131
linkSkillToAgents,
3232
fanOutSkillToAgents,
33+
planUnlinkSkillFromAgents,
3334
unlinkSkillFromAgents,
3435
type AgentTarget,
3536
type LinkResult,

‎packages/core/tests/skills-agents.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
fanOutSkillToAgents,
1717
getAgentTargets,
1818
linkSkillToAgents,
19+
planUnlinkSkillFromAgents,
1920
unlinkSkillFromAgents,
2021
} from "../src/skills/agents.ts";
2122
import { getSkillsDir } from "../src/skills/lock.ts";
@@ -250,6 +251,31 @@ test("agents: unlink reclaims managed links, leaves foreign content untouched",
250251
});
251252
});
252253

254+
test("agents: planUnlink includes historical managed symlinks missing from lock", async () => {
255+
await inFakeHome(async (home) => {
256+
mkdirSync(join(home, ".claude"), { recursive: true });
257+
mkdirSync(join(home, ".agents"), { recursive: true });
258+
const canonical = seedCanonicalSkill("demo");
259+
const allLinks = linkSkillToAgents("demo")
260+
.filter((link) => link.mode === "symlink")
261+
.map((link) => link.path);
262+
expect(allLinks.length).toBeGreaterThanOrEqual(2);
263+
264+
// Lock only recorded one path; the rest are historical managed symlinks
265+
const recordedOnly = [allLinks[0]!];
266+
const planned = planUnlinkSkillFromAgents("demo", recordedOnly);
267+
expect(planned.sort()).toEqual([...allLinks].sort());
268+
269+
// plan 与真实 unlink 范围一致
270+
const removed = unlinkSkillFromAgents("demo", recordedOnly);
271+
expect(removed.sort()).toEqual(planned.sort());
272+
expect(existsSync(canonical)).toBe(true);
273+
for (const linkPath of allLinks) {
274+
expect(existsSync(linkPath)).toBe(false);
275+
}
276+
});
277+
});
278+
253279
test("agents: official config-dir env vars relocate detection and fan-out", async () => {
254280
await inFakeHome(async (home) => {
255281
const customClaude = join(home, "relocated-claude");

0 commit comments

Comments
 (0)