@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.
- package/README.md +30 -21
- package/package.json +1 -1
- package/src/COVERAGE-GAPS.md +117 -0
- package/src/add.test.ts +195 -0
- package/src/add.ts +71 -17
- package/src/cli.ts +65 -16
- package/src/link.test.ts +320 -0
- package/src/link.ts +55 -29
- package/src/migrate-schema.ts +58 -0
- package/src/parse-deck.test.ts +53 -0
- package/src/parse-deck.ts +78 -0
- package/src/prune.test.ts +137 -0
- package/src/prune.ts +196 -0
- package/src/refresh.test.ts +266 -0
- package/src/refresh.ts +213 -0
- package/src/remove.test.ts +145 -0
- package/src/remove.ts +91 -0
- package/src/schema.ts +10 -0
- package/src/update.ts +6 -147
- package/src/validate.test.ts +160 -0
- package/src/validate.ts +17 -22
|
@@ -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
|
+
})
|