@lythos/skill-deck 0.7.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * refresh.test.ts — unit tests for refresh.ts helpers
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/refresh.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach, spyOn } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import { execSync } from 'node:child_process'
13
+ import * as childProcess from 'node:child_process'
14
+
15
+ import { findGitRoot } from './refresh.ts'
16
+
17
+ let cleanup: string[] = []
18
+ let execSpy: ReturnType<typeof spyOn> | null = null
19
+
20
+ afterEach(() => {
21
+ if (execSpy) {
22
+ execSpy.mockRestore()
23
+ execSpy = null
24
+ }
25
+ for (const dir of cleanup) {
26
+ rmSync(dir, { recursive: true, force: true })
27
+ }
28
+ cleanup = []
29
+ })
30
+
31
+ function makeTmp(): string {
32
+ const dir = mkdtempSync(join(tmpdir(), 'deck-refresh-'))
33
+ cleanup.push(dir)
34
+ return dir
35
+ }
36
+
37
+ describe('findGitRoot', () => {
38
+ it('returns the directory itself when .git is directly present', () => {
39
+ const dir = makeTmp()
40
+ execSync('git init', { cwd: dir, stdio: 'ignore' })
41
+ const root = findGitRoot(dir, dir)
42
+ expect(root).not.toBeNull()
43
+ expect(realpathSync(root!)).toBe(realpathSync(dir))
44
+ })
45
+
46
+ it('finds git root in parent directory for monorepo layout', () => {
47
+ const repoDir = makeTmp()
48
+ const skillDir = join(repoDir, 'skills', 'my-skill')
49
+ mkdirSync(skillDir, { recursive: true })
50
+ execSync('git init', { cwd: repoDir, stdio: 'ignore' })
51
+ const root = findGitRoot(skillDir, repoDir)
52
+ expect(root).not.toBeNull()
53
+ expect(realpathSync(root!)).toBe(realpathSync(repoDir))
54
+ })
55
+
56
+ it('returns null for a non-git directory', () => {
57
+ const dir = makeTmp()
58
+ expect(findGitRoot(dir, dir)).toBeNull()
59
+ })
60
+ })
61
+
62
+ function initGitRepo(dir: string) {
63
+ execSync('git init', { cwd: dir, stdio: 'ignore' })
64
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'ignore' })
65
+ execSync('git config user.name "Test"', { cwd: dir, stdio: 'ignore' })
66
+ execSync('git commit --allow-empty -m "init"', { cwd: dir, stdio: 'ignore' })
67
+ }
68
+
69
+ function placeSkill(coldPool: string, relPath: string): string {
70
+ const skillDir = join(coldPool, relPath)
71
+ mkdirSync(skillDir, { recursive: true })
72
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
73
+ return skillDir
74
+ }
75
+
76
+ function mockGitPull(status: 'up-to-date' | 'updated') {
77
+ const originalExecSync = childProcess.execSync
78
+ execSpy = spyOn(childProcess, 'execSync').mockImplementation(((cmd: string, options?: any) => {
79
+ if (cmd === 'git pull') {
80
+ return status === 'up-to-date'
81
+ ? 'Already up to date.\n'
82
+ : 'Updating abc123..def456\nFast-forward\n README.md | 1 +\n'
83
+ }
84
+ return originalExecSync(cmd, options)
85
+ }) as any)
86
+ }
87
+
88
+ describe('refreshDeck', () => {
89
+ it('C12: refresh all skills reports status for each cold pool repo', async () => {
90
+ const projectDir = makeTmp()
91
+ const coldPoolRel = 'cold-pool'
92
+ const coldPool = join(projectDir, coldPoolRel)
93
+
94
+ const skillADir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
95
+ const skillBDir = placeSkill(coldPool, 'github.com/owner/repo/skill-b')
96
+ initGitRepo(skillADir)
97
+ initGitRepo(skillBDir)
98
+
99
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n\n[tool.skills.skill-b]\npath = "github.com/owner/repo/skill-b"\n`
100
+ const deckPath = join(projectDir, 'skill-deck.toml')
101
+ writeFileSync(deckPath, deckContent)
102
+
103
+ mockGitPull('up-to-date')
104
+
105
+ const logs: string[] = []
106
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
107
+ logs.push(String(msg))
108
+ })
109
+
110
+ const { refreshDeck } = await import('./refresh.ts')
111
+ refreshDeck(deckPath, projectDir)
112
+
113
+ logSpy.mockRestore()
114
+
115
+ expect(logs.some(l => l.includes('skill-a'))).toBe(true)
116
+ expect(logs.some(l => l.includes('skill-b'))).toBe(true)
117
+ expect(logs.some(l => l.includes('Up-to-date: 2'))).toBe(true)
118
+ })
119
+
120
+ it('C13: refresh single skill by alias only processes the target', async () => {
121
+ const projectDir = makeTmp()
122
+ const coldPoolRel = 'cold-pool'
123
+ const coldPool = join(projectDir, coldPoolRel)
124
+
125
+ const skillADir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
126
+ const skillBDir = placeSkill(coldPool, 'github.com/owner/repo/skill-b')
127
+ initGitRepo(skillADir)
128
+ initGitRepo(skillBDir)
129
+
130
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n\n[tool.skills.skill-b]\npath = "github.com/owner/repo/skill-b"\n`
131
+ const deckPath = join(projectDir, 'skill-deck.toml')
132
+ writeFileSync(deckPath, deckContent)
133
+
134
+ mockGitPull('up-to-date')
135
+
136
+ const logs: string[] = []
137
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
138
+ logs.push(String(msg))
139
+ })
140
+
141
+ const { refreshDeck } = await import('./refresh.ts')
142
+ refreshDeck(deckPath, projectDir, 'skill-a')
143
+
144
+ logSpy.mockRestore()
145
+
146
+ expect(logs.some(l => l.includes('skill-a'))).toBe(true)
147
+ expect(logs.some(l => l.includes('skill-b'))).toBe(false)
148
+ expect(logs.some(l => l.includes('single skill'))).toBe(true)
149
+ })
150
+
151
+ it('C14: refresh with updated skills triggers linkDeck', async () => {
152
+ const projectDir = makeTmp()
153
+ const coldPoolRel = 'cold-pool'
154
+ const coldPool = join(projectDir, coldPoolRel)
155
+
156
+ const skillADir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
157
+ initGitRepo(skillADir)
158
+
159
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n`
160
+ const deckPath = join(projectDir, 'skill-deck.toml')
161
+ writeFileSync(deckPath, deckContent)
162
+
163
+ mockGitPull('updated')
164
+
165
+ const logs: string[] = []
166
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
167
+ logs.push(String(msg))
168
+ })
169
+
170
+ const { refreshDeck } = await import('./refresh.ts')
171
+ refreshDeck(deckPath, projectDir)
172
+
173
+ logSpy.mockRestore()
174
+
175
+ expect(logs.some(l => l.includes('Running deck link'))).toBe(true)
176
+ expect(logs.some(l => l.includes('Updated: 1'))).toBe(true)
177
+ })
178
+
179
+ it('C15: refresh skips localhost skills', async () => {
180
+ const projectDir = makeTmp()
181
+ const coldPoolRel = 'cold-pool'
182
+ const coldPool = join(projectDir, coldPoolRel)
183
+
184
+ const skillDir = placeSkill(coldPool, 'localhost/my-skill')
185
+ initGitRepo(skillDir)
186
+
187
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.local]\npath = "localhost/my-skill"\n`
188
+ const deckPath = join(projectDir, 'skill-deck.toml')
189
+ writeFileSync(deckPath, deckContent)
190
+
191
+ mockGitPull('up-to-date')
192
+
193
+ const logs: string[] = []
194
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
195
+ logs.push(String(msg))
196
+ })
197
+
198
+ const { refreshDeck } = await import('./refresh.ts')
199
+ refreshDeck(deckPath, projectDir)
200
+
201
+ logSpy.mockRestore()
202
+
203
+ expect(logs.some(l => l.includes('local'))).toBe(true)
204
+ expect(logs.some(l => l.includes('Skipped: 1'))).toBe(true)
205
+ })
206
+
207
+ it('C16: refresh reports not-git for non-git directories', async () => {
208
+ const projectDir = makeTmp()
209
+ const coldPoolRel = 'cold-pool'
210
+ const coldPool = join(projectDir, coldPoolRel)
211
+
212
+ placeSkill(coldPool, 'github.com/owner/repo/skill-a')
213
+ // NO git init
214
+
215
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n`
216
+ const deckPath = join(projectDir, 'skill-deck.toml')
217
+ writeFileSync(deckPath, deckContent)
218
+
219
+ const logs: string[] = []
220
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
221
+ logs.push(String(msg))
222
+ })
223
+
224
+ const { refreshDeck } = await import('./refresh.ts')
225
+ refreshDeck(deckPath, projectDir)
226
+
227
+ logSpy.mockRestore()
228
+
229
+ expect(logs.some(l => l.includes('not a git repository'))).toBe(true)
230
+ })
231
+
232
+ it('C17: refresh target not found exits with error', async () => {
233
+ const projectDir = makeTmp()
234
+ const coldPoolRel = 'cold-pool'
235
+ const coldPool = join(projectDir, coldPoolRel)
236
+ mkdirSync(coldPool, { recursive: true })
237
+
238
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n`
239
+ const deckPath = join(projectDir, 'skill-deck.toml')
240
+ writeFileSync(deckPath, deckContent)
241
+
242
+ const errors: string[] = []
243
+ const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
244
+ errors.push(String(msg))
245
+ })
246
+
247
+ const originalExit = process.exit
248
+ let exitCode: number | undefined
249
+ process.exit = ((code?: number) => {
250
+ exitCode = code ?? 0
251
+ throw new Error(`EXIT:${code}`)
252
+ }) as typeof process.exit
253
+
254
+ try {
255
+ const { refreshDeck } = await import('./refresh.ts')
256
+ refreshDeck(deckPath, projectDir, 'nonexistent')
257
+ expect(false).toBe(true)
258
+ } catch (err: any) {
259
+ expect(exitCode).toBe(1)
260
+ expect(errors.some(e => e.includes('not found'))).toBe(true)
261
+ } finally {
262
+ process.exit = originalExit
263
+ errorSpy.mockRestore()
264
+ }
265
+ })
266
+ })
package/src/refresh.ts ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-refresh.ts — Refresh declared skills from their upstream sources
4
+ *
5
+ * Reads skill-deck.toml → traverses declared skills → git pull.
6
+ * Supports single-skill (by FQ path or alias) or all skills.
7
+ * Never modifies deck.toml.
8
+ */
9
+
10
+ import { parse as parseToml } from "@iarna/toml";
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
+ import { execSync } from "node:child_process";
13
+ import { resolve, dirname, join, relative } from "node:path";
14
+ import { findDeckToml, expandHome, findSource, linkDeck } from "./link.js";
15
+ import { parseDeck } from "./parse-deck.js";
16
+
17
+ interface RefreshResult {
18
+ name: string;
19
+ path: string;
20
+ status: "updated" | "up-to-date" | "skipped" | "failed" | "not-git";
21
+ message?: string;
22
+ }
23
+
24
+ export function findGitRoot(dir: string, coldPool: string): string | null {
25
+ // Standalone skill: .git directly in skill dir
26
+ if (existsSync(join(dir, ".git"))) {
27
+ return dir;
28
+ }
29
+
30
+ try {
31
+ const out = execSync("git rev-parse --show-toplevel", {
32
+ cwd: dir,
33
+ encoding: "utf-8",
34
+ stdio: ["pipe", "pipe", "pipe"],
35
+ }).trim();
36
+
37
+ const resolvedRoot = realpathSync(out);
38
+ const resolvedDir = realpathSync(dir);
39
+ const resolvedColdPool = realpathSync(coldPool);
40
+
41
+ // Must be an ancestor of dir (standalone case handled above)
42
+ if (!resolvedDir.startsWith(resolvedRoot + "/")) {
43
+ return null;
44
+ }
45
+
46
+ // Must be within cold_pool — prevents finding an unrelated git repo outside
47
+ if (resolvedRoot === resolvedColdPool || resolvedRoot.startsWith(resolvedColdPool + "/")) {
48
+ return out;
49
+ }
50
+
51
+ return null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
58
+ try {
59
+ const output = execSync("git pull", {
60
+ cwd: dir,
61
+ encoding: "utf-8",
62
+ stdio: ["pipe", "pipe", "pipe"],
63
+ timeout: 30000,
64
+ }).trim();
65
+
66
+ if (output.includes("Already up to date") || output.includes("Already up-to-date")) {
67
+ return { status: "up-to-date", message: output };
68
+ }
69
+ return { status: "updated", message: output };
70
+ } catch (err: any) {
71
+ const stderr = err.stderr?.toString() || err.message || "";
72
+ return { status: "failed", message: stderr.trim() };
73
+ }
74
+ }
75
+
76
+ export function refreshDeck(cliDeckPath?: string, cliWorkdir?: string, target?: string): void {
77
+ const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
78
+ const DECK_PATH = cliDeck
79
+ ? resolve(cliDeck)
80
+ : findDeckToml(process.cwd()) || resolve("skill-deck.toml");
81
+
82
+ if (!existsSync(DECK_PATH)) {
83
+ console.error(`❌ skill-deck.toml not found in ${process.cwd()}`);
84
+ console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`);
85
+ process.exit(1);
86
+ }
87
+
88
+ const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
89
+ const deckRaw = readFileSync(DECK_PATH, "utf-8");
90
+ const deck = parseToml(deckRaw) as any;
91
+
92
+ const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
93
+
94
+ // ── 收集声明 ────────────────────────────────────────────────
95
+
96
+ const declared: { name: string; alias: string; path: string; type: string }[] = [];
97
+
98
+ // Use parseDeck for alias-dict compatibility
99
+ const { entries: parsedEntries, deprecated: isDeprecated } = parseDeck(deckRaw);
100
+ if (isDeprecated) {
101
+ console.warn("⚠️ Deprecation: string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
102
+ }
103
+
104
+ for (const entry of parsedEntries) {
105
+ declared.push({ name: entry.path, alias: entry.alias, path: entry.path, type: entry.type });
106
+ }
107
+
108
+ if (declared.length === 0) {
109
+ console.log("📭 No skills declared in deck. Nothing to refresh.");
110
+ process.exit(0);
111
+ }
112
+
113
+ // ── 确定目标 ────────────────────────────────────────────────
114
+
115
+ let targets: { name: string; alias: string; path: string; type: string }[];
116
+
117
+ if (target) {
118
+ // Try resolve as alias first, then as FQ path
119
+ const byAlias = declared.find(d => d.alias === target);
120
+ if (byAlias) {
121
+ targets = [byAlias];
122
+ } else {
123
+ const byPath = declared.find(d => d.path === target);
124
+ if (byPath) {
125
+ targets = [byPath];
126
+ } else {
127
+ console.error(`❌ Skill not found in deck: ${target}`);
128
+ console.error(` Declared aliases: ${declared.map(d => d.alias).join(", ")}`);
129
+ process.exit(1);
130
+ }
131
+ }
132
+ } else {
133
+ targets = declared;
134
+ }
135
+
136
+ // ── 执行刷新 ────────────────────────────────────────────────
137
+
138
+ const results: RefreshResult[] = [];
139
+ let updated = 0;
140
+ let upToDate = 0;
141
+ let skipped = 0;
142
+ let failed = 0;
143
+
144
+ for (const item of targets) {
145
+ const result = findSource(item.path, COLD_POOL, PROJECT_DIR);
146
+
147
+ if (result.error || !result.path) {
148
+ results.push({ name: item.alias, path: "", status: "failed", message: result.error || "Skill not found in cold pool" });
149
+ failed++;
150
+ continue;
151
+ }
152
+
153
+ const path = result.path;
154
+
155
+ // localhost skills are user-managed; skip
156
+ const relativePath = relative(COLD_POOL, path);
157
+ if (relativePath.startsWith("localhost")) {
158
+ results.push({ name: item.alias, path: relativePath, status: "skipped", message: "localhost skill — user-managed" });
159
+ skipped++;
160
+ continue;
161
+ }
162
+
163
+ const gitRoot = findGitRoot(path, COLD_POOL);
164
+ if (!gitRoot) {
165
+ results.push({ name: item.alias, path: relativePath, status: "not-git", message: "skipped: not a git repository" });
166
+ skipped++;
167
+ continue;
168
+ }
169
+
170
+ const pullResult = gitPull(gitRoot);
171
+ results.push({ name: item.alias, path: relativePath, status: pullResult.status, message: pullResult.message });
172
+
173
+ if (pullResult.status === "updated") updated++;
174
+ else if (pullResult.status === "up-to-date") upToDate++;
175
+ else failed++;
176
+ }
177
+
178
+ // ── 报告 ────────────────────────────────────────────────────
179
+
180
+ const scope = target ? `single skill` : `${declared.length} skill(s)`;
181
+ console.log(`\n📦 Skill Refresh Report — ${scope} checked`);
182
+ console.log(` Updated: ${updated} | Up-to-date: ${upToDate} | Skipped: ${skipped} | Failed: ${failed}`);
183
+ console.log();
184
+
185
+ for (const r of results) {
186
+ const icon =
187
+ r.status === "updated" ? "🔄" :
188
+ r.status === "up-to-date" ? "✅" :
189
+ r.status === "skipped" ? "⏭️" :
190
+ r.status === "not-git" ? "📁" :
191
+ "❌";
192
+ console.log(`${icon} ${r.name}`);
193
+ if (r.message && r.status !== "up-to-date") {
194
+ const lines = r.message.split("\n").filter(l => l.trim());
195
+ for (const line of lines.slice(0, 3)) {
196
+ console.log(` ${line.trim()}`);
197
+ }
198
+ if (lines.length > 3) {
199
+ console.log(` ... (${lines.length - 3} more lines)`);
200
+ }
201
+ }
202
+ }
203
+
204
+ if (updated > 0) {
205
+ console.log(`\n💡 Run 'bunx @lythos/skill-deck link' to sync refreshed skills to working set.`);
206
+ console.log("🔗 Running deck link...");
207
+ linkDeck(cliDeckPath, cliWorkdir);
208
+ }
209
+
210
+ if (failed > 0) {
211
+ process.exit(1);
212
+ }
213
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * remove.test.ts — unit tests for remove.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/remove.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach, spyOn } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, symlinkSync, lstatSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+
13
+ let cleanup: string[] = []
14
+
15
+ afterEach(() => {
16
+ for (const dir of cleanup) {
17
+ rmSync(dir, { recursive: true, force: true })
18
+ }
19
+ cleanup = []
20
+ })
21
+
22
+ function makeTmp(): string {
23
+ const dir = mkdtempSync(join(tmpdir(), 'deck-remove-'))
24
+ cleanup.push(dir)
25
+ return dir
26
+ }
27
+
28
+ function placeSkill(coldPool: string, relPath: string): string {
29
+ const skillDir = join(coldPool, relPath)
30
+ mkdirSync(skillDir, { recursive: true })
31
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
32
+ return skillDir
33
+ }
34
+
35
+ function buildDeck(projectDir: string, coldPoolRel: string, alias: string, path: string): string {
36
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.${alias}]\npath = "${path}"\n`
37
+ const deckPath = join(projectDir, 'skill-deck.toml')
38
+ writeFileSync(deckPath, deckContent)
39
+ return deckPath
40
+ }
41
+
42
+ describe('removeSkill', () => {
43
+ it('C9: remove by alias cleans deck.toml + symlink, preserves cold pool', async () => {
44
+ const projectDir = makeTmp()
45
+ const coldPoolRel = 'cold-pool'
46
+ const coldPool = join(projectDir, coldPoolRel)
47
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
48
+
49
+ const deckPath = buildDeck(projectDir, coldPoolRel, 'skill-a', 'github.com/owner/repo/skill-a')
50
+
51
+ const workingSet = join(projectDir, '.claude', 'skills')
52
+ mkdirSync(workingSet, { recursive: true })
53
+ symlinkSync(skillDir, join(workingSet, 'skill-a'))
54
+
55
+ const { removeSkill } = await import('./remove.ts')
56
+ removeSkill('skill-a', deckPath, projectDir)
57
+
58
+ const deckContent = readFileSync(deckPath, 'utf-8')
59
+ expect(deckContent).not.toContain('[tool.skills.skill-a]')
60
+ expect(deckContent).not.toContain('path = "github.com/owner/repo/skill-a"')
61
+
62
+ expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
63
+ expect(existsSync(skillDir)).toBe(true)
64
+ expect(existsSync(join(skillDir, 'SKILL.md'))).toBe(true)
65
+ })
66
+
67
+ it('C10: remove by FQ path cleans deck.toml + symlink, preserves cold pool', async () => {
68
+ const projectDir = makeTmp()
69
+ const coldPoolRel = 'cold-pool'
70
+ const coldPool = join(projectDir, coldPoolRel)
71
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
72
+
73
+ const deckPath = buildDeck(projectDir, coldPoolRel, 'skill-a', 'github.com/owner/repo/skill-a')
74
+
75
+ const workingSet = join(projectDir, '.claude', 'skills')
76
+ mkdirSync(workingSet, { recursive: true })
77
+ symlinkSync(skillDir, join(workingSet, 'skill-a'))
78
+
79
+ const { removeSkill } = await import('./remove.ts')
80
+ removeSkill('github.com/owner/repo/skill-a', deckPath, projectDir)
81
+
82
+ const deckContent = readFileSync(deckPath, 'utf-8')
83
+ expect(deckContent).not.toContain('[tool.skills.skill-a]')
84
+ expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
85
+ expect(existsSync(skillDir)).toBe(true)
86
+ })
87
+
88
+ it('C11: remove non-existent target exits with error', async () => {
89
+ const projectDir = makeTmp()
90
+ const coldPoolRel = 'cold-pool'
91
+ const coldPool = join(projectDir, coldPoolRel)
92
+ mkdirSync(coldPool, { recursive: true })
93
+
94
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n`
95
+ const deckPath = join(projectDir, 'skill-deck.toml')
96
+ writeFileSync(deckPath, deckContent)
97
+
98
+ const errors: string[] = []
99
+ const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
100
+ errors.push(String(msg))
101
+ })
102
+
103
+ const originalExit = process.exit
104
+ let exitCode: number | undefined
105
+ process.exit = ((code?: number) => {
106
+ exitCode = code ?? 0
107
+ throw new Error(`EXIT:${code}`)
108
+ }) as typeof process.exit
109
+
110
+ try {
111
+ const { removeSkill } = await import('./remove.ts')
112
+ removeSkill('not-in-deck', deckPath, projectDir)
113
+ expect(false).toBe(true)
114
+ } catch (err: any) {
115
+ expect(exitCode).toBe(1)
116
+ expect(errors.some(e => e.includes('Skill not found in deck'))).toBe(true)
117
+ } finally {
118
+ process.exit = originalExit
119
+ errorSpy.mockRestore()
120
+ }
121
+ })
122
+
123
+ it('C11.b: remove legacy string-array entry by alias', async () => {
124
+ const projectDir = makeTmp()
125
+ const coldPoolRel = 'cold-pool'
126
+ const coldPool = join(projectDir, coldPoolRel)
127
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
128
+
129
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner/repo/skill-a"]\n`
130
+ const deckPath = join(projectDir, 'skill-deck.toml')
131
+ writeFileSync(deckPath, deckContent)
132
+
133
+ const workingSet = join(projectDir, '.claude', 'skills')
134
+ mkdirSync(workingSet, { recursive: true })
135
+ symlinkSync(skillDir, join(workingSet, 'skill-a'))
136
+
137
+ const { removeSkill } = await import('./remove.ts')
138
+ removeSkill('skill-a', deckPath, projectDir)
139
+
140
+ const deckContentAfter = readFileSync(deckPath, 'utf-8')
141
+ expect(deckContentAfter).not.toContain('skills = [')
142
+ expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
143
+ expect(existsSync(skillDir)).toBe(true)
144
+ })
145
+ })