@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 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.49 add vercel-labs/agent-skills
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.49 add mattpocock/skills@tdd
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.49 add github.com/anthropics/skills/skills/frontend-design
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.49 link
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.49 <command> [options]
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.49 link` |
77
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.49 validate` |
78
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.49 add owner/repo` |
79
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.49 refresh` |
80
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.49 refresh tdd` |
81
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.49 remove tdd` |
82
- | Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.49 to-symlink tdd` |
83
- | Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.49 to-snapshot tdd` |
84
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.49 link --deck ./my-deck.toml --workdir /path/to/project` |
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.49 link
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.49 add github.com/owner/repo/skill` or clone manually into cold pool |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.49",
3
+ "version": "0.9.51",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/add.test.ts CHANGED
@@ -18,366 +18,126 @@ mock.module('node:os', () => ({
18
18
  homedir: () => mockHomeDir,
19
19
  }))
20
20
 
21
- let cleanup: string[] = []
22
- let execSpy: ReturnType<typeof spyOn> | null = null
21
+ // ── findSkillDir ───────────────────────────────────────────────
23
22
 
24
- afterEach(() => {
25
- if (execSpy) {
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 mockGitClone(fixturePath: string) {
42
- const originalExec = childProcess.execFileSync
43
- execSpy = spyOn(childProcess, 'execFileSync').mockImplementation(((cmd: string, args: string[], options?: any) => {
44
- if (cmd === 'git' && args[0] === 'clone') {
45
- const dest = args[args.length - 1]
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
- function makeRepo(): string {
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: standalone\n---\n')
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('returns skills/ subdir when single skill exists there', () => {
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
- const expected = placeSkill(repo, 'my-skill')
227
- expect(findSkillDir(repo, null)).toBe(expected)
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 — top skills parse validation ────────
58
+ // ── normalizeSkillsSh — skills.sh syntax sugar ─────────────────
261
59
 
262
60
  describe('normalizeSkillsSh', () => {
263
- // FQ locators — must pass through unchanged
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
- it('normalizes vercel-labs/skills', () => {
277
- expect(normalizeSkillsSh('vercel-labs/skills')).toBe('github.com/vercel-labs/skills')
278
- })
279
-
280
- it('normalizes vercel-labs/agent-skills', () => {
281
- expect(normalizeSkillsSh('vercel-labs/agent-skills')).toBe('github.com/vercel-labs/agent-skills')
282
- })
283
-
284
- it('normalizes anthropics/skills', () => {
285
- expect(normalizeSkillsSh('anthropics/skills')).toBe('github.com/anthropics/skills')
286
- })
287
-
288
- it('normalizes obra/superpowers', () => {
289
- expect(normalizeSkillsSh('obra/superpowers')).toBe('github.com/obra/superpowers')
290
- })
291
-
292
- it('normalizes browser-use/browser-use', () => {
293
- expect(normalizeSkillsSh('browser-use/browser-use')).toBe('github.com/browser-use/browser-use')
294
- })
295
-
296
- it('normalizes firecrawl/cli', () => {
297
- expect(normalizeSkillsSh('firecrawl/cli')).toBe('github.com/firecrawl/cli')
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
- it('normalizes astronmer/agents', () => {
333
- expect(normalizeSkillsSh('astronomer/agents')).toBe('github.com/astronomer/agents')
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
- // owner/repo@skill syntax — normalizes to repo level, discovery at runtime
337
- it('normalizes owner/repo@skill to repo-level locator', () => {
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
- // owner/repo/subpath syntax
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 (branch/tag/commit) — compatible with skills.sh parseFragmentRef
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
- expect(normalizeSkillsSh('vercel-labs/skills#main@find-skills'))
371
- .toBe('github.com/vercel-labs/skills#main')
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 function normalizeSkillsSh(input: string): string {
93
- // localhost: always pass through (parseLocator handles multi-segment validation)
94
- if (input.startsWith('localhost/')) return input
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 (branch/tag/commit) — compatible with skills.sh parseFragmentRef.
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) // includes '#'
137
+ ref = input.slice(hashIdx)
110
138
  }
111
139
  }
112
140
 
113
- // Already an FQ locator: host.tld/owner/repo[/...] — pass through with ref
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) return `github.com/${ghPrefix[1]}${ref}`
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 shorthand normalizes to repo-level locator.
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] shorthand (no dot in first segment → not a hostname)
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
- const fq = subpath
135
- ? `github.com/${owner}/${repo}/${subpath}`
136
- : `github.com/${owner}/${repo}`
137
- return `${fq}${ref}`
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 normalized = normalizeSkillsSh(locator)
169
- const parsed = parseLocator(normalized)
170
- if (!parsed) exitInvalidLocator(normalized)
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
- const rawAlias = options.alias || skillName
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 = "${fqPath}"`)
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}`)