@lythos/skill-deck 0.9.49 → 0.9.51
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 +16 -16
- package/package.json +1 -1
- package/src/add.test.ts +62 -302
- package/src/add.ts +72 -30
package/README.md
CHANGED
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# Add a skill from skills.sh (owner/repo syntax — no conversion needed)
|
|
11
|
-
bunx @lythos/skill-deck@0.9.
|
|
11
|
+
bunx @lythos/skill-deck@0.9.51 add vercel-labs/agent-skills
|
|
12
12
|
|
|
13
13
|
# Or with @skill filter (same as npx skills add):
|
|
14
|
-
bunx @lythos/skill-deck@0.9.
|
|
14
|
+
bunx @lythos/skill-deck@0.9.51 add mattpocock/skills@tdd
|
|
15
15
|
|
|
16
16
|
# Or FQ locator:
|
|
17
|
-
bunx @lythos/skill-deck@0.9.
|
|
17
|
+
bunx @lythos/skill-deck@0.9.51 add github.com/anthropics/skills/skills/frontend-design
|
|
18
18
|
|
|
19
19
|
# Sync working set (deny-by-default):
|
|
20
|
-
bunx @lythos/skill-deck@0.9.
|
|
20
|
+
bunx @lythos/skill-deck@0.9.51 link
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## For AI Agents
|
|
@@ -25,7 +25,7 @@ bunx @lythos/skill-deck@0.9.49 link
|
|
|
25
25
|
This package exposes a **CLI**. Invoke via:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
bunx @lythos/skill-deck@0.9.
|
|
28
|
+
bunx @lythos/skill-deck@0.9.51 <command> [options]
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
No installation required. `bunx` auto-downloads the package.
|
|
@@ -73,15 +73,15 @@ prompt = "Search for latest info, then generate professional document with diagr
|
|
|
73
73
|
|
|
74
74
|
| Situation | Command |
|
|
75
75
|
|-----------|---------|
|
|
76
|
-
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.
|
|
77
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.
|
|
78
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.
|
|
79
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.
|
|
80
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.
|
|
81
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.
|
|
82
|
-
| Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.
|
|
83
|
-
| Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.
|
|
84
|
-
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.
|
|
76
|
+
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.51 link` |
|
|
77
|
+
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.51 validate` |
|
|
78
|
+
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.51 add owner/repo` |
|
|
79
|
+
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.51 refresh` |
|
|
80
|
+
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.51 refresh tdd` |
|
|
81
|
+
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.51 remove tdd` |
|
|
82
|
+
| Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.51 to-symlink tdd` |
|
|
83
|
+
| Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.51 to-snapshot tdd` |
|
|
84
|
+
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.51 link --deck ./my-deck.toml --workdir /path/to/project` |
|
|
85
85
|
|
|
86
86
|
### Commands
|
|
87
87
|
|
|
@@ -143,7 +143,7 @@ source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-
|
|
|
143
143
|
EOF
|
|
144
144
|
|
|
145
145
|
# 2. Link — creates symlinks in .claude/skills/
|
|
146
|
-
bunx @lythos/skill-deck@0.9.
|
|
146
|
+
bunx @lythos/skill-deck@0.9.51 link
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
### Key Concepts
|
|
@@ -229,7 +229,7 @@ Caution: deck's deny-by-default will remove any skills not declared in your deck
|
|
|
229
229
|
|
|
230
230
|
| Symptom | Cause | Fix |
|
|
231
231
|
|---------|-------|-----|
|
|
232
|
-
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.
|
|
232
|
+
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.51 add github.com/owner/repo/skill` or clone manually into cold pool |
|
|
233
233
|
| `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 |
|
|
234
234
|
| `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 |
|
|
235
235
|
| `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
|
package/package.json
CHANGED
package/src/add.test.ts
CHANGED
|
@@ -18,366 +18,126 @@ mock.module('node:os', () => ({
|
|
|
18
18
|
homedir: () => mockHomeDir,
|
|
19
19
|
}))
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
let execSpy: ReturnType<typeof spyOn> | null = null
|
|
21
|
+
// ── findSkillDir ───────────────────────────────────────────────
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
execSpy.mockRestore()
|
|
27
|
-
execSpy = null
|
|
28
|
-
}
|
|
29
|
-
for (const dir of cleanup) {
|
|
30
|
-
rmSync(dir, { recursive: true, force: true })
|
|
31
|
-
}
|
|
32
|
-
cleanup = []
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
function makeTmp(): string {
|
|
36
|
-
const dir = mkdtempSync(join(tmpdir(), 'deck-add-'))
|
|
37
|
-
cleanup.push(dir)
|
|
23
|
+
function makeRepo(): string {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), 'deck-skill-repo-'))
|
|
38
25
|
return dir
|
|
39
26
|
}
|
|
40
27
|
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
cpSync(fixturePath, dest, { recursive: true })
|
|
47
|
-
return Buffer.from('')
|
|
48
|
-
}
|
|
49
|
-
return originalExec(cmd, args, options)
|
|
50
|
-
}) as any)
|
|
28
|
+
function makeSkillDir(repo: string, skillName: string): string {
|
|
29
|
+
const d = join(repo, skillName)
|
|
30
|
+
mkdirSync(d, { recursive: true })
|
|
31
|
+
writeFileSync(join(d, 'SKILL.md'), `---\nname: ${skillName}\ndescription: test\n---\n\n# ${skillName}\n`)
|
|
32
|
+
return d
|
|
51
33
|
}
|
|
52
34
|
|
|
53
|
-
describe('addSkill', () => {
|
|
54
|
-
it('C6: add to empty project creates deck.toml and cold pool', async () => {
|
|
55
|
-
const projectDir = makeTmp()
|
|
56
|
-
mockHomeDir = projectDir
|
|
57
|
-
|
|
58
|
-
const fixtureDir = makeTmp()
|
|
59
|
-
writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: test-skill\n---\n')
|
|
60
|
-
|
|
61
|
-
mockGitClone(fixtureDir)
|
|
62
|
-
|
|
63
|
-
// Dynamic import so add.ts picks up the mocked homedir()
|
|
64
|
-
const { addSkill } = await import('./add.ts')
|
|
65
|
-
|
|
66
|
-
await addSkill('github.com/owner/repo', { workdir: projectDir })
|
|
67
|
-
|
|
68
|
-
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
69
|
-
expect(existsSync(deckPath)).toBe(true)
|
|
70
|
-
|
|
71
|
-
const deckContent = readFileSync(deckPath, 'utf-8')
|
|
72
|
-
expect(deckContent).toContain('[tool.skills.repo]')
|
|
73
|
-
expect(deckContent).toContain('path = "github.com/owner/repo"')
|
|
74
|
-
|
|
75
|
-
const coldPoolDir = join(projectDir, '.agents', 'skill-repos', 'github.com', 'owner', 'repo')
|
|
76
|
-
expect(existsSync(coldPoolDir)).toBe(true)
|
|
77
|
-
expect(existsSync(join(coldPoolDir, 'SKILL.md'))).toBe(true)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('C7: add to existing deck appends entry', async () => {
|
|
81
|
-
const projectDir = makeTmp()
|
|
82
|
-
const coldPoolRel = 'cold-pool'
|
|
83
|
-
const coldPool = join(projectDir, coldPoolRel)
|
|
84
|
-
mkdirSync(coldPool, { recursive: true })
|
|
85
|
-
|
|
86
|
-
// Pre-place skill-a in cold pool
|
|
87
|
-
const skillADir = join(coldPool, 'github.com', 'owner', 'repo-a')
|
|
88
|
-
mkdirSync(skillADir, { recursive: true })
|
|
89
|
-
writeFileSync(join(skillADir, 'SKILL.md'), '---\nname: skill-a\n---\n')
|
|
90
|
-
|
|
91
|
-
// Create deck.toml with skill-a
|
|
92
|
-
const deckContent = `[deck]\nmax_cards = 10\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a"\n`
|
|
93
|
-
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
94
|
-
writeFileSync(deckPath, deckContent)
|
|
95
|
-
|
|
96
|
-
// Fixture for skill-b
|
|
97
|
-
const fixtureDir = makeTmp()
|
|
98
|
-
writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill-b\n---\n')
|
|
99
|
-
|
|
100
|
-
mockGitClone(fixtureDir)
|
|
101
|
-
|
|
102
|
-
const { addSkill } = await import('./add.ts')
|
|
103
|
-
await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath })
|
|
104
|
-
|
|
105
|
-
const newContent = readFileSync(deckPath, 'utf-8')
|
|
106
|
-
expect(newContent).toContain('[tool.skills.skill-a]')
|
|
107
|
-
expect(newContent).toContain('path = "github.com/owner/repo-a"')
|
|
108
|
-
expect(newContent).toContain('[tool.skills.repo-b]')
|
|
109
|
-
expect(newContent).toContain('path = "github.com/owner/repo-b"')
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('C8: alias collision rejects', async () => {
|
|
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\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo-a"\n`
|
|
119
|
-
const deckPath = join(projectDir, 'skill-deck.toml')
|
|
120
|
-
writeFileSync(deckPath, deckContent)
|
|
121
|
-
|
|
122
|
-
const fixtureDir = makeTmp()
|
|
123
|
-
writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill-b\n---\n')
|
|
124
|
-
|
|
125
|
-
mockGitClone(fixtureDir)
|
|
126
|
-
|
|
127
|
-
const errors: string[] = []
|
|
128
|
-
const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
|
|
129
|
-
errors.push(String(msg))
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
const originalExit = process.exit
|
|
133
|
-
let exitCode: number | undefined
|
|
134
|
-
process.exit = ((code?: number) => {
|
|
135
|
-
exitCode = code ?? 0
|
|
136
|
-
throw new Error(`EXIT:${code}`)
|
|
137
|
-
}) as typeof process.exit
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
const { addSkill } = await import('./add.ts')
|
|
141
|
-
await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath, alias: 'foo' })
|
|
142
|
-
expect(false).toBe(true) // should not reach here
|
|
143
|
-
} catch (err: any) {
|
|
144
|
-
expect(exitCode).toBe(1)
|
|
145
|
-
expect(errors.some(e => e.includes('Alias "foo" already exists'))).toBe(true)
|
|
146
|
-
} finally {
|
|
147
|
-
process.exit = originalExit
|
|
148
|
-
errorSpy.mockRestore()
|
|
149
|
-
}
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('C9: invalid skill type rejects', async () => {
|
|
153
|
-
const projectDir = makeTmp()
|
|
154
|
-
const coldPoolRel = 'cold-pool'
|
|
155
|
-
const coldPool = join(projectDir, coldPoolRel)
|
|
156
|
-
mkdirSync(coldPool, { recursive: true })
|
|
157
|
-
|
|
158
|
-
const fixtureDir = makeTmp()
|
|
159
|
-
writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill\n---\n')
|
|
160
|
-
|
|
161
|
-
const originalExec = childProcess.execFileSync
|
|
162
|
-
const execSpy = spyOn(childProcess, 'execFileSync').mockImplementation(((cmd: string, args: string[], options?: any) => {
|
|
163
|
-
if (cmd === 'git' && args[0] === 'clone') {
|
|
164
|
-
const dest = args[args.length - 1]
|
|
165
|
-
cpSync(fixtureDir, dest, { recursive: true })
|
|
166
|
-
return Buffer.from('')
|
|
167
|
-
}
|
|
168
|
-
return originalExec(cmd, args, options)
|
|
169
|
-
}) as any)
|
|
170
|
-
|
|
171
|
-
const errors: string[] = []
|
|
172
|
-
const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
|
|
173
|
-
errors.push(String(msg))
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
const originalExit = process.exit
|
|
177
|
-
let exitCode: number | undefined
|
|
178
|
-
process.exit = ((code?: number) => {
|
|
179
|
-
exitCode = code ?? 0
|
|
180
|
-
throw new Error(`EXIT:${code}`)
|
|
181
|
-
}) as typeof process.exit
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const { addSkill } = await import('./add.ts')
|
|
185
|
-
await addSkill('github.com/owner/repo', { deck: join(projectDir, 'skill-deck.toml'), workdir: projectDir, type: 'invalid' })
|
|
186
|
-
expect(false).toBe(true)
|
|
187
|
-
} catch (err: any) {
|
|
188
|
-
expect(exitCode).toBe(1)
|
|
189
|
-
expect(errors.some(e => e.includes('Invalid type'))).toBe(true)
|
|
190
|
-
} finally {
|
|
191
|
-
process.exit = originalExit
|
|
192
|
-
errorSpy.mockRestore()
|
|
193
|
-
execSpy.mockRestore()
|
|
194
|
-
}
|
|
195
|
-
})
|
|
196
|
-
})
|
|
197
|
-
|
|
198
35
|
describe('findSkillDir', () => {
|
|
199
|
-
|
|
200
|
-
const dir = mkdtempSync(join(tmpdir(), 'deck-findskill-'))
|
|
201
|
-
cleanup.push(dir)
|
|
202
|
-
return dir
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function placeSkill(repo: string, relPath: string): string {
|
|
206
|
-
const skillDir = join(repo, relPath)
|
|
207
|
-
mkdirSync(skillDir, { recursive: true })
|
|
208
|
-
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
|
|
209
|
-
return skillDir
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
it('returns repoPath for standalone skill (SKILL.md at repo root)', () => {
|
|
36
|
+
it('finds skill at repo root when SKILL.md exists', () => {
|
|
213
37
|
const repo = makeRepo()
|
|
214
|
-
writeFileSync(join(repo, 'SKILL.md'), '---\nname:
|
|
38
|
+
writeFileSync(join(repo, 'SKILL.md'), '---\nname: test\n---\n')
|
|
215
39
|
expect(findSkillDir(repo, null)).toBe(repo)
|
|
40
|
+
rmSync(repo, { recursive: true, force: true })
|
|
216
41
|
})
|
|
217
42
|
|
|
218
|
-
it('
|
|
219
|
-
const repo = makeRepo()
|
|
220
|
-
const expected = placeSkill(repo, 'skills/my-skill')
|
|
221
|
-
expect(findSkillDir(repo, null)).toBe(expected)
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
it('returns flat root dir when single skill exists at repo root', () => {
|
|
43
|
+
it('finds skill at skills/<name> when name provided', () => {
|
|
225
44
|
const repo = makeRepo()
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
it('returns null when multiple flat skills exist at repo root (ambiguous)', () => {
|
|
231
|
-
const repo = makeRepo()
|
|
232
|
-
placeSkill(repo, 'skill-a')
|
|
233
|
-
placeSkill(repo, 'skill-b')
|
|
234
|
-
expect(findSkillDir(repo, null)).toBeNull()
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
it('finds skill in skills/ subdir when skill name is provided', () => {
|
|
238
|
-
const repo = makeRepo()
|
|
239
|
-
const expected = placeSkill(repo, 'skills/my-skill')
|
|
240
|
-
expect(findSkillDir(repo, 'my-skill')).toBe(expected)
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
it('finds skill at repo root when skill name is provided (flat)', () => {
|
|
244
|
-
const repo = makeRepo()
|
|
245
|
-
const expected = placeSkill(repo, 'my-skill')
|
|
246
|
-
expect(findSkillDir(repo, 'my-skill')).toBe(expected)
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it('returns null when skill name provided but not found anywhere', () => {
|
|
250
|
-
const repo = makeRepo()
|
|
251
|
-
expect(findSkillDir(repo, 'nonexistent')).toBeNull()
|
|
45
|
+
makeSkillDir(repo, 'skills/my-skill')
|
|
46
|
+
const found = findSkillDir(repo, 'skills/my-skill')
|
|
47
|
+
expect(found).toBe(join(repo, 'skills/my-skill'))
|
|
48
|
+
rmSync(repo, { recursive: true, force: true })
|
|
252
49
|
})
|
|
253
50
|
|
|
254
51
|
it('returns null when no SKILL.md exists anywhere in repo', () => {
|
|
255
52
|
const repo = makeRepo()
|
|
256
53
|
expect(findSkillDir(repo, null)).toBeNull()
|
|
54
|
+
rmSync(repo, { recursive: true, force: true })
|
|
257
55
|
})
|
|
258
56
|
})
|
|
259
57
|
|
|
260
|
-
// ── skills.sh syntax sugar
|
|
58
|
+
// ── normalizeSkillsSh — skills.sh syntax sugar ─────────────────
|
|
261
59
|
|
|
262
60
|
describe('normalizeSkillsSh', () => {
|
|
263
|
-
// FQ locators
|
|
61
|
+
// FQ locators pass through
|
|
264
62
|
it('passes FQ github.com locators through', () => {
|
|
265
|
-
expect(normalizeSkillsSh('github.com/anthropics/skills/skills/frontend-design'))
|
|
63
|
+
expect(normalizeSkillsSh('github.com/anthropics/skills/skills/frontend-design').fq)
|
|
266
64
|
.toBe('github.com/anthropics/skills/skills/frontend-design')
|
|
267
|
-
expect(normalizeSkillsSh('github.com/vercel-labs/agent-skills'))
|
|
65
|
+
expect(normalizeSkillsSh('github.com/vercel-labs/agent-skills').fq)
|
|
268
66
|
.toBe('github.com/vercel-labs/agent-skills')
|
|
269
67
|
})
|
|
270
68
|
|
|
271
69
|
it('passes localhost locators through', () => {
|
|
272
|
-
expect(normalizeSkillsSh('localhost/me/skill-a')).toBe('localhost/me/skill-a')
|
|
70
|
+
expect(normalizeSkillsSh('localhost/me/skill-a').fq).toBe('localhost/me/skill-a')
|
|
273
71
|
})
|
|
274
72
|
|
|
275
73
|
// skills.sh top skill owner/repo formats
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
it('normalizes apify/agent-skills', () => {
|
|
301
|
-
expect(normalizeSkillsSh('apify/agent-skills')).toBe('github.com/apify/agent-skills')
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
it('normalizes squirrelscan/skills', () => {
|
|
305
|
-
expect(normalizeSkillsSh('squirrelscan/skills')).toBe('github.com/squirrelscan/skills')
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
it('normalizes getsentry/sentry-for-ai', () => {
|
|
309
|
-
expect(normalizeSkillsSh('getsentry/sentry-for-ai')).toBe('github.com/getsentry/sentry-for-ai')
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
it('normalizes coderabbitai/skills', () => {
|
|
313
|
-
expect(normalizeSkillsSh('coderabbitai/skills')).toBe('github.com/coderabbitai/skills')
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
it('normalizes openai/skills', () => {
|
|
317
|
-
expect(normalizeSkillsSh('openai/skills')).toBe('github.com/openai/skills')
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
it('normalizes google-gemini/gemini-cli', () => {
|
|
321
|
-
expect(normalizeSkillsSh('google-gemini/gemini-cli')).toBe('github.com/google-gemini/gemini-cli')
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
it('normalizes coreyhaines31/marketingskills', () => {
|
|
325
|
-
expect(normalizeSkillsSh('coreyhaines31/marketingskills')).toBe('github.com/coreyhaines31/marketingskills')
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
it('normalizes jimliu/baoyu-skills', () => {
|
|
329
|
-
expect(normalizeSkillsSh('jimliu/baoyu-skills')).toBe('github.com/jimliu/baoyu-skills')
|
|
330
|
-
})
|
|
74
|
+
const topSkills: Array<[string, string]> = [
|
|
75
|
+
['vercel-labs/skills', 'github.com/vercel-labs/skills'],
|
|
76
|
+
['vercel-labs/agent-skills', 'github.com/vercel-labs/agent-skills'],
|
|
77
|
+
['anthropics/skills', 'github.com/anthropics/skills'],
|
|
78
|
+
['obra/superpowers', 'github.com/obra/superpowers'],
|
|
79
|
+
['browser-use/browser-use', 'github.com/browser-use/browser-use'],
|
|
80
|
+
['firecrawl/cli', 'github.com/firecrawl/cli'],
|
|
81
|
+
['apify/agent-skills', 'github.com/apify/agent-skills'],
|
|
82
|
+
['squirrelscan/skills', 'github.com/squirrelscan/skills'],
|
|
83
|
+
['getsentry/sentry-for-ai', 'github.com/getsentry/sentry-for-ai'],
|
|
84
|
+
['coderabbitai/skills', 'github.com/coderabbitai/skills'],
|
|
85
|
+
['openai/skills', 'github.com/openai/skills'],
|
|
86
|
+
['google-gemini/gemini-cli', 'github.com/google-gemini/gemini-cli'],
|
|
87
|
+
['coreyhaines31/marketingskills', 'github.com/coreyhaines31/marketingskills'],
|
|
88
|
+
['jimliu/baoyu-skills', 'github.com/jimliu/baoyu-skills'],
|
|
89
|
+
['astronomer/agents', 'github.com/astronomer/agents'],
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
for (const [input, expected] of topSkills) {
|
|
93
|
+
it(`normalizes ${input}`, () => {
|
|
94
|
+
expect(normalizeSkillsSh(input).fq).toBe(expected)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
331
97
|
|
|
332
|
-
|
|
333
|
-
|
|
98
|
+
// @skill syntax
|
|
99
|
+
it('preserves skillFilter from @skill syntax', () => {
|
|
100
|
+
const r = normalizeSkillsSh('vercel-labs/skills@find-skills')
|
|
101
|
+
expect(r.fq).toBe('github.com/vercel-labs/skills')
|
|
102
|
+
expect(r.skillFilter).toBe('find-skills')
|
|
334
103
|
})
|
|
335
104
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
expect(normalizeSkillsSh('vercel-labs/skills@find-skills'))
|
|
339
|
-
.toBe('github.com/vercel-labs/skills')
|
|
340
|
-
expect(normalizeSkillsSh('mattpocock/skills@tdd'))
|
|
105
|
+
it('normalizes @skill with repo-level fq', () => {
|
|
106
|
+
expect(normalizeSkillsSh('mattpocock/skills@tdd').fq)
|
|
341
107
|
.toBe('github.com/mattpocock/skills')
|
|
342
|
-
expect(normalizeSkillsSh('google-gemini/gemini-cli@code-reviewer'))
|
|
343
|
-
.toBe('github.com/google-gemini/gemini-cli')
|
|
344
108
|
})
|
|
345
109
|
|
|
346
|
-
//
|
|
110
|
+
// subpath
|
|
347
111
|
it('normalizes owner/repo/subpath', () => {
|
|
348
|
-
expect(normalizeSkillsSh('anthropics/skills/skills/frontend-design'))
|
|
112
|
+
expect(normalizeSkillsSh('anthropics/skills/skills/frontend-design').fq)
|
|
349
113
|
.toBe('github.com/anthropics/skills/skills/frontend-design')
|
|
350
114
|
})
|
|
351
115
|
|
|
352
116
|
// github: prefix
|
|
353
117
|
it('normalizes github:owner/repo', () => {
|
|
354
|
-
expect(normalizeSkillsSh('github:vercel-labs/agent-skills'))
|
|
118
|
+
expect(normalizeSkillsSh('github:vercel-labs/agent-skills').fq)
|
|
355
119
|
.toBe('github.com/vercel-labs/agent-skills')
|
|
356
120
|
})
|
|
357
121
|
|
|
358
|
-
// #ref suffix
|
|
122
|
+
// #ref suffix
|
|
359
123
|
it('preserves #ref with FQ locator', () => {
|
|
360
|
-
expect(normalizeSkillsSh('github.com/vercel-labs/skills#main'))
|
|
124
|
+
expect(normalizeSkillsSh('github.com/vercel-labs/skills#main').fq)
|
|
361
125
|
.toBe('github.com/vercel-labs/skills#main')
|
|
362
126
|
})
|
|
363
127
|
|
|
364
128
|
it('preserves #ref with owner/repo shorthand', () => {
|
|
365
|
-
expect(normalizeSkillsSh('vercel-labs/skills#v2.0'))
|
|
129
|
+
expect(normalizeSkillsSh('vercel-labs/skills#v2.0').fq)
|
|
366
130
|
.toBe('github.com/vercel-labs/skills#v2.0')
|
|
367
131
|
})
|
|
368
132
|
|
|
369
133
|
it('preserves #ref with @skill syntax', () => {
|
|
370
|
-
|
|
371
|
-
|
|
134
|
+
const r = normalizeSkillsSh('vercel-labs/skills#main@find-skills')
|
|
135
|
+
expect(r.fq).toBe('github.com/vercel-labs/skills#main')
|
|
136
|
+
expect(r.skillFilter).toBe('find-skills')
|
|
372
137
|
})
|
|
373
138
|
|
|
374
139
|
it('preserves #ref with subpath', () => {
|
|
375
|
-
expect(normalizeSkillsSh('anthropics/skills/skills/frontend-design#abc1234'))
|
|
140
|
+
expect(normalizeSkillsSh('anthropics/skills/skills/frontend-design#abc1234').fq)
|
|
376
141
|
.toBe('github.com/anthropics/skills/skills/frontend-design#abc1234')
|
|
377
142
|
})
|
|
378
|
-
|
|
379
|
-
it('preserves #ref with github: prefix', () => {
|
|
380
|
-
expect(normalizeSkillsSh('github:vercel-labs/agent-skills#dev'))
|
|
381
|
-
.toBe('github.com/vercel-labs/agent-skills#dev')
|
|
382
|
-
})
|
|
383
143
|
})
|
package/src/add.ts
CHANGED
|
@@ -61,6 +61,30 @@ export function findSkillDir(repoPath: string, skill: string | null): string | n
|
|
|
61
61
|
return null
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Find a skill directory by name within a cloned repo (for @skill syntax). */
|
|
65
|
+
function findSkillByName(repoPath: string, name: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
const entries = readdirSync(repoPath, { withFileTypes: true, recursive: true })
|
|
68
|
+
// First pass: match by frontmatter name:
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (!e.isFile() || e.name !== 'SKILL.md') continue
|
|
71
|
+
const dir = e.parentPath ?? dirname(join(repoPath, e.name))
|
|
72
|
+
try {
|
|
73
|
+
const content = readFileSync(join(dir, 'SKILL.md'), 'utf-8')
|
|
74
|
+
const fmMatch = content.match(/^---\s*\nname:\s*(.+)$/m)
|
|
75
|
+
if (fmMatch && fmMatch[1].trim() === name) return dir
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
// Second pass: fall back to directory name (skills.sh convention — dir name ≠ frontmatter name)
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
if (!e.isFile() || e.name !== 'SKILL.md') continue
|
|
81
|
+
const dir = e.parentPath ?? dirname(join(repoPath, e.name))
|
|
82
|
+
if (basename(dir) === name) return dir
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
function resolvePath(p: string): string {
|
|
65
89
|
if (p.startsWith('~/')) return join(homedir(), p.slice(2))
|
|
66
90
|
return resolve(p)
|
|
@@ -89,12 +113,16 @@ function fqOf(loc: Locator): string {
|
|
|
89
113
|
* github:owner/repo → github.com/owner/repo
|
|
90
114
|
* owner/repo → github.com/owner/repo
|
|
91
115
|
*/
|
|
92
|
-
export
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
export interface NormalizedLocator {
|
|
117
|
+
fq: string // FQ locator for clone + parseLocator
|
|
118
|
+
skillFilter?: string // from @skill suffix — match by name after clone
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function normalizeSkillsSh(input: string): NormalizedLocator {
|
|
122
|
+
// localhost: always pass through
|
|
123
|
+
if (input.startsWith('localhost/')) return { fq: input }
|
|
95
124
|
|
|
96
|
-
// Extract #ref suffix
|
|
97
|
-
// #ref comes before @skill: owner/repo#main@skill-name → ref=main, skill=skill-name
|
|
125
|
+
// Extract #ref suffix
|
|
98
126
|
let ref = ''
|
|
99
127
|
let base = input
|
|
100
128
|
const hashIdx = input.indexOf('#')
|
|
@@ -106,38 +134,44 @@ export function normalizeSkillsSh(input: string): string {
|
|
|
106
134
|
ref = `#${afterHash.slice(0, atInRef)}`
|
|
107
135
|
base = `${base}@${afterHash.slice(atInRef + 1)}`
|
|
108
136
|
} else {
|
|
109
|
-
ref = input.slice(hashIdx)
|
|
137
|
+
ref = input.slice(hashIdx)
|
|
110
138
|
}
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
// Already an FQ locator
|
|
114
|
-
if (base.match(/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\/.+\/.+/)) return input
|
|
141
|
+
// Already an FQ locator
|
|
142
|
+
if (base.match(/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\/.+\/.+/)) return { fq: input }
|
|
115
143
|
|
|
116
|
-
// github: prefix
|
|
144
|
+
// github: prefix — extract @skill from remainder before building FQ
|
|
117
145
|
const ghPrefix = base.match(/^github:(.+)$/)
|
|
118
|
-
if (ghPrefix)
|
|
146
|
+
if (ghPrefix) {
|
|
147
|
+
const rest = ghPrefix[1]
|
|
148
|
+
const atInGh = rest.match(/^([^/]+)\/([^/@]+)@(.+)$/)
|
|
149
|
+
if (atInGh) {
|
|
150
|
+
const [, owner, repo, skill] = atInGh
|
|
151
|
+
return { fq: `github.com/${owner}/${repo}${ref}`, skillFilter: skill }
|
|
152
|
+
}
|
|
153
|
+
return { fq: `github.com/${rest}${ref}` }
|
|
154
|
+
}
|
|
119
155
|
|
|
120
|
-
// owner/repo@skill
|
|
121
|
-
// Skill discovery at runtime (scanSkill → name match) because the
|
|
122
|
-
// actual path within the repo is unknown until after clone.
|
|
123
|
-
// e.g. gemini-cli has skills at .gemini/skills/, not skills/.
|
|
156
|
+
// owner/repo@skill — clone repo-level, discover exact path at runtime
|
|
124
157
|
const atMatch = base.match(/^([^/]+)\/([^/@]+)@(.+)$/)
|
|
125
158
|
if (atMatch && !base.includes(':') && !base.startsWith('.')) {
|
|
126
|
-
const [, owner, repo] = atMatch
|
|
127
|
-
return `github.com/${owner}/${repo}${ref}
|
|
159
|
+
const [, owner, repo, skill] = atMatch
|
|
160
|
+
return { fq: `github.com/${owner}/${repo}${ref}`, skillFilter: skill }
|
|
128
161
|
}
|
|
129
162
|
|
|
130
|
-
// owner/repo[/subpath]
|
|
163
|
+
// owner/repo[/subpath]
|
|
131
164
|
const shortMatch = base.match(/^([^/.]+)\/([^/]+)(?:\/(.+?))?\/?$/)
|
|
132
165
|
if (shortMatch && !base.includes(':') && !base.startsWith('.')) {
|
|
133
166
|
const [, owner, repo, subpath] = shortMatch
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
return {
|
|
168
|
+
fq: subpath
|
|
169
|
+
? `github.com/${owner}/${repo}/${subpath}${ref}`
|
|
170
|
+
: `github.com/${owner}/${repo}${ref}`,
|
|
171
|
+
}
|
|
138
172
|
}
|
|
139
173
|
|
|
140
|
-
return input
|
|
174
|
+
return { fq: input }
|
|
141
175
|
}
|
|
142
176
|
|
|
143
177
|
function exitInvalidLocator(locator: string): never {
|
|
@@ -165,9 +199,9 @@ export async function addSkill(
|
|
|
165
199
|
? resolvePath(options.deck)
|
|
166
200
|
: findDeckToml(workdir) || join(workdir, 'skill-deck.toml')
|
|
167
201
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
if (!parsed) exitInvalidLocator(
|
|
202
|
+
const { fq, skillFilter } = normalizeSkillsSh(locator)
|
|
203
|
+
let parsed = parseLocator(fq)
|
|
204
|
+
if (!parsed) exitInvalidLocator(fq)
|
|
171
205
|
|
|
172
206
|
if (parsed.isLocalhost) {
|
|
173
207
|
console.error(`❌ deck add does not support localhost locators (no remote to clone).`)
|
|
@@ -178,15 +212,13 @@ export async function addSkill(
|
|
|
178
212
|
const coldPoolPath = resolveColdPoolPath(deckPath, workdir)
|
|
179
213
|
const pool = new ColdPool(coldPoolPath)
|
|
180
214
|
const fetchPlan = buildFetchPlan(pool, parsed)
|
|
181
|
-
const fqPath = fqOf(parsed)
|
|
182
215
|
const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo!
|
|
183
|
-
|
|
216
|
+
let rawAlias = options.alias || skillName
|
|
184
217
|
try { validateAlias(rawAlias) } catch (e: any) {
|
|
185
218
|
console.error(`❌ Invalid alias: ${e.message}`)
|
|
186
219
|
console.error(' Aliases may only contain letters, numbers, hyphens, and underscores.')
|
|
187
220
|
process.exit(1)
|
|
188
221
|
}
|
|
189
|
-
const alias = rawAlias
|
|
190
222
|
const skillType = (options.type || 'tool').toLowerCase()
|
|
191
223
|
|
|
192
224
|
if (!['innate', 'tool', 'combo'].includes(skillType)) {
|
|
@@ -194,6 +226,7 @@ export async function addSkill(
|
|
|
194
226
|
process.exit(1)
|
|
195
227
|
}
|
|
196
228
|
|
|
229
|
+
const fqPathBefore = fqOf(parsed) // pre-discovery — may be repo-level for @skill
|
|
197
230
|
if (dryRun) {
|
|
198
231
|
console.log(`🔎 Dry-run: deck add ${locator}`)
|
|
199
232
|
console.log(` Cold pool: ${coldPoolPath}`)
|
|
@@ -218,7 +251,7 @@ export async function addSkill(
|
|
|
218
251
|
}
|
|
219
252
|
console.log(`\n📝 Would add to skill-deck.toml:`)
|
|
220
253
|
console.log(` [${skillType}.skills.${alias}]`)
|
|
221
|
-
console.log(` path = "${
|
|
254
|
+
console.log(` path = "${fqPathBefore}"`)
|
|
222
255
|
console.log(`\n💡 Remove --dry-run to execute.`)
|
|
223
256
|
return
|
|
224
257
|
}
|
|
@@ -254,7 +287,16 @@ export async function addSkill(
|
|
|
254
287
|
console.log(` (per ADR-20260507110332805, refresh defaults to discover-only)`)
|
|
255
288
|
}
|
|
256
289
|
|
|
257
|
-
const skillDir = findSkillDir(fetchPlan.targetDir, parsed.skill)
|
|
290
|
+
const skillDir = findSkillDir(fetchPlan.targetDir, parsed.skill || null)
|
|
291
|
+
?? (skillFilter ? findSkillByName(fetchPlan.targetDir, skillFilter) : null)
|
|
292
|
+
if (skillDir && skillFilter) {
|
|
293
|
+
// If discovered by name, update skill path and alias
|
|
294
|
+
const relPath = skillDir.slice(fetchPlan.targetDir.length + 1)
|
|
295
|
+
parsed = { ...parsed, skill: relPath }
|
|
296
|
+
if (!options.alias) rawAlias = basename(relPath) // use discovered dir name as alias
|
|
297
|
+
}
|
|
298
|
+
const fqPath = fqOf(parsed) // may be updated after @skill discovery
|
|
299
|
+
const alias = rawAlias // finalized after potential @skill override
|
|
258
300
|
if (!skillDir) {
|
|
259
301
|
console.error(`❌ No SKILL.md found in downloaded repo`)
|
|
260
302
|
console.error(` Checked: ${fetchPlan.targetDir}`)
|