@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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @lythos/skill-deck
2
2
 
3
+ ![Coverage](https://img.shields.io/badge/coverage-82%25-brightgreen)
4
+
3
5
  > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
4
6
 
5
7
  ## For AI Agents
@@ -18,8 +20,8 @@ No installation required. `bunx` auto-downloads the package.
18
20
  [deck]
19
21
  max_cards = 10
20
22
 
21
- [tool]
22
- skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
23
+ [tool.skills.lythoskill-deck]
24
+ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
23
25
  ```
24
26
 
25
27
  ### skill-deck.toml (full reference)
@@ -30,22 +32,21 @@ max_cards = 10 # Hard limit on total skills
30
32
  working_set = ".claude/skills" # Where symlinks are created
31
33
  cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
32
34
 
33
- [innate] # Always-loaded skills
34
- skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
35
+ [innate.skills.lythoskill-deck] # Always-loaded skills
36
+ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
37
+
38
+ [tool.skills.tdd] # Auto-triggered skills
39
+ path = "github.com/mattpocock/skills/skills/engineering/tdd"
35
40
 
36
- [tool] # Auto-triggered skills
37
- skills = [
38
- "github.com/mattpocock/skills/skills/engineering/tdd",
39
- "github.com/garrytan/gstack",
40
- ]
41
+ [tool.skills.gstack]
42
+ path = "github.com/garrytan/gstack"
41
43
 
42
- [combo] # Multi-skill bundles
43
- skills = ["github.com/anthropics/skills/skills/pdf"]
44
+ [combo.skills.pdf] # Multi-skill bundles
45
+ path = "github.com/anthropics/skills/skills/pdf"
44
46
 
45
- [transient] # Temporary skills with expiry
46
- [transient.handoff]
47
- path = "./skills/handoff" # Local path (not cold pool)
48
- expires = "2026-05-01" # ISO date; warns at ≤14 days
47
+ [transient.handoff] # Temporary skills with expiry
48
+ path = "./skills/handoff" # Local path (not cold pool)
49
+ expires = "2026-05-01" # ISO date; warns at ≤14 days
49
50
  ```
50
51
 
51
52
  ### When to invoke
@@ -55,7 +56,10 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
55
56
  | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck link` |
56
57
  | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck validate` |
57
58
  | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck add owner/repo` |
58
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck update` |
59
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck refresh` |
60
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck refresh tdd` |
61
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck remove tdd` |
62
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck prune` |
59
63
  | Use a custom deck file or working dir | `bunx @lythos/skill-deck link --deck ./my-deck.toml --workdir /path/to/project` |
60
64
 
61
65
  ### Commands
@@ -64,8 +68,10 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
64
68
  |---------|------|-------------|
65
69
  | `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
66
70
  | `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
67
- | `add` | `<locator> [--via <backend>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
68
- | `update` | `[--deck <path>]` | Pull latest versions of declared skills from upstream git repos. |
71
+ | `add` | `<locator> [--via <backend>] [--as <alias>] [--type <type>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
72
+ | `refresh` | `[<fq|alias>] [--deck <path>]` | Pull latest versions of declared skills from upstream git repos. Pass a name to refresh one skill. |
73
+ | `remove` | `<fq|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
74
+ | `prune` | `[--yes] [--deck <path>]` | GC cold pool repos no longer referenced. Interactive confirm (skip with `--yes`). |
69
75
 
70
76
  ### Options
71
77
 
@@ -74,6 +80,8 @@ skills = ["github.com/anthropics/skills/skills/pdf"]
74
80
  | `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
75
81
  | `--workdir <dir>` | Working directory | cwd |
76
82
  | `--via <backend>` | Download backend for `add`: `git` or `skills.sh` | `git` |
83
+ | `--as <alias>` | Explicit alias for the skill (default: basename of path) | — |
84
+ | `--type <type>` | Target section for `add`: `innate`, `tool`, or `combo` | `tool` |
77
85
 
78
86
  ### Safety guards
79
87
 
@@ -104,8 +112,8 @@ cat > skill-deck.toml << 'EOF'
104
112
  [deck]
105
113
  max_cards = 10
106
114
 
107
- [tool]
108
- skills = ["github.com/lythos-labs/lythoskill/skills/lythoskill-deck"]
115
+ [tool.skills.lythoskill-deck]
116
+ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
109
117
  EOF
110
118
 
111
119
  # 2. Link — creates symlinks in .claude/skills/
@@ -140,7 +148,8 @@ Different agents look for skills in different directories. `skill-deck.toml` con
140
148
  |---------|-------|-----|
141
149
  | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck add github.com/owner/repo/skill` or clone manually into cold pool |
142
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 |
143
- | `update` 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 |
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
+ | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
144
153
  | `link` refuses with "budget exceeded" | Declared skills > `max_cards` | Increase `max_cards` in `skill-deck.toml` or remove unused skills |
145
154
  | `link` refuses with "unsafe working_set" | `working_set` resolves to `~` or `/` | Check `skill-deck.toml` has correct relative path (e.g. `.claude/skills/`) |
146
155
  | Agent doesn't see skills after `link` | `working_set` path doesn't match agent's scan location | Claude Code: `.claude/skills/`; Cursor: `.cursor/skills/`; Kimi: check your platform docs. Set `working_set` correctly |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.7.2",
3
+ "version": "0.9.1",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -0,0 +1,117 @@
1
+ # Deck Package Coverage Gaps
2
+
3
+ > Generated from `bun test --coverage packages/lythoskill-deck/src/*.test.ts`
4
+ > Lines: 82.35% | Funcs: 73.81% | Date: 2026-05-04
5
+
6
+ ## 策略声明
7
+
8
+ 以下缺口是**故意保留**的。覆盖这些分支的意图已经看到,处理方式不是"为覆盖率改代码",而是:
9
+
10
+ 1. **提取行为后单测** — 如果某段逻辑值得验证,把它提取为纯函数,单独写 test
11
+ 2. **直接说明不覆盖** — 如果分支是纯边界防护(`catch {}`、交互式确认、外部依赖),写明原因即可
12
+ 3. **拒绝为覆盖率改写代码** — 不为了触发分支而拆散可读性高的连续逻辑
13
+
14
+ 具体不追的类别:
15
+ - `catch {}` silent ignore(fs 边界错误,测了也是 mock,无业务意义)
16
+ - 需要 >1GB 文件才能触发的分支(formatSize GB)
17
+ - 依赖外部 network 的分支(git clone 真实失败、skills.sh backend)
18
+ - 交互式 CLI 分支(prune confirm())
19
+ - 仅为凑分支覆盖率而进行的微重构(降低可读性)
20
+
21
+ ---
22
+
23
+ ## add.ts — 62.37% lines
24
+
25
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
26
+ |----|------|-----------|--------|
27
+ | 44, 46–50 | `deckPath` resolve + `findDeckToml` 失败 | CLI 路径解析,可用 spawnSync 追但成本高 | ⬜ 低优先级 |
28
+ | 55–59 | cold pool mkdir | 正常路径已覆盖,异常分支为 `catch {}` | ❌ 不追 |
29
+ | 62–71 | `targetDir` exists 错误 | 需构造 cold pool 冲突,有意义但成本中等 | ⬜ 低优先级 |
30
+ | 87–89 | `skills.sh` / `vercel` backend | 依赖外部 `npx skills` 命令,无 mock 价值 | ❌ 不追 |
31
+ | 106–108 | auto-migrate string-array → dict | 有意义,但需 mock `git clone` 全流程 | ⬜ 低优先级 |
32
+ | 123–124, 127–131 | alias resolve / `nameParts.length` | shorthand path 分支,有意义 | ⬜ 低优先级 |
33
+ | 136–141 | invalid type rejection | **已覆盖** (C9) | ✅ |
34
+ | 143–154 | git clone 成功后的 `findSkillDir` | 正常路径已覆盖 | ✅ |
35
+ | 163–164 | `--as` alias | 正常路径已覆盖 | ✅ |
36
+ | 172–174, 182–183 | error handling (catch) | `catch {}` 无意义 | ❌ 不追 |
37
+ | 206, 210 | skills.sh 路径 | 外部依赖 | ❌ 不追 |
38
+ | 221–227 | `writeFileSync` / `linkDeck` | 正常路径已覆盖 | ✅ |
39
+ | 235–240 | catch final error | `catch {}` | ❌ 不追 |
40
+
41
+ ## link.ts — 60.71% lines
42
+
43
+ link.ts 是 deck 包最大文件(~540 行), uncovered 行最多。主要缺口:
44
+
45
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
46
+ |----|------|-----------|--------|
47
+ | 103–111 | `parseSkillFrontmatter` 异常(SKILL.md 不存在/解析失败) | 有意义,需构造无 SKILL.md 的 fixture | ⬜ 低优先级 |
48
+ | 116, 119–123 | `calculateDirSize` 异常 / `readdirSync` 失败 | `catch {}` | ❌ 不追 |
49
+ | 131–142 | backup 逻辑(nonSymlink > 100MB tar) | 需构造大文件或 mock `tar`,成本高 | ❌ 不追 |
50
+ | 146–147 | backup 路径构造 | 同上 | ❌ 不追 |
51
+ | 162–171 | transient 过期警告(`days <= 14` / `days <= 0`) | 有意义,需调系统时间或 mock Date | ⬜ 低优先级 |
52
+ | 180 | managed_dirs 重叠检测 | 有意义,需构造父子目录冲突 | ⬜ 低优先级 |
53
+ | 206–211, 218–225 | `readdirSync` / `symlinkSync` 异常 | `catch {}` | ❌ 不追 |
54
+ | 237–239, 244–245 | 各种路径 resolve | 边界情况 | ❌ 不追 |
55
+ | 247–261 | `findSource` 多种匹配策略 | 部分已覆盖,剩余为罕见 fallback | ⬜ 低优先级 |
56
+ | 265–278 | budget exceeded 详细报告 | 正常路径已覆盖 | ✅ |
57
+ | 285–287, 298–300, 307–310 | 各种 `existsSync` / `lstatSync` 边界 | `catch {}` | ❌ 不追 |
58
+ | 326 | `content_hash` 计算 | 正常路径已覆盖 | ✅ |
59
+ | 334–372 | lock 文件 schema 校验 | 正常路径已覆盖 | ✅ |
60
+ | 407–408 | `readlinkSync` 异常 | `catch {}` | ❌ 不追 |
61
+ | 454–461 | `parseDeck` fallback | 正常路径已覆盖 | ✅ |
62
+ | 479–480, 488–490, 492–498 | `readdirSync` / `rmSync` 边界 | `catch {}` | ❌ 不追 |
63
+ | 524–525, 537 | 各种 cleanup / 路径 | 边界 | ❌ 不追 |
64
+
65
+ ## parse-deck.ts — 92.45% lines
66
+
67
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
68
+ |----|------|-----------|--------|
69
+ | 47–50 | legacy string-array `continue`(空 name) | 有意义但 trivial | ⬜ 低优先级 |
70
+
71
+ ## prune.ts — 80.43% lines
72
+
73
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
74
+ |----|------|-----------|--------|
75
+ | 25–27 | `formatSize` GB 分支 | 需 >1GB 文件 | ❌ 不追 |
76
+ | 74 | `calculateDirSize` catch | `catch {}` | ❌ 不追 |
77
+ | 81–87 | `scanColdPoolRepos` catch | `catch {}` | ❌ 不追 |
78
+ | 98–100 | empty cold pool | **已覆盖** (C17) | ✅ |
79
+ | 118–122 | all-referenced no-op | **已覆盖** (C16) | ✅ |
80
+ | 129–130 | failed > 0 exit(1) | 需构造删除失败(权限不足) | ❌ 不追 |
81
+ | 167 | formatSize MB 分支 | 正常路径已覆盖 | ✅ |
82
+ | 172–173 | 删除失败 catch | `catch {}` | ❌ 不追 |
83
+ | 185–186 | formatSize 边界 | 正常路径已覆盖 | ✅ |
84
+
85
+ ## refresh.ts — 88.97% lines
86
+
87
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
88
+ |----|------|-----------|--------|
89
+ | 43, 49 | deck 不存在 / `findDeckToml` 失败 | CLI 路径解析 | ⬜ 低优先级 |
90
+ | 70–71 | localhost skip | **已覆盖** (C15) | ✅ |
91
+ | 83–85 | not-git | **已覆盖** (C16) | ✅ |
92
+ | 101 | `result.error`(findSource 失败) | 有意义 | ⬜ 低优先级 |
93
+ | 109–110 | `gitRoot` null 后的 skip | **已覆盖** (C16) | ✅ |
94
+ | 125 | `pullResult.status === "failed"` | 需 mock `git pull` 返回错误 | ⬜ 低优先级 |
95
+ | 148–150 | `updated` / `upToDate` / `failed` 计数 | **已覆盖** (C12-C14) | ✅ |
96
+ | 199 | `failed > 0` exit(1) | 需构造 failed 状态 | ⬜ 低优先级 |
97
+
98
+ ## remove.ts — 91.53% lines
99
+
100
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
101
+ |----|------|-----------|--------|
102
+ | 22–24 | deck 不存在 error | CLI 路径解析 | ⬜ 低优先级 |
103
+ | 44 | legacy string-array filter | **已覆盖** (C11.b) | ✅ |
104
+
105
+ ## schema.ts — 100.00%
106
+
107
+ 全部覆盖 ✅
108
+
109
+ ---
110
+
111
+ ## 如果未来要推到 90%+
112
+
113
+ 优先级排序:
114
+ 1. **link.ts `parseSkillFrontmatter` 异常** — 有业务意义(broken SKILL.md),中等成本
115
+ 2. **link.ts transient 过期警告** — 有业务意义,需 mock Date
116
+ 3. **add.ts auto-migrate** — 有业务意义,需 mock git clone
117
+ 4. **link.ts managed_dirs 重叠** — 有业务意义,需构造目录冲突
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * add.test.ts — unit tests for add.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/add.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach, spyOn, mock } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, cpSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import * as childProcess from 'node:child_process'
13
+
14
+ // Control homedir() return value for tests that need default cold_pool under tmpdir
15
+ let mockHomeDir = '/tmp'
16
+ mock.module('node:os', () => ({
17
+ homedir: () => mockHomeDir,
18
+ }))
19
+
20
+ let cleanup: string[] = []
21
+ let execSpy: ReturnType<typeof spyOn> | null = null
22
+
23
+ afterEach(() => {
24
+ if (execSpy) {
25
+ execSpy.mockRestore()
26
+ execSpy = null
27
+ }
28
+ for (const dir of cleanup) {
29
+ rmSync(dir, { recursive: true, force: true })
30
+ }
31
+ cleanup = []
32
+ })
33
+
34
+ function makeTmp(): string {
35
+ const dir = mkdtempSync(join(tmpdir(), 'deck-add-'))
36
+ cleanup.push(dir)
37
+ return dir
38
+ }
39
+
40
+ function mockGitClone(fixturePath: string) {
41
+ const originalExec = childProcess.execFileSync
42
+ execSpy = spyOn(childProcess, 'execFileSync').mockImplementation(((cmd: string, args: string[], options?: any) => {
43
+ if (cmd === 'git' && args[0] === 'clone') {
44
+ const dest = args[args.length - 1]
45
+ cpSync(fixturePath, dest, { recursive: true })
46
+ return Buffer.from('')
47
+ }
48
+ return originalExec(cmd, args, options)
49
+ }) as any)
50
+ }
51
+
52
+ describe('addSkill', () => {
53
+ it('C6: add to empty project creates deck.toml and cold pool', async () => {
54
+ const projectDir = makeTmp()
55
+ mockHomeDir = projectDir
56
+
57
+ const fixtureDir = makeTmp()
58
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: test-skill\n---\n')
59
+
60
+ mockGitClone(fixtureDir)
61
+
62
+ // Dynamic import so add.ts picks up the mocked homedir()
63
+ const { addSkill } = await import('./add.ts')
64
+
65
+ await addSkill('github.com/owner/repo', { workdir: projectDir })
66
+
67
+ const deckPath = join(projectDir, 'skill-deck.toml')
68
+ expect(existsSync(deckPath)).toBe(true)
69
+
70
+ const deckContent = readFileSync(deckPath, 'utf-8')
71
+ expect(deckContent).toContain('[tool.skills.repo]')
72
+ expect(deckContent).toContain('path = "github.com/owner/repo"')
73
+
74
+ const coldPoolDir = join(projectDir, '.agents', 'skill-repos', 'github.com', 'owner', 'repo')
75
+ expect(existsSync(coldPoolDir)).toBe(true)
76
+ expect(existsSync(join(coldPoolDir, 'SKILL.md'))).toBe(true)
77
+ })
78
+
79
+ it('C7: add to existing deck appends entry', async () => {
80
+ const projectDir = makeTmp()
81
+ const coldPoolRel = 'cold-pool'
82
+ const coldPool = join(projectDir, coldPoolRel)
83
+ mkdirSync(coldPool, { recursive: true })
84
+
85
+ // Pre-place skill-a in cold pool
86
+ const skillADir = join(coldPool, 'github.com', 'owner', 'repo-a')
87
+ mkdirSync(skillADir, { recursive: true })
88
+ writeFileSync(join(skillADir, 'SKILL.md'), '---\nname: skill-a\n---\n')
89
+
90
+ // Create deck.toml with skill-a
91
+ const deckContent = `[deck]\nmax_cards = 10\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a"\n`
92
+ const deckPath = join(projectDir, 'skill-deck.toml')
93
+ writeFileSync(deckPath, deckContent)
94
+
95
+ // Fixture for skill-b
96
+ const fixtureDir = makeTmp()
97
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill-b\n---\n')
98
+
99
+ mockGitClone(fixtureDir)
100
+
101
+ const { addSkill } = await import('./add.ts')
102
+ await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath })
103
+
104
+ const newContent = readFileSync(deckPath, 'utf-8')
105
+ expect(newContent).toContain('[tool.skills.skill-a]')
106
+ expect(newContent).toContain('path = "github.com/owner/repo-a"')
107
+ expect(newContent).toContain('[tool.skills.repo-b]')
108
+ expect(newContent).toContain('path = "github.com/owner/repo-b"')
109
+ })
110
+
111
+ it('C8: alias collision rejects', async () => {
112
+ const projectDir = makeTmp()
113
+ const coldPoolRel = 'cold-pool'
114
+ const coldPool = join(projectDir, coldPoolRel)
115
+ mkdirSync(coldPool, { recursive: true })
116
+
117
+ const deckContent = `[deck]\nmax_cards = 10\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo-a"\n`
118
+ const deckPath = join(projectDir, 'skill-deck.toml')
119
+ writeFileSync(deckPath, deckContent)
120
+
121
+ const fixtureDir = makeTmp()
122
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill-b\n---\n')
123
+
124
+ mockGitClone(fixtureDir)
125
+
126
+ const errors: string[] = []
127
+ const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
128
+ errors.push(String(msg))
129
+ })
130
+
131
+ const originalExit = process.exit
132
+ let exitCode: number | undefined
133
+ process.exit = ((code?: number) => {
134
+ exitCode = code ?? 0
135
+ throw new Error(`EXIT:${code}`)
136
+ }) as typeof process.exit
137
+
138
+ try {
139
+ const { addSkill } = await import('./add.ts')
140
+ await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath, as: 'foo' })
141
+ expect(false).toBe(true) // should not reach here
142
+ } catch (err: any) {
143
+ expect(exitCode).toBe(1)
144
+ expect(errors.some(e => e.includes('Alias "foo" already exists'))).toBe(true)
145
+ } finally {
146
+ process.exit = originalExit
147
+ errorSpy.mockRestore()
148
+ }
149
+ })
150
+
151
+ it('C9: invalid skill type rejects', async () => {
152
+ const projectDir = makeTmp()
153
+ const coldPoolRel = 'cold-pool'
154
+ const coldPool = join(projectDir, coldPoolRel)
155
+ mkdirSync(coldPool, { recursive: true })
156
+
157
+ const fixtureDir = makeTmp()
158
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill\n---\n')
159
+
160
+ const originalExec = childProcess.execFileSync
161
+ const execSpy = spyOn(childProcess, 'execFileSync').mockImplementation(((cmd: string, args: string[], options?: any) => {
162
+ if (cmd === 'git' && args[0] === 'clone') {
163
+ const dest = args[args.length - 1]
164
+ cpSync(fixtureDir, dest, { recursive: true })
165
+ return Buffer.from('')
166
+ }
167
+ return originalExec(cmd, args, options)
168
+ }) as any)
169
+
170
+ const errors: string[] = []
171
+ const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
172
+ errors.push(String(msg))
173
+ })
174
+
175
+ const originalExit = process.exit
176
+ let exitCode: number | undefined
177
+ process.exit = ((code?: number) => {
178
+ exitCode = code ?? 0
179
+ throw new Error(`EXIT:${code}`)
180
+ }) as typeof process.exit
181
+
182
+ try {
183
+ const { addSkill } = await import('./add.ts')
184
+ await addSkill('github.com/owner/repo', { deck: join(projectDir, 'skill-deck.toml'), workdir: projectDir, type: 'invalid' })
185
+ expect(false).toBe(true)
186
+ } catch (err: any) {
187
+ expect(exitCode).toBe(1)
188
+ expect(errors.some(e => e.includes('Invalid type'))).toBe(true)
189
+ } finally {
190
+ process.exit = originalExit
191
+ errorSpy.mockRestore()
192
+ execSpy.mockRestore()
193
+ }
194
+ })
195
+ })
package/src/add.ts CHANGED
@@ -11,9 +11,10 @@ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync,
11
11
  import { mkdtempSync } from 'node:fs'
12
12
  import { tmpdir, homedir } from 'node:os'
13
13
  import { join, basename, dirname, resolve } from 'node:path'
14
- import { execSync } from 'node:child_process'
14
+ import { execFileSync } from 'node:child_process'
15
15
  import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
16
16
  import { findDeckToml, expandHome } from './link.js'
17
+ import { parseDeck } from './parse-deck.js'
17
18
 
18
19
  const CLAUDE_SKILLS_DIR = join(homedir(), '.claude', 'skills')
19
20
 
@@ -75,7 +76,7 @@ function resolvePath(p: string): string {
75
76
  return resolve(p)
76
77
  }
77
78
 
78
- export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string }) {
79
+ export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string; as?: string; type?: string }) {
79
80
  const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
80
81
  const deckPath = options.deck
81
82
  ? resolvePath(options.deck)
@@ -129,7 +130,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
129
130
  .map(e => e.name))
130
131
  : new Set<string>()
131
132
 
132
- execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
133
+ execFileSync('npx', ['skills', 'add', skillsShLocator, '-g'], { cwd: tmpDir, stdio: 'inherit' })
133
134
 
134
135
  // Detect the newly installed directory
135
136
  const afterDirs = existsSync(CLAUDE_SKILLS_DIR)
@@ -154,7 +155,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
154
155
  } else {
155
156
  const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
156
157
  console.log(`📦 Cloning: ${gitUrl}`)
157
- execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
158
+ execFileSync('git', ['clone', '--depth', '1', gitUrl, tmpRepo], { stdio: 'inherit' })
158
159
  skillSourceDir = tmpRepo
159
160
  }
160
161
 
@@ -174,31 +175,84 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
174
175
  }
175
176
 
176
177
  const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
177
- console.log(`✅ Skill ready: ${skillName}`)
178
+ const alias = options.as || skillName
179
+ const skillType = (options.type || 'tool').toLowerCase()
180
+
181
+ if (!['innate', 'tool', 'combo'].includes(skillType)) {
182
+ console.error(`❌ Invalid type: ${skillType}. Must be innate, tool, or combo.`)
183
+ process.exit(1)
184
+ }
185
+
186
+ const fqPath = parsed.skill
187
+ ? `${parsed.host}/${parsed.owner}/${parsed.repo}/${parsed.skill}`
188
+ : `${parsed.host}/${parsed.owner}/${parsed.repo}`
189
+
190
+ console.log(`✅ Skill ready: ${skillName} (alias: ${alias})`)
178
191
  console.log(` Location: ${skillDir}`)
179
192
 
193
+ // ── 写 deck.toml ────────────────────────────────────────────
194
+
180
195
  if (existsSync(deckPath)) {
181
196
  const deckRaw = readFileSync(deckPath, 'utf-8')
182
197
  const deck = parseToml(deckRaw) as any
183
- const toolSkills = deck.tool?.skills || []
184
- if (!toolSkills.includes(skillName)) {
185
- if (!deck.tool) deck.tool = {}
186
- if (!deck.tool.skills) deck.tool.skills = []
187
- deck.tool.skills.push(skillName)
188
- writeFileSync(deckPath, stringifyToml(deck))
189
- console.log(`📝 Added "${skillName}" to ${deckPath}`)
190
- } else {
191
- console.log(`📝 "${skillName}" already declared in ${deckPath}`)
198
+
199
+ // Alias collision check across all sections
200
+ const allAliases = new Set<string>()
201
+ for (const section of ['innate', 'tool', 'combo'] as const) {
202
+ const skills = deck[section]?.skills
203
+ if (skills && typeof skills === 'object' && !Array.isArray(skills)) {
204
+ for (const key of Object.keys(skills)) allAliases.add(key)
205
+ } else if (Array.isArray(skills)) {
206
+ for (const name of skills) allAliases.add(name.split('/').pop() || name)
207
+ }
208
+ }
209
+ for (const key of Object.keys(deck.transient || {})) {
210
+ allAliases.add(key)
192
211
  }
212
+ if (allAliases.has(alias)) {
213
+ console.error(`❌ Alias "${alias}" already exists in deck`)
214
+ process.exit(1)
215
+ }
216
+
217
+ // Auto-migrate old string-array format to dict
218
+ for (const section of ['innate', 'tool', 'combo'] as const) {
219
+ const sectionData = deck[section]
220
+ if (sectionData && Array.isArray(sectionData.skills)) {
221
+ const dict: Record<string, { path: string }> = {}
222
+ for (const name of sectionData.skills) {
223
+ const a = name.split('/').pop() || name
224
+ dict[a] = { path: name }
225
+ }
226
+ deck[section].skills = dict
227
+ console.log(`📝 Auto-migrated [${section}] from string-array to dict format`)
228
+ }
229
+ }
230
+
231
+ // Ensure target section exists and is dict format
232
+ if (!deck[skillType]) deck[skillType] = {}
233
+ if (!deck[skillType].skills) deck[skillType].skills = {}
234
+ if (Array.isArray(deck[skillType].skills)) {
235
+ const dict: Record<string, { path: string }> = {}
236
+ for (const name of deck[skillType].skills) {
237
+ const a = name.split('/').pop() || name
238
+ dict[a] = { path: name }
239
+ }
240
+ deck[skillType].skills = dict
241
+ }
242
+
243
+ deck[skillType].skills[alias] = { path: fqPath }
244
+ writeFileSync(deckPath, stringifyToml(deck))
245
+ console.log(`📝 Added "${alias}" to [${skillType}.skills] in ${deckPath}`)
193
246
  } else {
194
- const minimal = { deck: { max_cards: 10 }, tool: { skills: [skillName] } }
247
+ const minimal: any = { deck: { max_cards: 10 } }
248
+ minimal[skillType] = { skills: { [alias]: { path: fqPath } } }
195
249
  writeFileSync(deckPath, stringifyToml(minimal))
196
- console.log(`📝 Created ${deckPath} with "${skillName}"`)
250
+ console.log(`📝 Created ${deckPath} with "${alias}"`)
197
251
  }
198
252
 
199
253
  console.log('🔗 Running deck link...')
200
254
  const { linkDeck } = await import('./link.js')
201
- linkDeck(deckPath === join(workdir, 'skill-deck.toml') ? undefined : deckPath, workdir)
255
+ linkDeck(deckPath, workdir)
202
256
 
203
257
  } catch (err) {
204
258
  console.error(`❌ Failed to add skill: ${err}`)