@lythos/skill-deck 0.9.17 → 0.9.19

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.17 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.19 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -53,14 +53,14 @@ expires = "2026-05-01" # ISO date; warns at ≤14 days
53
53
 
54
54
  | Situation | Command |
55
55
  |-----------|---------|
56
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.17 link` |
57
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.17 validate` |
58
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.17 add owner/repo` |
59
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.17 refresh` |
60
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.17 refresh tdd` |
61
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.17 remove tdd` |
62
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.17 prune` |
63
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.17 link --deck ./my-deck.toml --workdir /path/to/project` |
56
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.19 link` |
57
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.19 validate` |
58
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.19 add owner/repo` |
59
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.19 refresh` |
60
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.19 refresh tdd` |
61
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.19 remove tdd` |
62
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.19 prune` |
63
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.19 link --deck ./my-deck.toml --workdir /path/to/project` |
64
64
 
65
65
  ### Commands
66
66
 
@@ -117,7 +117,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
117
117
  EOF
118
118
 
119
119
  # 2. Link — creates symlinks in .claude/skills/
120
- bunx @lythos/skill-deck@0.9.17 link
120
+ bunx @lythos/skill-deck@0.9.19 link
121
121
  ```
122
122
 
123
123
  ### Key Concepts
@@ -146,7 +146,7 @@ Different agents look for skills in different directories. `skill-deck.toml` con
146
146
 
147
147
  | Symptom | Cause | Fix |
148
148
  |---------|-------|-----|
149
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.17 add github.com/owner/repo/skill` or clone manually into cold pool |
149
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.19 add github.com/owner/repo/skill` or clone manually into cold pool |
150
150
  | `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
151
  | `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
152
  | `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.17",
3
+ "version": "0.9.19",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/add.ts CHANGED
@@ -75,7 +75,8 @@ function resolvePath(p: string): string {
75
75
  return resolve(p)
76
76
  }
77
77
 
78
- export async function addSkill(locator: string, options: { deck?: string; workdir?: string; alias?: string; type?: string }) {
78
+ export async function addSkill(locator: string, options: { deck?: string; workdir?: string; alias?: string; type?: string; dryRun?: boolean }) {
79
+ const dryRun = options.dryRun || false
79
80
  const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
80
81
  const deckPath = options.deck
81
82
  ? resolvePath(options.deck)
@@ -99,6 +100,37 @@ export async function addSkill(locator: string, options: { deck?: string; workdi
99
100
 
100
101
  const targetDir = join(coldPool, parsed.host, parsed.owner, parsed.repo)
101
102
 
103
+ if (dryRun) {
104
+ const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
105
+ const alias = options.alias || skillName
106
+ const skillType = (options.type || 'tool').toLowerCase()
107
+ const fqPath = parsed.skill
108
+ ? `${parsed.host}/${parsed.owner}/${parsed.repo}/${parsed.skill}`
109
+ : `${parsed.host}/${parsed.owner}/${parsed.repo}`
110
+
111
+ console.log(`🔎 Dry-run: deck add ${locator}`)
112
+ console.log(` Cold pool: ${coldPool}`)
113
+ console.log(` Deck: ${deckPath}`)
114
+ console.log()
115
+ console.log(`📂 Repo status: ${existsSync(join(targetDir, '.git')) ? 'already cloned' : existsSync(targetDir) ? 'dir exists (partial clone?)' : 'not in cold pool'}`)
116
+ if (!existsSync(join(targetDir, '.git'))) {
117
+ console.log(`📦 Would clone: https://${parsed.host}/${parsed.owner}/${parsed.repo}.git --depth 1`)
118
+ }
119
+ if (parsed.skill) {
120
+ const skillMd = join(targetDir, parsed.skill, 'SKILL.md')
121
+ if (existsSync(targetDir) && existsSync(skillMd)) {
122
+ console.log(`📄 Skill path: valid — ${skillMd}`)
123
+ } else if (existsSync(targetDir)) {
124
+ console.log(`⚠️ Skill path: NOT FOUND — check repo layout`)
125
+ }
126
+ }
127
+ console.log(`\n📝 Would add to skill-deck.toml:`)
128
+ console.log(` [${skillType}.skills.${alias}]`)
129
+ console.log(` path = "${fqPath}"`)
130
+ console.log(`\n💡 Remove --dry-run to execute.`)
131
+ return
132
+ }
133
+
102
134
  if (existsSync(targetDir)) {
103
135
  console.error(`❌ Already exists in cold pool: ${targetDir}`)
104
136
  console.error(` To update: rm -rf ${targetDir} and re-run`)
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ const alias = aliasFlagIdx >= 0 ? args[aliasFlagIdx + 1] : undefined
23
23
  const type = typeFlagIdx >= 0 ? args[typeFlagIdx + 1] : undefined
24
24
  const noBackup = args.includes('--no-backup')
25
25
  const yes = args.includes('--yes')
26
+ const dryRun = args.includes('--dry-run')
26
27
 
27
28
  const HELP_CONFIG = {
28
29
  binName: 'lythoskill-deck',
@@ -43,6 +44,7 @@ const HELP_CONFIG = {
43
44
 
44
45
  { flag: '--alias <name>', description: 'Explicit alias for the skill (default: basename of path)' },
45
46
  { flag: '--type <type>', description: 'Target section: innate | tool | combo (default: tool)' },
47
+ { flag: '--dry-run', description: 'Show plan without executing (add, prune)' },
46
48
  { flag: '--yes', description: 'Skip interactive confirmation (for prune)' },
47
49
  ],
48
50
  }
@@ -61,7 +63,7 @@ switch (command) {
61
63
  console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
62
64
  process.exit(1)
63
65
  }
64
- await addSkill(locator, { deck: deckPath, workdir, alias, type })
66
+ await addSkill(locator, { deck: deckPath, workdir, alias, type, dryRun })
65
67
  break
66
68
  }
67
69
  case 'refresh': {
package/src/link.ts CHANGED
@@ -79,13 +79,14 @@ export function findSource(name: string, coldPool: string, projectDir: string):
79
79
  if (existsSync(join(directPath, "SKILL.md"))) return { path: directPath };
80
80
  }
81
81
 
82
- // 0.5 localhost skills: localhost/skill → cold_pool/skill
82
+ // 0.5 localhost skills: localhost/skill → cold_pool/<skill>
83
83
  if (name.startsWith('localhost/')) {
84
84
  const skill = name.slice('localhost/'.length);
85
85
  if (skill) {
86
86
  const localPath = join(coldPool, skill);
87
87
  if (existsSync(join(localPath, "SKILL.md"))) return { path: localPath };
88
88
  }
89
+ return { path: null };
89
90
  }
90
91
 
91
92
  // 1. 直接路径
@@ -216,8 +217,32 @@ for (const entry of parsedEntries) {
216
217
  continue;
217
218
  }
218
219
  if (!result.path) {
219
- errors.push(`Skill not found: ${entry.path}`);
220
- continue;
220
+ // For localhost skills, create a placeholder so the user can fill it in
221
+ if (entry.path.startsWith('localhost/')) {
222
+ const skill = entry.path.slice('localhost/'.length)
223
+ const localPath = join(COLD_POOL, skill)
224
+ if (!existsSync(join(localPath, 'SKILL.md'))) {
225
+ const now = new Date().toISOString().slice(0, 10)
226
+ const placeholder = [
227
+ '---', `name: ${skill}`, 'description: TODO — add description', 'type: standard', '---',
228
+ '', `# ${skill}`,
229
+ '', '> ⚠️ Placeholder — declared in skill-deck.toml but not yet implemented.',
230
+ '', '## TODO',
231
+ '- [ ] Define what this skill does',
232
+ '- [ ] Add usage instructions',
233
+ '- [ ] Run `deck link` to activate',
234
+ '', `Created: ${now}`, '',
235
+ ].join('\n')
236
+ mkdirSync(localPath, { recursive: true })
237
+ writeFileSync(join(localPath, 'SKILL.md'), placeholder)
238
+ console.log(`📝 Created placeholder: localhost/${skill} → ${localPath}/SKILL.md`)
239
+ result.path = localPath
240
+ }
241
+ }
242
+ if (!result.path) {
243
+ errors.push(`Skill not found: ${entry.path}`)
244
+ continue
245
+ }
221
246
  }
222
247
  declared.push({ name: entry.path, alias: entry.alias, type: entry.type, sourcePath: result.path });
223
248
  }
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
  })