@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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/COVERAGE-GAPS.md +117 -0
- package/src/add.test.ts +195 -0
- package/src/add.ts +1 -1
- package/src/link.test.ts +320 -0
- package/src/parse-deck.test.ts +53 -0
- package/src/prune.test.ts +137 -0
- package/src/refresh.test.ts +266 -0
- package/src/refresh.ts +36 -6
- package/src/remove.test.ts +145 -0
- package/src/validate.test.ts +160 -0
- package/src/validate.ts +17 -22
|
@@ -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
|
|
25
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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(
|
|
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
|
+
})
|