@lythos/skill-deck 0.9.18 → 0.9.20

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 CHANGED
@@ -9,7 +9,7 @@
9
9
  This package exposes a **CLI**. Invoke via:
10
10
 
11
11
  ```bash
12
- bunx @lythos/skill-deck@0.9.18 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.20 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -41,26 +41,28 @@ path = "github.com/mattpocock/skills/skills/engineering/tdd"
41
41
  [tool.skills.gstack]
42
42
  path = "github.com/garrytan/gstack"
43
43
 
44
- [combo.skills.pdf] # Multi-skill bundles
45
- path = "github.com/anthropics/skills/skills/pdf"
44
+ [transient.trial-skill] # Trial skills with auto-expiry
45
+ path = "./skills/experimental"
46
+ expires = "2026-06-01" # ISO date; warns at ≤14 days
46
47
 
47
- [transient.handoff] # Temporary skills with expiry
48
- path = "./skills/handoff" # Local path (not cold pool)
49
- expires = "2026-05-01" # ISO date; warns at ≤14 days
48
+ # combo is a meta-declaration, not a skill type (doesn't count against max_cards):
49
+ [combo.report-generation]
50
+ skills = ["web-search", "docx", "mermaid"]
51
+ prompt = "Search for latest info, then generate professional document with diagrams"
50
52
  ```
51
53
 
52
54
  ### When to invoke
53
55
 
54
56
  | Situation | Command |
55
57
  |-----------|---------|
56
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.18 link` |
57
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.18 validate` |
58
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.18 add owner/repo` |
59
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.18 refresh` |
60
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.18 refresh tdd` |
61
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.18 remove tdd` |
62
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.18 prune` |
63
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.18 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.20 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.20 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.20 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.20 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.20 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.20 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.20 prune` |
65
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.20 link --deck ./my-deck.toml --workdir /path/to/project` |
64
66
 
65
67
  ### Commands
66
68
 
@@ -81,7 +83,7 @@ expires = "2026-05-01" # ISO date; warns at ≤14 days
81
83
  | `--workdir <dir>` | Working directory | cwd |
82
84
 
83
85
  | `--alias <alias>` | Explicit alias for the skill (default: basename of path) | — |
84
- | `--type <type>` | Target section for `add`: `innate`, `tool`, or `combo` | `tool` |
86
+ | `--type <type>` | Target section for `add`: `innate`, `tool`, or `transient` | `tool` |
85
87
 
86
88
  ### Safety guards
87
89
 
@@ -117,7 +119,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
117
119
  EOF
118
120
 
119
121
  # 2. Link — creates symlinks in .claude/skills/
120
- bunx @lythos/skill-deck@0.9.18 link
122
+ bunx @lythos/skill-deck@0.9.20 link
121
123
  ```
122
124
 
123
125
  ### Key Concepts
@@ -146,7 +148,7 @@ Different agents look for skills in different directories. `skill-deck.toml` con
146
148
 
147
149
  | Symptom | Cause | Fix |
148
150
  |---------|-------|-----|
149
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.18 add github.com/owner/repo/skill` or clone manually into cold pool |
151
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.20 add github.com/owner/repo/skill` or clone manually into cold pool |
150
152
  | `link` skips entries with warnings | Real files/directories exist in working set (not symlinks) | Delete the real directories in `working_set` and re-run `link`. Never create directories manually there |
151
153
  | `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone with `git clone` or use `deck add` which clones by default |
152
154
  | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/prune.test.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, afterEach, spyOn } from 'bun:test'
9
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { tmpdir } from 'node:os'
12
+ import { formatSize } from './prune.ts'
12
13
 
13
14
  let cleanup: string[] = []
14
15
 
@@ -38,6 +39,18 @@ function placeSkillInRepo(repoDir: string, skillName: string): string {
38
39
  return skillDir
39
40
  }
40
41
 
42
+ describe('formatSize', () => {
43
+ it('formats bytes correctly at each boundary', () => {
44
+ expect(formatSize(0)).toBe('0B')
45
+ expect(formatSize(512)).toBe('512B')
46
+ expect(formatSize(1023)).toBe('1023B')
47
+ expect(formatSize(1024)).toBe('1.0KB')
48
+ expect(formatSize(1536)).toBe('1.5KB')
49
+ expect(formatSize(1048576)).toBe('1.0MB')
50
+ expect(formatSize(1073741824)).toBe('1.0GB')
51
+ })
52
+ })
53
+
41
54
  describe('pruneDeck', () => {
42
55
  it('C15: prune with unreferenced repos deletes them when --yes is set', async () => {
43
56
  const projectDir = makeTmp()
package/src/prune.ts CHANGED
@@ -19,7 +19,7 @@ interface PruneCandidate {
19
19
  size: number;
20
20
  }
21
21
 
22
- function formatSize(bytes: number): string {
22
+ export function formatSize(bytes: number): string {
23
23
  if (bytes < 1024) return `${bytes}B`;
24
24
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
25
25
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
2
+ import { mkdirSync, rmSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { resolveRefreshConfig, detectGitRoot, buildRefreshPlan } from './refresh-plan'
4
+ import { resolveRefreshConfig, detectGitRoot, buildRefreshPlan, executeRefreshPlan, type RefreshPlan, type RefreshTarget } from './refresh-plan'
5
5
 
6
6
  const deckAliasDict = `[deck]
7
7
  max_cards = 10
@@ -54,6 +54,15 @@ describe('detectGitRoot', () => {
54
54
  expect(result.type).toBe('localhost')
55
55
  })
56
56
 
57
+ test('git: directory with .git directly present', () => {
58
+ const dir = join('/tmp', 'refresh-test-git-' + Date.now())
59
+ mkdirSync(join(dir, '.git'), { recursive: true })
60
+ const result = detectGitRoot(dir, '/tmp')
61
+ expect(result.type).toBe('git')
62
+ expect(result.gitRoot).toBe(dir)
63
+ rmSync(dir, { recursive: true, force: true })
64
+ })
65
+
57
66
  test('not-git: directory without .git', () => {
58
67
  const dir = join('/tmp', 'refresh-test-no-git-' + Date.now())
59
68
  mkdirSync(dir, { recursive: true })
@@ -105,6 +114,17 @@ describe('buildRefreshPlan', () => {
105
114
  expect(localhost!.path).toBe('localhost/skill-b')
106
115
  })
107
116
 
117
+ test('derives coldPool from deck toml when not in opts', () => {
118
+ const plan = buildRefreshPlan(deckAliasDict, { workdir: '/custom/work' })
119
+ expect(plan.workdir).toBe('/custom/work')
120
+ expect(plan.coldPool).toBe('/custom/work/cold-pool')
121
+ })
122
+
123
+ test('explicit coldPool in opts overrides deck toml', () => {
124
+ const plan = buildRefreshPlan(deckAliasDict, { coldPool: '/explicit/pool' })
125
+ expect(plan.coldPool).toBe('/explicit/pool')
126
+ })
127
+
108
128
  test('paths are resolved through config', () => {
109
129
  const plan = buildRefreshPlan(deckAliasDict, {
110
130
  deckPath: '/custom/deck.toml',
@@ -116,3 +136,139 @@ describe('buildRefreshPlan', () => {
116
136
  expect(plan.coldPool).toBe('/custom/pool')
117
137
  })
118
138
  })
139
+
140
+ // ── executeRefreshPlan (IO-injected plan execution) ────────────────
141
+
142
+ function makeTarget(overrides: Partial<RefreshTarget> = {}): RefreshTarget {
143
+ return {
144
+ alias: 'skill-a',
145
+ path: 'github.com/owner/repo/skill-a',
146
+ sourcePath: '/pool/github.com/owner/repo/skill-a',
147
+ sourceRel: 'github.com/owner/repo/skill-a',
148
+ type: 'git',
149
+ gitRoot: '/pool/github.com/owner/repo/skill-a',
150
+ ...overrides,
151
+ }
152
+ }
153
+
154
+ function makePlan(targets: RefreshTarget[]): RefreshPlan {
155
+ return {
156
+ deckPath: '/tmp/deck.toml',
157
+ workdir: '/tmp',
158
+ coldPool: '/pool',
159
+ targets,
160
+ allDeclared: targets.map(t => ({ alias: t.alias, path: t.path, type: 'tool' as const })),
161
+ }
162
+ }
163
+
164
+ describe('executeRefreshPlan', () => {
165
+ test('git up-to-date: reports correctly, does not call linkDeck', () => {
166
+ const plan = makePlan([makeTarget()])
167
+ const logs: string[] = []
168
+ let linkCalled = false
169
+
170
+ const results = executeRefreshPlan(plan, {
171
+ gitPull: () => ({ status: 'up-to-date', message: 'Already up to date.' }),
172
+ log: (msg) => logs.push(msg),
173
+ linkDeck: () => { linkCalled = true },
174
+ })
175
+
176
+ expect(results).toHaveLength(1)
177
+ expect(results[0].status).toBe('up-to-date')
178
+ expect(logs.some(l => l.includes('Up-to-date: 1'))).toBe(true)
179
+ expect(linkCalled).toBe(false)
180
+ })
181
+
182
+ test('git updated: triggers linkDeck', () => {
183
+ const plan = makePlan([makeTarget()])
184
+ let linkCalled = false
185
+
186
+ const results = executeRefreshPlan(plan, {
187
+ gitPull: () => ({ status: 'updated', message: 'Fast-forward' }),
188
+ log: () => {},
189
+ linkDeck: () => { linkCalled = true },
190
+ })
191
+
192
+ expect(results[0].status).toBe('updated')
193
+ expect(linkCalled).toBe(true)
194
+ })
195
+
196
+ test('git failed: reports failed, does not call linkDeck', () => {
197
+ const plan = makePlan([makeTarget()])
198
+ let linkCalled = false
199
+
200
+ const results = executeRefreshPlan(plan, {
201
+ gitPull: () => ({ status: 'failed', message: 'connection refused' }),
202
+ log: () => {},
203
+ linkDeck: () => { linkCalled = true },
204
+ })
205
+
206
+ expect(results[0].status).toBe('failed')
207
+ expect(linkCalled).toBe(false)
208
+ })
209
+
210
+ test('localhost: skipped with user-managed message', () => {
211
+ const plan = makePlan([makeTarget({ type: 'localhost', gitRoot: undefined })])
212
+
213
+ const results = executeRefreshPlan(plan, { log: () => {} })
214
+
215
+ expect(results[0].status).toBe('skipped')
216
+ expect(results[0].message).toContain('localhost')
217
+ expect(results[0].message).toContain('user-managed')
218
+ })
219
+
220
+ test('not-git: skipped with not-a-git-repository message', () => {
221
+ const plan = makePlan([makeTarget({ type: 'not-git', gitRoot: undefined })])
222
+
223
+ const results = executeRefreshPlan(plan, { log: () => {} })
224
+
225
+ expect(results[0].status).toBe('not-git')
226
+ expect(results[0].message).toContain('not a git repository')
227
+ })
228
+
229
+ test('missing: failed with not-found message', () => {
230
+ const plan = makePlan([makeTarget({ type: 'missing', gitRoot: undefined, sourcePath: '' })])
231
+
232
+ const results = executeRefreshPlan(plan, { log: () => {} })
233
+
234
+ expect(results[0].status).toBe('failed')
235
+ expect(results[0].message).toContain('not found')
236
+ })
237
+
238
+ test('multiple targets: counts each status', () => {
239
+ const logs: string[] = []
240
+ const plan = makePlan([
241
+ makeTarget({ alias: 'up', type: 'git', gitRoot: '/pool/a' }),
242
+ makeTarget({ alias: 'updated', type: 'git', gitRoot: '/pool/b' }),
243
+ makeTarget({ alias: 'local', type: 'localhost', gitRoot: undefined }),
244
+ makeTarget({ alias: 'nogit', type: 'not-git', gitRoot: undefined }),
245
+ ])
246
+
247
+ const results = executeRefreshPlan(plan, {
248
+ gitPull: (dir) => {
249
+ if (dir === '/pool/b') return { status: 'updated', message: 'Fast-forward' }
250
+ return { status: 'up-to-date', message: 'Already up to date.' }
251
+ },
252
+ log: (msg) => logs.push(msg),
253
+ })
254
+
255
+ expect(results).toHaveLength(4)
256
+ expect(logs.some(l => l.includes('Updated: 1') && l.includes('Up-to-date: 1') && l.includes('Skipped: 2'))).toBe(true)
257
+ })
258
+
259
+ test('single target in plan ≠ allDeclared → reports "single skill" scope', () => {
260
+ const plan = makePlan([makeTarget()])
261
+ plan.allDeclared = [
262
+ { alias: 'skill-a', path: 'github.com/owner/repo/skill-a', type: 'tool' },
263
+ { alias: 'skill-b', path: 'github.com/owner/repo/skill-b', type: 'tool' },
264
+ ]
265
+ const logs: string[] = []
266
+
267
+ executeRefreshPlan(plan, {
268
+ gitPull: () => ({ status: 'up-to-date', message: 'ok' }),
269
+ log: (msg) => logs.push(msg),
270
+ })
271
+
272
+ expect(logs.some(l => l.includes('single skill'))).toBe(true)
273
+ })
274
+ })
@@ -2,265 +2,22 @@
2
2
  /**
3
3
  * refresh.test.ts — unit tests for refresh.ts helpers
4
4
  *
5
- * Run: bun test packages/lythoskill-deck/src/refresh.test.ts
5
+ * Actual IO tests (git pull, linkDeck, refreshDeck end-to-end) belong in
6
+ * e2e/integration tests run manually. This file tests thin wrappers only.
6
7
  */
7
8
 
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'
9
+ import { describe, it, expect } from 'bun:test'
14
10
 
15
11
  import { findGitRoot } from './refresh.ts'
16
12
 
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
13
  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
- }
14
+ it('wraps detectGitRoot returns gitRoot or null', () => {
15
+ // Thin wrapper: delegates to detectGitRoot(dir, coldPool) and returns gitRoot ?? null.
16
+ // detectGitRoot is tested in refresh-plan.test.ts with IO injection.
17
+ // This test verifies the signature and null-coalescing.
18
+ // null means detectGitRoot returned something without gitRoot (not-git, localhost, missing).
19
+ // string means detectGitRoot found a git root.
20
+ const result = findGitRoot('/nonexistent/path', '/pool')
21
+ expect(typeof result === 'string' || result === null).toBe(true)
265
22
  })
266
23
  })