@lythos/skill-deck 0.9.0 → 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,53 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * parse-deck.test.ts — unit tests for parse-deck.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/parse-deck.test.ts
6
+ */
7
+
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { parseDeck } from './parse-deck.ts'
10
+
11
+ describe('parseDeck', () => {
12
+ it('parses dict format entries', () => {
13
+ const raw = `[tool.skills.foo]\npath = "github.com/owner/repo"\n`
14
+ const result = parseDeck(raw)
15
+ expect(result.entries).toHaveLength(1)
16
+ expect(result.entries[0].alias).toBe('foo')
17
+ expect(result.entries[0].path).toBe('github.com/owner/repo')
18
+ expect(result.entries[0].type).toBe('tool')
19
+ expect(result.deprecated).toBe(false)
20
+ expect(result.errors).toHaveLength(0)
21
+ })
22
+
23
+ it('errors when dict entry lacks path', () => {
24
+ const raw = `[tool.skills.foo]\nname = "foo"\n`
25
+ const result = parseDeck(raw)
26
+ expect(result.errors.length).toBeGreaterThan(0)
27
+ expect(result.errors[0]).toContain('Missing path')
28
+ })
29
+
30
+ it('errors when dict entry has invalid schema', () => {
31
+ const raw = `[tool.skills.foo]\npath = ""\n`
32
+ const result = parseDeck(raw)
33
+ expect(result.errors.length).toBeGreaterThan(0)
34
+ })
35
+
36
+ it('parses legacy string-array format and marks deprecated', () => {
37
+ const raw = `[tool]\nskills = ["github.com/owner/repo"]\n`
38
+ const result = parseDeck(raw)
39
+ expect(result.deprecated).toBe(true)
40
+ expect(result.entries).toHaveLength(1)
41
+ expect(result.entries[0].alias).toBe('repo')
42
+ expect(result.entries[0].path).toBe('github.com/owner/repo')
43
+ expect(result.entries[0].type).toBe('tool')
44
+ })
45
+
46
+ it('returns empty for deck with no skill sections', () => {
47
+ const raw = `[deck]\nmax_cards = 10\n`
48
+ const result = parseDeck(raw)
49
+ expect(result.entries).toHaveLength(0)
50
+ expect(result.deprecated).toBe(false)
51
+ expect(result.errors).toHaveLength(0)
52
+ })
53
+ })
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * prune.test.ts — unit tests for prune.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/prune.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach, spyOn } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } 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-prune-'))
24
+ cleanup.push(dir)
25
+ return dir
26
+ }
27
+
28
+ function placeRepo(coldPool: string, host: string, owner: string, repo: string): string {
29
+ const repoDir = join(coldPool, host, owner, repo)
30
+ mkdirSync(repoDir, { recursive: true })
31
+ return repoDir
32
+ }
33
+
34
+ function placeSkillInRepo(repoDir: string, skillName: string): string {
35
+ const skillDir = join(repoDir, 'skills', skillName)
36
+ mkdirSync(skillDir, { recursive: true })
37
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
38
+ return skillDir
39
+ }
40
+
41
+ describe('pruneDeck', () => {
42
+ it('C15: prune with unreferenced repos deletes them when --yes is set', async () => {
43
+ const projectDir = makeTmp()
44
+ const coldPoolRel = 'cold-pool'
45
+ const coldPool = join(projectDir, coldPoolRel)
46
+
47
+ const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
48
+ placeSkillInRepo(repoA, 'skill-a')
49
+
50
+ const repoB = placeRepo(coldPool, 'github.com', 'owner', 'repo-b')
51
+ placeSkillInRepo(repoB, 'skill-b')
52
+
53
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skill-a"\n`
54
+ const deckPath = join(projectDir, 'skill-deck.toml')
55
+ writeFileSync(deckPath, deckContent)
56
+
57
+ const { pruneDeck } = await import('./prune.ts')
58
+ await pruneDeck(deckPath, projectDir, true)
59
+
60
+ expect(existsSync(repoA)).toBe(true)
61
+ expect(existsSync(join(repoA, 'skills', 'skill-a', 'SKILL.md'))).toBe(true)
62
+
63
+ expect(existsSync(repoB)).toBe(false)
64
+ })
65
+
66
+ it('C16: prune with all referenced repos is a no-op', async () => {
67
+ const projectDir = makeTmp()
68
+ const coldPoolRel = 'cold-pool'
69
+ const coldPool = join(projectDir, coldPoolRel)
70
+
71
+ const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
72
+ placeSkillInRepo(repoA, 'skill-a')
73
+
74
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skill-a"\n`
75
+ const deckPath = join(projectDir, 'skill-deck.toml')
76
+ writeFileSync(deckPath, deckContent)
77
+
78
+ const logs: string[] = []
79
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
80
+ logs.push(String(msg))
81
+ })
82
+
83
+ const originalExit = process.exit
84
+ let exitCode: number | undefined
85
+ process.exit = ((code?: number) => {
86
+ exitCode = code ?? 0
87
+ throw new Error(`EXIT:${code}`)
88
+ }) as typeof process.exit
89
+
90
+ try {
91
+ const { pruneDeck } = await import('./prune.ts')
92
+ await pruneDeck(deckPath, projectDir, true)
93
+ expect(false).toBe(true)
94
+ } catch (err: any) {
95
+ expect(exitCode).toBe(0)
96
+ expect(logs.some(l => l.includes('Nothing to prune'))).toBe(true)
97
+ } finally {
98
+ process.exit = originalExit
99
+ logSpy.mockRestore()
100
+ }
101
+ })
102
+
103
+ it('C17: prune with empty cold pool reports nothing to prune', async () => {
104
+ const projectDir = makeTmp()
105
+ const coldPoolRel = 'cold-pool'
106
+ const coldPool = join(projectDir, coldPoolRel)
107
+ mkdirSync(coldPool, { recursive: true })
108
+
109
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n`
110
+ const deckPath = join(projectDir, 'skill-deck.toml')
111
+ writeFileSync(deckPath, deckContent)
112
+
113
+ const logs: string[] = []
114
+ const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
115
+ logs.push(String(msg))
116
+ })
117
+
118
+ const originalExit = process.exit
119
+ let exitCode: number | undefined
120
+ process.exit = ((code?: number) => {
121
+ exitCode = code ?? 0
122
+ throw new Error(`EXIT:${code}`)
123
+ }) as typeof process.exit
124
+
125
+ try {
126
+ const { pruneDeck } = await import('./prune.ts')
127
+ await pruneDeck(deckPath, projectDir, true)
128
+ expect(false).toBe(true)
129
+ } catch (err: any) {
130
+ expect(exitCode).toBe(0)
131
+ expect(logs.some(l => l.includes('empty'))).toBe(true)
132
+ } finally {
133
+ process.exit = originalExit
134
+ logSpy.mockRestore()
135
+ }
136
+ })
137
+ })
@@ -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 CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { parse as parseToml } from "@iarna/toml";
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
12
  import { execSync } from "node:child_process";
13
13
  import { resolve, dirname, join, relative } from "node:path";
14
14
  import { findDeckToml, expandHome, findSource, linkDeck } from "./link.js";
@@ -21,8 +21,37 @@ interface RefreshResult {
21
21
  message?: string;
22
22
  }
23
23
 
24
- function isGitRepo(dir: string): boolean {
25
- return existsSync(join(dir, ".git"));
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
+ }
26
55
  }
27
56
 
28
57
  function gitPull(dir: string): { status: "updated" | "up-to-date" | "failed"; message: string } {
@@ -131,13 +160,14 @@ export function refreshDeck(cliDeckPath?: string, cliWorkdir?: string, target?:
131
160
  continue;
132
161
  }
133
162
 
134
- if (!isGitRepo(path)) {
135
- results.push({ name: item.alias, path: relativePath, status: "not-git", message: "Not a git repository" });
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" });
136
166
  skipped++;
137
167
  continue;
138
168
  }
139
169
 
140
- const pullResult = gitPull(path);
170
+ const pullResult = gitPull(gitRoot);
141
171
  results.push({ name: item.alias, path: relativePath, status: pullResult.status, message: pullResult.message });
142
172
 
143
173
  if (pullResult.status === "updated") updated++;
@@ -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
+ })