@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
package/src/cli.ts
CHANGED
|
@@ -2,38 +2,53 @@
|
|
|
2
2
|
import { linkDeck } from './link.js'
|
|
3
3
|
import { validateDeck } from './validate.js'
|
|
4
4
|
import { addSkill } from './add.js'
|
|
5
|
+
import { refreshDeck } from './refresh.js'
|
|
5
6
|
import { updateDeck } from './update.js'
|
|
7
|
+
import { migrateSchema } from './migrate-schema.js'
|
|
8
|
+
import { removeSkill } from './remove.js'
|
|
9
|
+
import { pruneDeck } from './prune.js'
|
|
6
10
|
import { formatHelp } from './help.js'
|
|
7
11
|
|
|
12
|
+
const args = process.argv.slice(2)
|
|
13
|
+
const command = args[0]
|
|
14
|
+
|
|
15
|
+
const deckFlagIdx = args.indexOf('--deck')
|
|
16
|
+
const workdirFlagIdx = args.indexOf('--workdir')
|
|
17
|
+
const viaFlagIdx = args.indexOf('--via')
|
|
18
|
+
const asFlagIdx = args.indexOf('--as')
|
|
19
|
+
const typeFlagIdx = args.indexOf('--type')
|
|
20
|
+
|
|
21
|
+
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
22
|
+
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
23
|
+
const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
|
|
24
|
+
const as = asFlagIdx >= 0 ? args[asFlagIdx + 1] : undefined
|
|
25
|
+
const type = typeFlagIdx >= 0 ? args[typeFlagIdx + 1] : undefined
|
|
26
|
+
const noBackup = args.includes('--no-backup')
|
|
27
|
+
const yes = args.includes('--yes')
|
|
28
|
+
|
|
8
29
|
const HELP_CONFIG = {
|
|
9
30
|
binName: 'lythoskill-deck',
|
|
10
31
|
description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
|
|
11
32
|
commands: [
|
|
12
33
|
{ name: 'link', description: 'Sync working set with skill-deck.toml' },
|
|
13
34
|
{ name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
|
|
14
|
-
{ name: '
|
|
35
|
+
{ name: 'refresh', description: 'Pull latest versions of declared skills from upstream', args: '[<fq|alias>]' },
|
|
15
36
|
{ name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
|
|
37
|
+
{ name: 'remove', description: 'Remove a skill from deck.toml and working set', args: '<fq|alias>' },
|
|
38
|
+
{ name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
|
|
39
|
+
{ name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
|
|
16
40
|
],
|
|
17
41
|
options: [
|
|
18
42
|
{ flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
|
|
19
43
|
{ flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
|
|
20
44
|
{ flag: '--no-backup', description: 'Skip tar backup when removing non-symlink entries' },
|
|
21
45
|
{ flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
|
|
46
|
+
{ flag: '--as <alias>', description: 'Explicit alias for the skill (default: basename of path)' },
|
|
47
|
+
{ flag: '--type <type>', description: 'Target section: innate | tool | combo (default: tool)' },
|
|
48
|
+
{ flag: '--yes', description: 'Skip interactive confirmation (for prune)' },
|
|
22
49
|
],
|
|
23
50
|
}
|
|
24
51
|
|
|
25
|
-
const args = process.argv.slice(2)
|
|
26
|
-
const command = args[0]
|
|
27
|
-
|
|
28
|
-
const deckFlagIdx = args.indexOf('--deck')
|
|
29
|
-
const workdirFlagIdx = args.indexOf('--workdir')
|
|
30
|
-
const viaFlagIdx = args.indexOf('--via')
|
|
31
|
-
|
|
32
|
-
const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
|
|
33
|
-
const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
|
|
34
|
-
const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
|
|
35
|
-
const noBackup = args.includes('--no-backup')
|
|
36
|
-
|
|
37
52
|
switch (command) {
|
|
38
53
|
case '--help':
|
|
39
54
|
case '-h':
|
|
@@ -48,15 +63,49 @@ switch (command) {
|
|
|
48
63
|
console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
|
|
49
64
|
process.exit(1)
|
|
50
65
|
}
|
|
51
|
-
await addSkill(locator, { via, deck: deckPath, workdir })
|
|
66
|
+
await addSkill(locator, { via, deck: deckPath, workdir, as, type })
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
case 'refresh': {
|
|
70
|
+
const refreshTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
71
|
+
refreshDeck(deckPath, workdir, refreshTarget)
|
|
52
72
|
break
|
|
53
73
|
}
|
|
54
|
-
case 'update':
|
|
55
|
-
|
|
74
|
+
case 'update': {
|
|
75
|
+
const updateTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
76
|
+
updateDeck(deckPath, workdir, updateTarget)
|
|
56
77
|
break
|
|
78
|
+
}
|
|
57
79
|
case 'validate':
|
|
58
80
|
validateDeck(deckPath, workdir)
|
|
59
81
|
break
|
|
82
|
+
case 'remove': {
|
|
83
|
+
const removeTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
|
|
84
|
+
if (!removeTarget) {
|
|
85
|
+
console.error('❌ Missing target. Usage: deck remove <fq|alias>')
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
removeSkill(removeTarget, deckPath, workdir)
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
case 'prune': {
|
|
92
|
+
await pruneDeck(deckPath, workdir, yes)
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
case 'migrate-schema': {
|
|
96
|
+
const dryRun = args.includes('--dry-run')
|
|
97
|
+
const targetPath = deckPath || 'skill-deck.toml'
|
|
98
|
+
const result = migrateSchema(targetPath, dryRun)
|
|
99
|
+
if (result.diff) {
|
|
100
|
+
console.log(result.message)
|
|
101
|
+
console.log('---')
|
|
102
|
+
console.log(result.diff)
|
|
103
|
+
} else {
|
|
104
|
+
console.log(result.message)
|
|
105
|
+
}
|
|
106
|
+
if (!result.migrated) process.exit(0)
|
|
107
|
+
break
|
|
108
|
+
}
|
|
60
109
|
default:
|
|
61
110
|
console.error(formatHelp(HELP_CONFIG))
|
|
62
111
|
process.exit(1)
|
package/src/link.test.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* link.test.ts — unit tests for link.ts pure functions
|
|
4
|
+
*
|
|
5
|
+
* Run: bun test packages/lythoskill-deck/src/link.test.ts
|
|
6
|
+
*
|
|
7
|
+
* Co-located with src per ADR-20260503180000000 (curator-mind framework selection)
|
|
8
|
+
* and the existing precedent in packages/lythoskill-curator/src/cli.test.ts.
|
|
9
|
+
*
|
|
10
|
+
* Tests use real fs in mkdtempSync sandboxes — no mocks. Each it() owns its own
|
|
11
|
+
* tmpdir and afterEach cleans up to avoid cross-test state leakage.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, afterEach } from 'bun:test'
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, lstatSync, readlinkSync, readdirSync, existsSync, symlinkSync } from 'node:fs'
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
import { tmpdir, homedir } from 'node:os'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import { spawnSync } from 'node:child_process'
|
|
20
|
+
|
|
21
|
+
import { findDeckToml, expandHome, findSource, linkDeck } from './link.ts'
|
|
22
|
+
|
|
23
|
+
let cleanup: string[] = []
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const dir of cleanup) {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true })
|
|
28
|
+
}
|
|
29
|
+
cleanup = []
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function makeTmp(): string {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), 'deck-link-'))
|
|
34
|
+
cleanup.push(dir)
|
|
35
|
+
return dir
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function placeSkill(coldPool: string, relPath: string): string {
|
|
39
|
+
const skillDir = join(coldPool, relPath)
|
|
40
|
+
mkdirSync(skillDir, { recursive: true })
|
|
41
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
|
|
42
|
+
return skillDir
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('findDeckToml', () => {
|
|
46
|
+
it('returns absolute path when skill-deck.toml is present', () => {
|
|
47
|
+
const dir = makeTmp()
|
|
48
|
+
const tomlPath = join(dir, 'skill-deck.toml')
|
|
49
|
+
writeFileSync(tomlPath, '[[skills]]\n')
|
|
50
|
+
expect(findDeckToml(dir)).toBe(tomlPath)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns null when skill-deck.toml is absent', () => {
|
|
54
|
+
const dir = makeTmp()
|
|
55
|
+
expect(findDeckToml(dir)).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('expandHome', () => {
|
|
60
|
+
it('expands ~/<path> to homedir-anchored absolute path', () => {
|
|
61
|
+
expect(expandHome('~/foo/bar', '/anywhere')).toBe(join(homedir(), 'foo/bar'))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('resolves relative paths against base', () => {
|
|
65
|
+
expect(expandHome('foo/bar', '/some/base')).toBe(resolve('/some/base', 'foo/bar'))
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('findSource', () => {
|
|
70
|
+
it('resolves fully-qualified host.tld/owner/repo/skill via cold-pool skills/ subdir', () => {
|
|
71
|
+
const coldPool = makeTmp()
|
|
72
|
+
const projectDir = makeTmp()
|
|
73
|
+
const expected = placeSkill(coldPool, 'github.com/lythos-labs/lythoskill/skills/lythoskill-deck')
|
|
74
|
+
const result = findSource('github.com/lythos-labs/lythoskill/lythoskill-deck', coldPool, projectDir)
|
|
75
|
+
expect(result.path).toBe(expected)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('resolves a direct cold-pool hit when name matches a top-level dir with SKILL.md', () => {
|
|
79
|
+
const coldPool = makeTmp()
|
|
80
|
+
const projectDir = makeTmp()
|
|
81
|
+
const expected = placeSkill(coldPool, 'my-skill')
|
|
82
|
+
const result = findSource('my-skill', coldPool, projectDir)
|
|
83
|
+
expect(result.path).toBe(expected)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('resolves a monorepo layout (repo/skill → coldPool/repo/skills/skill)', () => {
|
|
87
|
+
const coldPool = makeTmp()
|
|
88
|
+
const projectDir = makeTmp()
|
|
89
|
+
const expected = placeSkill(coldPool, 'mono-repo/skills/inner-skill')
|
|
90
|
+
const result = findSource('mono-repo/inner-skill', coldPool, projectDir)
|
|
91
|
+
expect(result.path).toBe(expected)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('falls back to projectDir/skills/<name> for project-local skills', () => {
|
|
95
|
+
const coldPool = makeTmp()
|
|
96
|
+
const projectDir = makeTmp()
|
|
97
|
+
const expected = placeSkill(projectDir, 'skills/local-skill')
|
|
98
|
+
const result = findSource('local-skill', coldPool, projectDir)
|
|
99
|
+
expect(result.path).toBe(expected)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns {path: null} when no strategy resolves the name', () => {
|
|
103
|
+
const coldPool = makeTmp()
|
|
104
|
+
const projectDir = makeTmp()
|
|
105
|
+
const result = findSource('nonexistent-skill', coldPool, projectDir)
|
|
106
|
+
expect(result.path).toBeNull()
|
|
107
|
+
expect(result.error).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('linkDeck reconciler', () => {
|
|
112
|
+
it('B1.tracer: empty deck creates working set and lock with zero skills', () => {
|
|
113
|
+
const projectDir = makeTmp()
|
|
114
|
+
const coldPoolRel = 'cold-pool'
|
|
115
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
116
|
+
mkdirSync(coldPool, { recursive: true })
|
|
117
|
+
|
|
118
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n`
|
|
119
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
120
|
+
writeFileSync(deckPath, deckContent)
|
|
121
|
+
|
|
122
|
+
linkDeck(deckPath, projectDir, true)
|
|
123
|
+
|
|
124
|
+
const workingSet = join(projectDir, '.claude', 'skills')
|
|
125
|
+
expect(existsSync(workingSet)).toBe(true)
|
|
126
|
+
expect(lstatSync(workingSet).isDirectory()).toBe(true)
|
|
127
|
+
|
|
128
|
+
const lockPath = join(projectDir, 'skill-deck.lock')
|
|
129
|
+
expect(existsSync(lockPath)).toBe(true)
|
|
130
|
+
|
|
131
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'))
|
|
132
|
+
expect(lock.version).toBe('1.0.0')
|
|
133
|
+
expect(lock.skills).toEqual([])
|
|
134
|
+
expect(lock.constraints.total_cards).toBe(0)
|
|
135
|
+
expect(lock.constraints.max_cards).toBe(10)
|
|
136
|
+
expect(lock.constraints.within_budget).toBe(true)
|
|
137
|
+
expect(lock.working_set).toBe('.claude/skills')
|
|
138
|
+
expect(lock.cold_pool).toBe(coldPoolRel)
|
|
139
|
+
|
|
140
|
+
const expectedHash = createHash('sha256').update(deckContent).digest('hex')
|
|
141
|
+
expect(lock.deck_source.content_hash).toBe(expectedHash)
|
|
142
|
+
expect(lock.deck_source.path).toBe('skill-deck.toml')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('B2: declared skill with existing cold pool creates correct symlink', () => {
|
|
146
|
+
const projectDir = makeTmp()
|
|
147
|
+
const coldPoolRel = 'cold-pool'
|
|
148
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
149
|
+
|
|
150
|
+
const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill')
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(skillDir, 'SKILL.md'),
|
|
153
|
+
'---\nname: test-skill\ndeck_niche: testing\ndeck_managed_dirs: ["docs/"]\n---\n'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
|
|
157
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
158
|
+
writeFileSync(deckPath, deckContent)
|
|
159
|
+
|
|
160
|
+
linkDeck(deckPath, projectDir, true)
|
|
161
|
+
|
|
162
|
+
const workingSet = join(projectDir, '.claude', 'skills')
|
|
163
|
+
const symlinkPath = join(workingSet, 'my-alias')
|
|
164
|
+
|
|
165
|
+
expect(existsSync(symlinkPath)).toBe(true)
|
|
166
|
+
expect(lstatSync(symlinkPath).isSymbolicLink()).toBe(true)
|
|
167
|
+
|
|
168
|
+
const target = readlinkSync(symlinkPath)
|
|
169
|
+
expect(target).toBe(skillDir)
|
|
170
|
+
|
|
171
|
+
const lock = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
|
|
172
|
+
expect(lock.skills).toHaveLength(1)
|
|
173
|
+
|
|
174
|
+
const skill = lock.skills[0]
|
|
175
|
+
expect(skill.name).toBe('github.com/owner/repo/skill')
|
|
176
|
+
expect(skill.alias).toBe('my-alias')
|
|
177
|
+
expect(skill.type).toBe('tool')
|
|
178
|
+
expect(skill.source).toBe(join('github.com', 'owner', 'repo', 'skill'))
|
|
179
|
+
expect(skill.dest).toBe(join('.claude', 'skills', 'my-alias'))
|
|
180
|
+
expect(skill.content_hash).toMatch(/^[a-f0-9]{64}$/)
|
|
181
|
+
expect(skill.deck_niche).toBe('testing')
|
|
182
|
+
expect(skill.deck_managed_dirs).toEqual(['docs/'])
|
|
183
|
+
expect(skill.linked_at).toBeDefined()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('B2.b: idempotent re-run preserves symlink state', async () => {
|
|
187
|
+
const projectDir = makeTmp()
|
|
188
|
+
const coldPoolRel = 'cold-pool'
|
|
189
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
190
|
+
|
|
191
|
+
const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill')
|
|
192
|
+
|
|
193
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
|
|
194
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
195
|
+
writeFileSync(deckPath, deckContent)
|
|
196
|
+
|
|
197
|
+
linkDeck(deckPath, projectDir, true)
|
|
198
|
+
|
|
199
|
+
const lock1 = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
|
|
200
|
+
const symlinkPath = join(projectDir, '.claude', 'skills', 'my-alias')
|
|
201
|
+
const target1 = readlinkSync(symlinkPath)
|
|
202
|
+
|
|
203
|
+
await new Promise(r => setTimeout(r, 50))
|
|
204
|
+
|
|
205
|
+
linkDeck(deckPath, projectDir, true)
|
|
206
|
+
|
|
207
|
+
const lock2 = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
|
|
208
|
+
const target2 = readlinkSync(symlinkPath)
|
|
209
|
+
|
|
210
|
+
const entries = readdirSync(join(projectDir, '.claude', 'skills'))
|
|
211
|
+
.filter(e => !e.startsWith('.') && !e.startsWith('_'))
|
|
212
|
+
expect(entries).toHaveLength(1)
|
|
213
|
+
expect(target2).toBe(target1)
|
|
214
|
+
expect(target2).toBe(skillDir)
|
|
215
|
+
|
|
216
|
+
expect(lock2.generated_at).not.toBe(lock1.generated_at)
|
|
217
|
+
|
|
218
|
+
expect(lock2.skills).toHaveLength(1)
|
|
219
|
+
expect(lock2.skills[0].alias).toBe(lock1.skills[0].alias)
|
|
220
|
+
expect(lock2.skills[0].source).toBe(lock1.skills[0].source)
|
|
221
|
+
expect(lock2.skills[0].dest).toBe(lock1.skills[0].dest)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('B3: deny-by-default removes undeclared symlinks from working set', () => {
|
|
225
|
+
const projectDir = makeTmp()
|
|
226
|
+
const coldPoolRel = 'cold-pool'
|
|
227
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
228
|
+
|
|
229
|
+
const skillADir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
|
|
230
|
+
const skillBDir = placeSkill(coldPool, 'github.com/owner/repo/skill-b')
|
|
231
|
+
|
|
232
|
+
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`
|
|
233
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
234
|
+
writeFileSync(deckPath, deckContent)
|
|
235
|
+
|
|
236
|
+
// Pre-populate working set with both skill-a (declared) and skill-b (undeclared)
|
|
237
|
+
const workingSet = join(projectDir, '.claude', 'skills')
|
|
238
|
+
mkdirSync(workingSet, { recursive: true })
|
|
239
|
+
symlinkSync(skillADir, join(workingSet, 'skill-a'))
|
|
240
|
+
symlinkSync(skillBDir, join(workingSet, 'skill-b'))
|
|
241
|
+
|
|
242
|
+
linkDeck(deckPath, projectDir, true)
|
|
243
|
+
|
|
244
|
+
// skill-a should remain
|
|
245
|
+
expect(existsSync(join(workingSet, 'skill-a'))).toBe(true)
|
|
246
|
+
expect(lstatSync(join(workingSet, 'skill-a')).isSymbolicLink()).toBe(true)
|
|
247
|
+
|
|
248
|
+
// skill-b should be removed
|
|
249
|
+
expect(existsSync(join(workingSet, 'skill-b'))).toBe(false)
|
|
250
|
+
|
|
251
|
+
const lock = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
|
|
252
|
+
expect(lock.skills).toHaveLength(1)
|
|
253
|
+
expect(lock.skills[0].alias).toBe('skill-a')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('B4: same-type alias collision exits with fatal error', () => {
|
|
257
|
+
const projectDir = makeTmp()
|
|
258
|
+
const coldPoolRel = 'cold-pool'
|
|
259
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
260
|
+
placeSkill(coldPool, 'github.com/owner-a/repo/foo')
|
|
261
|
+
placeSkill(coldPool, 'github.com/owner-b/repo/foo')
|
|
262
|
+
|
|
263
|
+
// Legacy string-array format: two skills with same basename
|
|
264
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner-a/repo/foo", "github.com/owner-b/repo/foo"]\n`
|
|
265
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
266
|
+
writeFileSync(deckPath, deckContent)
|
|
267
|
+
|
|
268
|
+
const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
|
|
269
|
+
cwd: projectDir,
|
|
270
|
+
encoding: 'utf-8',
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(result.status).toBe(1)
|
|
274
|
+
expect(result.stderr).toContain('Alias collision')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('B4.b: cross-type alias collision exits with fatal error', () => {
|
|
278
|
+
const projectDir = makeTmp()
|
|
279
|
+
const coldPoolRel = 'cold-pool'
|
|
280
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
281
|
+
placeSkill(coldPool, 'github.com/owner-a/repo/foo')
|
|
282
|
+
placeSkill(coldPool, 'github.com/owner-b/repo/foo')
|
|
283
|
+
|
|
284
|
+
const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[innate.skills.foo]\npath = "github.com/owner-a/repo/foo"\n\n[tool.skills.foo]\npath = "github.com/owner-b/repo/foo"\n`
|
|
285
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
286
|
+
writeFileSync(deckPath, deckContent)
|
|
287
|
+
|
|
288
|
+
const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
|
|
289
|
+
cwd: projectDir,
|
|
290
|
+
encoding: 'utf-8',
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
expect(result.status).toBe(1)
|
|
294
|
+
expect(result.stderr).toContain('Alias collision')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('B5: max_cards exceeded exits before modifying working set', () => {
|
|
298
|
+
const projectDir = makeTmp()
|
|
299
|
+
const coldPoolRel = 'cold-pool'
|
|
300
|
+
const coldPool = join(projectDir, coldPoolRel)
|
|
301
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill-a')
|
|
302
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill-b')
|
|
303
|
+
placeSkill(coldPool, 'github.com/owner/repo/skill-c')
|
|
304
|
+
|
|
305
|
+
const deckContent = `[deck]\nmax_cards = 2\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\n[tool.skills.skill-c]\npath = "github.com/owner/repo/skill-c"\n`
|
|
306
|
+
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
307
|
+
writeFileSync(deckPath, deckContent)
|
|
308
|
+
|
|
309
|
+
const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
|
|
310
|
+
cwd: projectDir,
|
|
311
|
+
encoding: 'utf-8',
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(result.status).toBe(1)
|
|
315
|
+
expect(result.stderr).toContain('Budget exceeded')
|
|
316
|
+
|
|
317
|
+
// Working set should not be created (fail-fast before mkdir)
|
|
318
|
+
expect(existsSync(join(projectDir, '.claude', 'skills'))).toBe(false)
|
|
319
|
+
})
|
|
320
|
+
})
|
package/src/link.ts
CHANGED
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
existsSync, mkdirSync, readFileSync, readdirSync,
|
|
15
15
|
symlinkSync, lstatSync, rmSync, statSync, writeFileSync,
|
|
16
16
|
} from "node:fs";
|
|
17
|
-
import {
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
18
|
import { resolve, dirname, join, basename, relative } from "node:path";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import {
|
|
21
21
|
SkillDeckLockSchema,
|
|
22
22
|
type SkillDeckLock, type LinkedSkill, type ConstraintReport,
|
|
23
23
|
} from "./schema.js";
|
|
24
|
+
import { parseDeck } from "./parse-deck.js";
|
|
24
25
|
|
|
25
26
|
// ── 路径工具 ────────────────────────────────────────────────
|
|
26
27
|
|
|
@@ -173,44 +174,47 @@ if (!existsSync(DECK_PATH)) {
|
|
|
173
174
|
const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
|
|
174
175
|
const deckRaw = readFileSync(DECK_PATH, "utf-8");
|
|
175
176
|
const deckHash = hashContent(deckRaw);
|
|
176
|
-
const deck = parseToml(deckRaw) as any;
|
|
177
177
|
|
|
178
|
-
const
|
|
179
|
-
|
|
178
|
+
const { entries: parsedEntries, deprecated: isDeprecated, errors: parseErrors } = parseDeck(deckRaw);
|
|
179
|
+
if (isDeprecated) {
|
|
180
|
+
console.warn("⚠️ Deprecation: string-array skill entries are deprecated. Run `deck migrate-schema` to upgrade.");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsedToml = parseToml(deckRaw) as any;
|
|
184
|
+
const WORKING_SET_RAW = parsedToml.deck?.working_set || ".claude/skills";
|
|
185
|
+
const COLD_POOL_RAW = parsedToml.deck?.cold_pool || "~/.agents/skill-repos";
|
|
180
186
|
const WORKING_SET = expandHome(WORKING_SET_RAW, PROJECT_DIR);
|
|
181
187
|
const COLD_POOL = expandHome(COLD_POOL_RAW, PROJECT_DIR);
|
|
182
|
-
const MAX_CARDS = Number(
|
|
188
|
+
const MAX_CARDS = Number(parsedToml.deck?.max_cards || 10);
|
|
183
189
|
|
|
184
190
|
// ── 收集声明 ────────────────────────────────────────────────
|
|
185
191
|
|
|
186
192
|
interface DeclaredSkill {
|
|
187
|
-
name: string;
|
|
193
|
+
name: string; // original path/name (for lock.source backward-compat)
|
|
194
|
+
alias: string; // working-set flat symlink name
|
|
188
195
|
type: "innate" | "tool" | "combo" | "transient";
|
|
189
196
|
sourcePath: string;
|
|
190
197
|
expires?: string;
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
const declared: DeclaredSkill[] = [];
|
|
194
|
-
const errors: string[] = [];
|
|
195
|
-
|
|
196
|
-
for (const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
errors.push(`Skill not found: ${name}`);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
declared.push({ name, type: section, sourcePath: result.path });
|
|
201
|
+
const errors: string[] = [...parseErrors];
|
|
202
|
+
|
|
203
|
+
for (const entry of parsedEntries) {
|
|
204
|
+
const result = findSource(entry.path, COLD_POOL, PROJECT_DIR);
|
|
205
|
+
if (result.error) {
|
|
206
|
+
errors.push(result.error);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!result.path) {
|
|
210
|
+
errors.push(`Skill not found: ${entry.path}`);
|
|
211
|
+
continue;
|
|
209
212
|
}
|
|
213
|
+
declared.push({ name: entry.path, alias: entry.alias, type: entry.type, sourcePath: result.path });
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
// transient: sub-tables with path field
|
|
213
|
-
for (const [key, value] of Object.entries(
|
|
216
|
+
// transient: sub-tables with path field (kept backward-compat; future ADR may unify)
|
|
217
|
+
for (const [key, value] of Object.entries(parsedToml.transient || {})) {
|
|
214
218
|
const t = value as any;
|
|
215
219
|
if (!t?.path) continue;
|
|
216
220
|
const src = resolve(PROJECT_DIR, t.path);
|
|
@@ -218,7 +222,22 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
|
|
|
218
222
|
errors.push(`Transient path does not exist: ${key} → ${src}`);
|
|
219
223
|
continue;
|
|
220
224
|
}
|
|
221
|
-
declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
225
|
+
declared.push({ name: key, alias: key, type: "transient", sourcePath: src, expires: t.expires });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 跨 type alias collision 检测 ──────────────────────────────
|
|
229
|
+
const aliasToTypes = new Map<string, string[]>();
|
|
230
|
+
for (const d of declared) {
|
|
231
|
+
const types = aliasToTypes.get(d.alias) || [];
|
|
232
|
+
types.push(d.type);
|
|
233
|
+
aliasToTypes.set(d.alias, types);
|
|
234
|
+
}
|
|
235
|
+
for (const [alias, types] of aliasToTypes) {
|
|
236
|
+
if (types.length > 1) {
|
|
237
|
+
errors.push(
|
|
238
|
+
`Alias collision: "${alias}" appears in [${types.join('], [')}]. Use --as to specify different aliases.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
if (errors.length > 0) {
|
|
@@ -242,6 +261,12 @@ if (errors.length > 0) {
|
|
|
242
261
|
}
|
|
243
262
|
// 继续执行已找到的 skill,不因个别缺失中断全部
|
|
244
263
|
|
|
264
|
+
// fatal errors: alias collision must block
|
|
265
|
+
const fatalErrors = errors.filter(e => e.includes('Alias collision'));
|
|
266
|
+
if (fatalErrors.length > 0) {
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
// 引导:如果 cold pool 为空,给出更明确的指引
|
|
246
271
|
const hasSkills = existsSync(COLD_POOL) && readdirSync(COLD_POOL).filter(e => !e.startsWith('.')).length > 0;
|
|
247
272
|
if (!hasSkills) {
|
|
@@ -328,7 +353,7 @@ if (nonSymlinks.length > 0) {
|
|
|
328
353
|
...nonSymlinks.map(e => relative(PROJECT_DIR, join(WORKING_SET, e))),
|
|
329
354
|
];
|
|
330
355
|
try {
|
|
331
|
-
|
|
356
|
+
execFileSync("tar", tarArgs, {
|
|
332
357
|
cwd: PROJECT_DIR,
|
|
333
358
|
stdio: "pipe",
|
|
334
359
|
});
|
|
@@ -348,7 +373,7 @@ if (nonSymlinks.length > 0) {
|
|
|
348
373
|
}
|
|
349
374
|
|
|
350
375
|
// 清理未声明的 symlink
|
|
351
|
-
const declaredNames = new Set(declared.map(d => d.
|
|
376
|
+
const declaredNames = new Set(declared.map(d => d.alias));
|
|
352
377
|
try {
|
|
353
378
|
for (const entry of readdirSync(WORKING_SET)) {
|
|
354
379
|
if (entry.startsWith("_") || entry.startsWith(".")) continue;
|
|
@@ -368,7 +393,7 @@ try {
|
|
|
368
393
|
const linkedSkills: LinkedSkill[] = [];
|
|
369
394
|
|
|
370
395
|
for (const item of declared) {
|
|
371
|
-
const dest = join(WORKING_SET, item.
|
|
396
|
+
const dest = join(WORKING_SET, item.alias);
|
|
372
397
|
|
|
373
398
|
// 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
|
|
374
399
|
try {
|
|
@@ -380,7 +405,7 @@ for (const item of declared) {
|
|
|
380
405
|
mkdirSync(dirname(dest), { recursive: true });
|
|
381
406
|
symlinkSync(item.sourcePath, dest);
|
|
382
407
|
} catch (err: any) {
|
|
383
|
-
console.error(`❌ Link failed: ${item.
|
|
408
|
+
console.error(`❌ Link failed: ${item.alias}: ${err.message}`);
|
|
384
409
|
continue;
|
|
385
410
|
}
|
|
386
411
|
|
|
@@ -405,6 +430,7 @@ for (const item of declared) {
|
|
|
405
430
|
|
|
406
431
|
linkedSkills.push({
|
|
407
432
|
name: item.name,
|
|
433
|
+
alias: item.alias,
|
|
408
434
|
deck_niche: niche,
|
|
409
435
|
type: item.type,
|
|
410
436
|
source: sourceRel,
|
|
@@ -415,7 +441,7 @@ for (const item of declared) {
|
|
|
415
441
|
deck_managed_dirs: managedDirs,
|
|
416
442
|
});
|
|
417
443
|
|
|
418
|
-
console.log(` 🔗 ${item.
|
|
444
|
+
console.log(` 🔗 ${item.alias}`);
|
|
419
445
|
}
|
|
420
446
|
|
|
421
447
|
// ── Transient 过期检查 ──────────────────────────────────────
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parse as parseToml, stringify } from "@iarna/toml";
|
|
3
|
+
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
4
|
+
import { parseDeck } from "./parse-deck.js";
|
|
5
|
+
|
|
6
|
+
export interface MigrateResult {
|
|
7
|
+
migrated: boolean;
|
|
8
|
+
message: string;
|
|
9
|
+
diff?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function migrateSchema(deckPath: string, dryRun: boolean): MigrateResult {
|
|
13
|
+
const raw = readFileSync(deckPath, "utf-8");
|
|
14
|
+
const { deprecated } = parseDeck(raw);
|
|
15
|
+
|
|
16
|
+
if (!deprecated) {
|
|
17
|
+
return {
|
|
18
|
+
migrated: false,
|
|
19
|
+
message: "No migration needed: deck.toml already uses alias-as-key dict schema.",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parsed = parseToml(raw) as any;
|
|
24
|
+
|
|
25
|
+
for (const section of ["innate", "tool", "combo"] as const) {
|
|
26
|
+
const skills = parsed[section]?.skills;
|
|
27
|
+
if (!Array.isArray(skills)) continue;
|
|
28
|
+
|
|
29
|
+
delete parsed[section].skills;
|
|
30
|
+
const entries: Record<string, { path: string }> = {};
|
|
31
|
+
for (const name of skills) {
|
|
32
|
+
if (!name || typeof name !== "string") continue;
|
|
33
|
+
const alias = name.split("/").pop() || name;
|
|
34
|
+
entries[alias] = { path: name };
|
|
35
|
+
}
|
|
36
|
+
parsed[section] = { skills: entries };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const newToml = stringify(parsed);
|
|
40
|
+
|
|
41
|
+
if (dryRun) {
|
|
42
|
+
return {
|
|
43
|
+
migrated: true,
|
|
44
|
+
message: `Dry run — would migrate ${deckPath} to alias-as-key dict schema:`,
|
|
45
|
+
diff: newToml,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
50
|
+
const backupPath = `${deckPath}.bak.${ts}`;
|
|
51
|
+
renameSync(deckPath, backupPath);
|
|
52
|
+
writeFileSync(deckPath, newToml);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
migrated: true,
|
|
56
|
+
message: `Migrated ${deckPath} → alias-as-key dict schema. Backup: ${backupPath}`,
|
|
57
|
+
};
|
|
58
|
+
}
|