@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/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @lythos/skill-deck
|
|
2
2
|
|
|
3
|
+

|
|
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
|
-
|
|
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]
|
|
34
|
-
|
|
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]
|
|
37
|
-
|
|
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]
|
|
43
|
-
|
|
44
|
+
[combo.skills.pdf] # Multi-skill bundles
|
|
45
|
+
path = "github.com/anthropics/skills/skills/pdf"
|
|
44
46
|
|
|
45
|
-
[transient] # Temporary skills with expiry
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
@@ -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 重叠** — 有业务意义,需构造目录冲突
|
package/src/add.test.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
deck
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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 }
|
|
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 "${
|
|
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
|
|
255
|
+
linkDeck(deckPath, workdir)
|
|
202
256
|
|
|
203
257
|
} catch (err) {
|
|
204
258
|
console.error(`❌ Failed to add skill: ${err}`)
|