@lythos/skill-deck 0.9.0 → 0.9.2
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 +2 -0
- package/package.json +1 -1
- package/src/COVERAGE-GAPS.md +117 -0
- package/src/add.test.ts +195 -0
- package/src/add.ts +1 -1
- package/src/link.test.ts +320 -0
- package/src/link.ts +9 -0
- package/src/parse-deck.test.ts +53 -0
- package/src/prune.test.ts +137 -0
- package/src/prune.ts +14 -0
- package/src/refresh.test.ts +266 -0
- package/src/refresh.ts +36 -6
- package/src/remove.test.ts +145 -0
- 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
|
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
|
@@ -252,7 +252,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
|
|
|
252
252
|
|
|
253
253
|
console.log('🔗 Running deck link...')
|
|
254
254
|
const { linkDeck } = await import('./link.js')
|
|
255
|
-
linkDeck(deckPath
|
|
255
|
+
linkDeck(deckPath, workdir)
|
|
256
256
|
|
|
257
257
|
} catch (err) {
|
|
258
258
|
console.error(`❌ Failed to add skill: ${err}`)
|
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
|
@@ -79,6 +79,15 @@ export function findSource(name: string, coldPool: string, projectDir: string):
|
|
|
79
79
|
if (existsSync(join(directPath, "SKILL.md"))) return { path: directPath };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// 0.5 localhost skills: localhost/skill → cold_pool/skill
|
|
83
|
+
if (name.startsWith('localhost/')) {
|
|
84
|
+
const skill = name.slice('localhost/'.length);
|
|
85
|
+
if (skill) {
|
|
86
|
+
const localPath = join(coldPool, skill);
|
|
87
|
+
if (existsSync(join(localPath, "SKILL.md"))) return { path: localPath };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
// 1. 直接路径
|
|
83
92
|
const direct = resolve(coldPool, name);
|
|
84
93
|
if (existsSync(join(direct, "SKILL.md"))) return { path: direct };
|