@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 +11 -11
- package/package.json +1 -1
- package/src/add.ts +33 -1
- package/src/cli.ts +3 -1
- package/src/link.ts +28 -3
- package/src/prune.test.ts +14 -1
- package/src/prune.ts +1 -1
- package/src/refresh-plan.test.ts +158 -2
- package/src/refresh.test.ts +11 -254
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.
|
|
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.
|
|
57
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.
|
|
58
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.
|
|
59
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.
|
|
60
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.
|
|
61
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.
|
|
62
|
-
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.
|
|
63
|
-
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.
|
|
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.
|
|
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.
|
|
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
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
|
|
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
|
-
|
|
220
|
-
|
|
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,
|
|
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`;
|
package/src/refresh-plan.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test'
|
|
2
|
-
import { mkdirSync,
|
|
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
|
+
})
|
package/src/refresh.test.ts
CHANGED
|
@@ -2,265 +2,22 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* refresh.test.ts — unit tests for refresh.ts helpers
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
|
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('
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
})
|