@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/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: 'update', description: 'Pull latest versions of declared skills from upstream' },
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
- updateDeck(deckPath, workdir)
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)
@@ -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 { execSync } from "node:child_process";
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 WORKING_SET_RAW = deck.deck?.working_set || ".claude/skills";
179
- const COLD_POOL_RAW = deck.deck?.cold_pool || "~/.agents/skill-repos";
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(deck.deck?.max_cards || 10);
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 section of ["innate", "tool", "combo"] as const) {
197
- for (const name of (deck[section]?.skills || [])) {
198
- if (!name || typeof name !== "string") continue;
199
- const result = findSource(name, COLD_POOL, PROJECT_DIR);
200
- if (result.error) {
201
- errors.push(result.error);
202
- continue;
203
- }
204
- if (!result.path) {
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(deck.transient || {})) {
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
- execSync("tar " + tarArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" "), {
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.name.split("/")[0]));
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.name);
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.name}: ${err.message}`);
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.name}`);
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
+ }