@lythos/skill-deck 0.9.48 → 0.9.49
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 +33 -14
- package/package.json +1 -1
- package/src/add.test.ts +126 -1
- package/src/add.ts +95 -8
- package/src/link.ts +10 -3
- package/src/parse-deck.ts +6 -1
- package/src/path-guard.ts +130 -0
- package/src/remove.ts +9 -1
- package/src/resolve-deck.ts +8 -0
- package/src/to-symlink-snapshot.ts +11 -0
package/README.md
CHANGED
|
@@ -2,14 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
   
|
|
4
4
|
|
|
5
|
-
> Declarative skill deck governance.
|
|
5
|
+
> Declarative skill deck governance. Declare skills, sync working set via symlinks — deny-by-default, max-cards budgeting, transient expiry. **Compatible with skills.sh syntax.**
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
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
|
|
12
|
+
|
|
13
|
+
# Or with @skill filter (same as npx skills add):
|
|
14
|
+
bunx @lythos/skill-deck@0.9.49 add mattpocock/skills@tdd
|
|
15
|
+
|
|
16
|
+
# Or FQ locator:
|
|
17
|
+
bunx @lythos/skill-deck@0.9.49 add github.com/anthropics/skills/skills/frontend-design
|
|
18
|
+
|
|
19
|
+
# Sync working set (deny-by-default):
|
|
20
|
+
bunx @lythos/skill-deck@0.9.49 link
|
|
21
|
+
```
|
|
6
22
|
|
|
7
23
|
## For AI Agents
|
|
8
24
|
|
|
9
25
|
This package exposes a **CLI**. Invoke via:
|
|
10
26
|
|
|
11
27
|
```bash
|
|
12
|
-
bunx @lythos/skill-deck@0.9.
|
|
28
|
+
bunx @lythos/skill-deck@0.9.49 <command> [options]
|
|
13
29
|
```
|
|
14
30
|
|
|
15
31
|
No installation required. `bunx` auto-downloads the package.
|
|
@@ -22,6 +38,7 @@ max_cards = 10
|
|
|
22
38
|
|
|
23
39
|
[tool.skills.lythoskill-deck]
|
|
24
40
|
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
41
|
+
source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
|
|
25
42
|
```
|
|
26
43
|
|
|
27
44
|
### skill-deck.toml (full reference)
|
|
@@ -34,6 +51,7 @@ cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
|
|
|
34
51
|
|
|
35
52
|
[innate.skills.lythoskill-deck] # Always-loaded skills
|
|
36
53
|
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
54
|
+
source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
|
|
37
55
|
|
|
38
56
|
[tool.skills.tdd] # Auto-triggered skills
|
|
39
57
|
path = "github.com/mattpocock/skills/skills/engineering/tdd"
|
|
@@ -55,15 +73,15 @@ prompt = "Search for latest info, then generate professional document with diagr
|
|
|
55
73
|
|
|
56
74
|
| Situation | Command |
|
|
57
75
|
|-----------|---------|
|
|
58
|
-
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.
|
|
59
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.
|
|
60
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.
|
|
61
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.
|
|
62
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.
|
|
63
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.
|
|
64
|
-
| Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.
|
|
65
|
-
| Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.
|
|
66
|
-
| 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.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` |
|
|
67
85
|
|
|
68
86
|
### Commands
|
|
69
87
|
|
|
@@ -71,7 +89,7 @@ prompt = "Search for latest info, then generate professional document with diagr
|
|
|
71
89
|
|---------|------|-------------|
|
|
72
90
|
| `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
|
|
73
91
|
| `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
|
|
74
|
-
| `add` | `<locator> [--alias <alias>] [--type <type>] [--deck <path>]` |
|
|
92
|
+
| `add` | `<locator> [--alias <alias>] [--type <type>] [--deck <path>]` | Add skill to cold pool + deck.toml. Accepts skills.sh syntax (owner/repo, owner/repo@skill, github:owner/repo) and FQ locators. |
|
|
75
93
|
| `refresh` | `[<fq\|alias>] [--deck <path>]` | Pull latest versions of declared skills from upstream git repos. Pass a name to refresh one skill. |
|
|
76
94
|
| `remove` | `<fq\|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
|
|
77
95
|
| `to-symlink` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch a skill to symlink mode (live link, follows cold pool) |
|
|
@@ -121,10 +139,11 @@ max_cards = 10
|
|
|
121
139
|
|
|
122
140
|
[tool.skills.lythoskill-deck]
|
|
123
141
|
path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
142
|
+
source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
|
|
124
143
|
EOF
|
|
125
144
|
|
|
126
145
|
# 2. Link — creates symlinks in .claude/skills/
|
|
127
|
-
bunx @lythos/skill-deck@0.9.
|
|
146
|
+
bunx @lythos/skill-deck@0.9.49 link
|
|
128
147
|
```
|
|
129
148
|
|
|
130
149
|
### Key Concepts
|
|
@@ -210,7 +229,7 @@ Caution: deck's deny-by-default will remove any skills not declared in your deck
|
|
|
210
229
|
|
|
211
230
|
| Symptom | Cause | Fix |
|
|
212
231
|
|---------|-------|-----|
|
|
213
|
-
| `❌ 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.49 add github.com/owner/repo/skill` or clone manually into cold pool |
|
|
214
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 |
|
|
215
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 |
|
|
216
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
|
@@ -10,7 +10,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync
|
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { tmpdir } from 'node:os'
|
|
12
12
|
import * as childProcess from 'node:child_process'
|
|
13
|
-
import { findSkillDir } from './add.ts'
|
|
13
|
+
import { findSkillDir, normalizeSkillsSh } from './add.ts'
|
|
14
14
|
|
|
15
15
|
// Control homedir() return value for tests that need default cold_pool under tmpdir
|
|
16
16
|
let mockHomeDir = '/tmp'
|
|
@@ -256,3 +256,128 @@ describe('findSkillDir', () => {
|
|
|
256
256
|
expect(findSkillDir(repo, null)).toBeNull()
|
|
257
257
|
})
|
|
258
258
|
})
|
|
259
|
+
|
|
260
|
+
// ── skills.sh syntax sugar — top skills parse validation ────────
|
|
261
|
+
|
|
262
|
+
describe('normalizeSkillsSh', () => {
|
|
263
|
+
// FQ locators — must pass through unchanged
|
|
264
|
+
it('passes FQ github.com locators through', () => {
|
|
265
|
+
expect(normalizeSkillsSh('github.com/anthropics/skills/skills/frontend-design'))
|
|
266
|
+
.toBe('github.com/anthropics/skills/skills/frontend-design')
|
|
267
|
+
expect(normalizeSkillsSh('github.com/vercel-labs/agent-skills'))
|
|
268
|
+
.toBe('github.com/vercel-labs/agent-skills')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('passes localhost locators through', () => {
|
|
272
|
+
expect(normalizeSkillsSh('localhost/me/skill-a')).toBe('localhost/me/skill-a')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// 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
|
+
})
|
|
331
|
+
|
|
332
|
+
it('normalizes astronmer/agents', () => {
|
|
333
|
+
expect(normalizeSkillsSh('astronomer/agents')).toBe('github.com/astronomer/agents')
|
|
334
|
+
})
|
|
335
|
+
|
|
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'))
|
|
341
|
+
.toBe('github.com/mattpocock/skills')
|
|
342
|
+
expect(normalizeSkillsSh('google-gemini/gemini-cli@code-reviewer'))
|
|
343
|
+
.toBe('github.com/google-gemini/gemini-cli')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// owner/repo/subpath syntax
|
|
347
|
+
it('normalizes owner/repo/subpath', () => {
|
|
348
|
+
expect(normalizeSkillsSh('anthropics/skills/skills/frontend-design'))
|
|
349
|
+
.toBe('github.com/anthropics/skills/skills/frontend-design')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// github: prefix
|
|
353
|
+
it('normalizes github:owner/repo', () => {
|
|
354
|
+
expect(normalizeSkillsSh('github:vercel-labs/agent-skills'))
|
|
355
|
+
.toBe('github.com/vercel-labs/agent-skills')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// #ref suffix (branch/tag/commit) — compatible with skills.sh parseFragmentRef
|
|
359
|
+
it('preserves #ref with FQ locator', () => {
|
|
360
|
+
expect(normalizeSkillsSh('github.com/vercel-labs/skills#main'))
|
|
361
|
+
.toBe('github.com/vercel-labs/skills#main')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('preserves #ref with owner/repo shorthand', () => {
|
|
365
|
+
expect(normalizeSkillsSh('vercel-labs/skills#v2.0'))
|
|
366
|
+
.toBe('github.com/vercel-labs/skills#v2.0')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('preserves #ref with @skill syntax', () => {
|
|
370
|
+
expect(normalizeSkillsSh('vercel-labs/skills#main@find-skills'))
|
|
371
|
+
.toBe('github.com/vercel-labs/skills#main')
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('preserves #ref with subpath', () => {
|
|
375
|
+
expect(normalizeSkillsSh('anthropics/skills/skills/frontend-design#abc1234'))
|
|
376
|
+
.toBe('github.com/anthropics/skills/skills/frontend-design#abc1234')
|
|
377
|
+
})
|
|
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
|
+
})
|
package/src/add.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type Locator,
|
|
30
30
|
} from '@lythos/cold-pool'
|
|
31
31
|
import { findDeckToml, expandHome } from './link.js'
|
|
32
|
+
import { validateAlias } from './path-guard.js'
|
|
32
33
|
|
|
33
34
|
export function findSkillDir(repoPath: string, skill: string | null): string | null {
|
|
34
35
|
if (skill) {
|
|
@@ -80,12 +81,77 @@ function fqOf(loc: Locator): string {
|
|
|
80
81
|
return formatLocator(loc)
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Normalize skills.sh syntax to FQ locator (UX sugar — internal rep stays git-only).
|
|
86
|
+
*
|
|
87
|
+
* owner/repo@skill → github.com/owner/repo (skillFilter handled by discovery)
|
|
88
|
+
* owner/repo/subpath → github.com/owner/repo/subpath
|
|
89
|
+
* github:owner/repo → github.com/owner/repo
|
|
90
|
+
* owner/repo → github.com/owner/repo
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeSkillsSh(input: string): string {
|
|
93
|
+
// localhost: always pass through (parseLocator handles multi-segment validation)
|
|
94
|
+
if (input.startsWith('localhost/')) return input
|
|
95
|
+
|
|
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
|
|
98
|
+
let ref = ''
|
|
99
|
+
let base = input
|
|
100
|
+
const hashIdx = input.indexOf('#')
|
|
101
|
+
if (hashIdx >= 0) {
|
|
102
|
+
base = input.slice(0, hashIdx)
|
|
103
|
+
const afterHash = input.slice(hashIdx + 1)
|
|
104
|
+
const atInRef = afterHash.indexOf('@')
|
|
105
|
+
if (atInRef >= 0) {
|
|
106
|
+
ref = `#${afterHash.slice(0, atInRef)}`
|
|
107
|
+
base = `${base}@${afterHash.slice(atInRef + 1)}`
|
|
108
|
+
} else {
|
|
109
|
+
ref = input.slice(hashIdx) // includes '#'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
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
|
|
115
|
+
|
|
116
|
+
// github: prefix
|
|
117
|
+
const ghPrefix = base.match(/^github:(.+)$/)
|
|
118
|
+
if (ghPrefix) return `github.com/${ghPrefix[1]}${ref}`
|
|
119
|
+
|
|
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/.
|
|
124
|
+
const atMatch = base.match(/^([^/]+)\/([^/@]+)@(.+)$/)
|
|
125
|
+
if (atMatch && !base.includes(':') && !base.startsWith('.')) {
|
|
126
|
+
const [, owner, repo] = atMatch
|
|
127
|
+
return `github.com/${owner}/${repo}${ref}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// owner/repo[/subpath] shorthand (no dot in first segment → not a hostname)
|
|
131
|
+
const shortMatch = base.match(/^([^/.]+)\/([^/]+)(?:\/(.+?))?\/?$/)
|
|
132
|
+
if (shortMatch && !base.includes(':') && !base.startsWith('.')) {
|
|
133
|
+
const [, owner, repo, subpath] = shortMatch
|
|
134
|
+
const fq = subpath
|
|
135
|
+
? `github.com/${owner}/${repo}/${subpath}`
|
|
136
|
+
: `github.com/${owner}/${repo}`
|
|
137
|
+
return `${fq}${ref}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return input
|
|
141
|
+
}
|
|
142
|
+
|
|
83
143
|
function exitInvalidLocator(locator: string): never {
|
|
84
144
|
console.error(`❌ Invalid locator: ${locator}`)
|
|
85
|
-
console.error(`
|
|
86
|
-
console.error(`
|
|
87
|
-
console.error(`
|
|
88
|
-
console.error(`
|
|
145
|
+
console.error(` Accepted formats:`)
|
|
146
|
+
console.error(` github.com/owner/repo[/skill] — FQ locator (cold pool path, NOT a browser URL)`)
|
|
147
|
+
console.error(` owner/repo — GitHub shorthand`)
|
|
148
|
+
console.error(` owner/repo@skill — skills.sh syntax`)
|
|
149
|
+
console.error(` owner/repo/subpath — subdirectory`)
|
|
150
|
+
console.error(` github:owner/repo — explicit GitHub prefix`)
|
|
151
|
+
console.error(` localhost/<name> — local-only skill`)
|
|
152
|
+
console.error(``)
|
|
153
|
+
console.error(` Note: FQ locators look like URLs but map to cold pool paths:`)
|
|
154
|
+
console.error(` github.com/o/r/skills/s → ~/.agents/skill-repos/github.com/o/r/skills/s/SKILL.md`)
|
|
89
155
|
process.exit(1)
|
|
90
156
|
}
|
|
91
157
|
|
|
@@ -99,8 +165,9 @@ export async function addSkill(
|
|
|
99
165
|
? resolvePath(options.deck)
|
|
100
166
|
: findDeckToml(workdir) || join(workdir, 'skill-deck.toml')
|
|
101
167
|
|
|
102
|
-
const
|
|
103
|
-
|
|
168
|
+
const normalized = normalizeSkillsSh(locator)
|
|
169
|
+
const parsed = parseLocator(normalized)
|
|
170
|
+
if (!parsed) exitInvalidLocator(normalized)
|
|
104
171
|
|
|
105
172
|
if (parsed.isLocalhost) {
|
|
106
173
|
console.error(`❌ deck add does not support localhost locators (no remote to clone).`)
|
|
@@ -113,7 +180,13 @@ export async function addSkill(
|
|
|
113
180
|
const fetchPlan = buildFetchPlan(pool, parsed)
|
|
114
181
|
const fqPath = fqOf(parsed)
|
|
115
182
|
const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo!
|
|
116
|
-
const
|
|
183
|
+
const rawAlias = options.alias || skillName
|
|
184
|
+
try { validateAlias(rawAlias) } catch (e: any) {
|
|
185
|
+
console.error(`❌ Invalid alias: ${e.message}`)
|
|
186
|
+
console.error(' Aliases may only contain letters, numbers, hyphens, and underscores.')
|
|
187
|
+
process.exit(1)
|
|
188
|
+
}
|
|
189
|
+
const alias = rawAlias
|
|
117
190
|
const skillType = (options.type || 'tool').toLowerCase()
|
|
118
191
|
|
|
119
192
|
if (!['innate', 'tool', 'combo'].includes(skillType)) {
|
|
@@ -190,6 +263,9 @@ export async function addSkill(
|
|
|
190
263
|
|
|
191
264
|
console.log(`✅ Skill ready: ${skillName} (alias: ${alias})`)
|
|
192
265
|
console.log(` Location: ${skillDir}`)
|
|
266
|
+
if (parsed.host === 'github.com') {
|
|
267
|
+
console.log(` Source: https://github.com/${parsed.owner}/${parsed.repo}`)
|
|
268
|
+
}
|
|
193
269
|
|
|
194
270
|
// ── 写 deck.toml ────────────────────────────────────────────
|
|
195
271
|
|
|
@@ -241,7 +317,18 @@ export async function addSkill(
|
|
|
241
317
|
deck[skillType].skills = dict
|
|
242
318
|
}
|
|
243
319
|
|
|
244
|
-
|
|
320
|
+
const entry: Record<string, string> = { path: fqPath }
|
|
321
|
+
if (parsed.host === 'github.com') {
|
|
322
|
+
const skillRel = parsed.skill ? `/${parsed.skill}` : ''
|
|
323
|
+
const rawRef = parsed.ref || 'HEAD'
|
|
324
|
+
// Reject refs that could inject into URL (query, fragment, auth)
|
|
325
|
+
if (/[?#@]/.test(rawRef)) {
|
|
326
|
+
console.warn(`⚠️ Ref "${rawRef}" contains URL-special characters — source URL skipped`)
|
|
327
|
+
} else {
|
|
328
|
+
entry.source = `https://github.com/${parsed.owner}/${parsed.repo}/blob/${rawRef}${skillRel}/SKILL.md`
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
deck[skillType].skills[alias] = entry
|
|
245
332
|
writeFileSync(deckPath, stringifyToml(deck))
|
|
246
333
|
console.log(`📝 Added "${alias}" to [${skillType}.skills] in ${deckPath}`)
|
|
247
334
|
} else {
|
package/src/link.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "./schema.js";
|
|
25
25
|
import { parseDeck } from "./parse-deck.js";
|
|
26
26
|
import { resolveDeckPathSync, fetchDeckUrl, isUrl } from "./resolve-deck.js";
|
|
27
|
+
import { safeResolveInDir } from "./path-guard.js";
|
|
27
28
|
|
|
28
29
|
// ── 路径工具 ────────────────────────────────────────────────
|
|
29
30
|
|
|
@@ -203,7 +204,13 @@ for (const entry of parsedEntries) {
|
|
|
203
204
|
// For localhost skills, create a placeholder so the user can fill it in
|
|
204
205
|
if (entry.path.startsWith('localhost/')) {
|
|
205
206
|
const skill = entry.path.slice('localhost/'.length)
|
|
206
|
-
|
|
207
|
+
let localPath: string
|
|
208
|
+
try {
|
|
209
|
+
localPath = safeResolveInDir(COLD_POOL, skill)
|
|
210
|
+
} catch (e: any) {
|
|
211
|
+
errors.push(`Invalid localhost path "${entry.path}": ${e.message}`)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
207
214
|
if (!existsSync(join(localPath, 'SKILL.md'))) {
|
|
208
215
|
const now = new Date().toISOString().slice(0, 10)
|
|
209
216
|
const placeholder = [
|
|
@@ -366,8 +373,8 @@ if (nonSymlinks.length > 0) {
|
|
|
366
373
|
mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
|
|
367
374
|
|
|
368
375
|
const tarArgs = [
|
|
369
|
-
"czf", bakPath,
|
|
370
|
-
...nonSymlinks.map(e => relative(PROJECT_DIR, join(WORKING_SET, e))),
|
|
376
|
+
"czf", bakPath, "--",
|
|
377
|
+
...nonSymlinks.map(e => "./" + relative(PROJECT_DIR, join(WORKING_SET, e))),
|
|
371
378
|
];
|
|
372
379
|
try {
|
|
373
380
|
execFileSync("tar", tarArgs, {
|
package/src/parse-deck.ts
CHANGED
|
@@ -19,7 +19,12 @@ export interface ParsedDeck {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function parseDeck(raw: string): ParsedDeck {
|
|
22
|
-
|
|
22
|
+
let parsed: any;
|
|
23
|
+
try {
|
|
24
|
+
parsed = parseToml(raw) as any;
|
|
25
|
+
} catch (e: any) {
|
|
26
|
+
return { entries: [], deprecated: false, errors: [`TOML parse error: ${e.message}`] };
|
|
27
|
+
}
|
|
23
28
|
const entries: ParsedSkillEntry[] = [];
|
|
24
29
|
const errors: string[] = [];
|
|
25
30
|
let deprecated = false;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-guard — Centralized path traversal prevention for deck CLI.
|
|
3
|
+
*
|
|
4
|
+
* All alias validation and safe-path construction MUST go through these
|
|
5
|
+
* functions. Individual commands (link, remove, to-symlink-snapshot, add)
|
|
6
|
+
* must NOT sanitize aliases or resolve paths by hand.
|
|
7
|
+
*
|
|
8
|
+
* Reference: CWE-22 (Path Traversal), OWASP A01:2021
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve } from 'node:path'
|
|
12
|
+
import { realpathSync, existsSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Characters allowed in skill aliases.
|
|
16
|
+
*
|
|
17
|
+
* Allow-list (not block-list): alphanumeric + hyphens + underscores only.
|
|
18
|
+
* Rejecting '.' '/' '\\' prevents traversal. Rejecting '..' explicitly
|
|
19
|
+
* prevents parent-directory escapes even if a '.' were to slip through.
|
|
20
|
+
*/
|
|
21
|
+
const ALIAS_ALLOWED = /^[A-Za-z0-9_-]+$/
|
|
22
|
+
const ALIAS_MAX_LENGTH = 128
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate a skill alias. Returns the alias unchanged if valid, throws otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Aliases serve as directory names in the working set (.claude/skills/<alias>/).
|
|
28
|
+
* They must be simple names — no path separators, no dots, no special chars.
|
|
29
|
+
*/
|
|
30
|
+
export function validateAlias(alias: string): string {
|
|
31
|
+
if (!alias || alias.length === 0) {
|
|
32
|
+
throw new Error(`Alias must not be empty`)
|
|
33
|
+
}
|
|
34
|
+
if (alias.length > ALIAS_MAX_LENGTH) {
|
|
35
|
+
throw new Error(`Alias too long (max ${ALIAS_MAX_LENGTH} chars): ${alias.slice(0, 50)}...`)
|
|
36
|
+
}
|
|
37
|
+
if (!ALIAS_ALLOWED.test(alias)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Invalid alias "${alias}". Aliases may only contain letters, numbers, hyphens, and underscores. ` +
|
|
40
|
+
`No dots, slashes, or special characters.`
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return alias
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a path segment within a root directory, rejecting traversals.
|
|
48
|
+
*
|
|
49
|
+
* Steps:
|
|
50
|
+
* 1. Resolve root + segment to absolute path
|
|
51
|
+
* 2. If root exists on disk, resolve symlinks (realpath) for both
|
|
52
|
+
* 3. Verify the resolved path stays within the resolved root
|
|
53
|
+
*/
|
|
54
|
+
export function safeResolveInDir(root: string, segment: string): string {
|
|
55
|
+
// Pre-check: reject segments that are obviously malicious before resolve()
|
|
56
|
+
if (segment.includes('\0')) {
|
|
57
|
+
throw new Error('Path segment contains null byte')
|
|
58
|
+
}
|
|
59
|
+
if (segment.includes('..')) {
|
|
60
|
+
throw new Error('Path segment contains parent traversal (..)')
|
|
61
|
+
}
|
|
62
|
+
if (segment.startsWith('/') || /^[A-Za-z]:/.test(segment)) {
|
|
63
|
+
throw new Error('Path segment is absolute')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resolved = resolve(root, segment)
|
|
67
|
+
|
|
68
|
+
// If root exists, use realpath for symlink-aware boundary check
|
|
69
|
+
if (existsSync(root)) {
|
|
70
|
+
const realRoot = realpathSync(root)
|
|
71
|
+
let realPath: string
|
|
72
|
+
try {
|
|
73
|
+
realPath = realpathSync(resolved)
|
|
74
|
+
} catch {
|
|
75
|
+
// Path doesn't exist yet (e.g. mkdir first) — resolve is sufficient
|
|
76
|
+
// since we already rejected '../' and absolute paths
|
|
77
|
+
if (!resolved.startsWith(resolve(root) + '/') && resolved !== resolve(root)) {
|
|
78
|
+
throw new Error(`Path traversal blocked: ${segment} resolves outside ${root}`)
|
|
79
|
+
}
|
|
80
|
+
return resolved
|
|
81
|
+
}
|
|
82
|
+
if (!realPath.startsWith(realRoot + '/') && realPath !== realRoot) {
|
|
83
|
+
throw new Error(`Path traversal blocked: ${segment} resolves outside ${root}`)
|
|
84
|
+
}
|
|
85
|
+
return realPath
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Root doesn't exist yet — trust resolve() since we pre-checked segments
|
|
89
|
+
return resolved
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify a working_set directory is safe for deck operations.
|
|
94
|
+
*
|
|
95
|
+
* The working set is where deck creates/removes symlinks and snapshots.
|
|
96
|
+
* It must not be a system-critical path.
|
|
97
|
+
*/
|
|
98
|
+
const FORBIDDEN_ROOTS = new Set([
|
|
99
|
+
'/', '/home', '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64',
|
|
100
|
+
'/var', '/tmp', '/opt', '/root', '/boot', '/dev', '/proc', '/sys',
|
|
101
|
+
'/System', '/Applications', '/Library', // macOS
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
export function validateWorkingSet(workingSet: string, projectDir: string): void {
|
|
105
|
+
const resolved = resolve(workingSet)
|
|
106
|
+
|
|
107
|
+
if (FORBIDDEN_ROOTS.has(resolved)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`working_set "${workingSet}" resolves to a forbidden system path "${resolved}". ` +
|
|
110
|
+
`The working set must be inside the project or under a dedicated agents directory.`
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// It must be under the project, OR be a hidden directory (.claude/skills, .agents/skills, etc.)
|
|
115
|
+
const resolvedProject = resolve(projectDir)
|
|
116
|
+
if (resolved.startsWith(resolvedProject + '/')) return
|
|
117
|
+
|
|
118
|
+
// Outside project — OK only if it's a hidden dir (agent convention)
|
|
119
|
+
const basename = resolved.split('/').pop()!
|
|
120
|
+
if (!basename.startsWith('.')) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`working_set "${workingSet}" is outside the project and not a hidden directory. ` +
|
|
123
|
+
`Agent skill directories should start with '.' (e.g. .claude/skills, .agents/skills).`
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Re-exports for convenience ───────────────────────────
|
|
129
|
+
|
|
130
|
+
export { ALIAS_ALLOWED, ALIAS_MAX_LENGTH }
|
package/src/remove.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { findDeckToml, expandHome } from "./link.js";
|
|
|
13
13
|
import { parseDeck } from "./parse-deck.js";
|
|
14
14
|
import { ColdPool } from "@lythos/cold-pool";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import {
|
|
16
|
+
import { validateAlias } from "./path-guard.js";
|
|
17
17
|
|
|
18
18
|
export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
|
|
19
19
|
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
|
|
@@ -54,6 +54,13 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
|
|
|
54
54
|
const section = match.type;
|
|
55
55
|
const alias = match.alias;
|
|
56
56
|
|
|
57
|
+
// Validate alias before using as path component (CWE-22)
|
|
58
|
+
try { validateAlias(alias) } catch (e: any) {
|
|
59
|
+
console.error(`❌ Invalid alias in deck.toml: ${e.message}`)
|
|
60
|
+
console.error(` Fix skill-deck.toml before re-running.`)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
if (deck[section]?.skills) {
|
|
58
65
|
if (Array.isArray(deck[section].skills)) {
|
|
59
66
|
// Legacy string-array format
|
|
@@ -106,3 +113,4 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
|
|
|
106
113
|
|
|
107
114
|
console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/cold-pool prune' to GC unreferenced repos.`);
|
|
108
115
|
}
|
|
116
|
+
|
package/src/resolve-deck.ts
CHANGED
|
@@ -44,6 +44,14 @@ export function resolveDeckPathSync(cliArg?: string): ResolvedDeck {
|
|
|
44
44
|
export async function fetchDeckUrl(url: string): Promise<string> {
|
|
45
45
|
const normalized = normalizeUrl(url)
|
|
46
46
|
const dest = resolve(process.cwd(), 'skill-deck.toml')
|
|
47
|
+
if (existsSync(dest)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Refusing to overwrite existing ${dest}.\n` +
|
|
50
|
+
` A skill-deck.toml already exists in this directory.\n` +
|
|
51
|
+
` To use a remote deck, run from an empty directory or specify a different --deck path.\n` +
|
|
52
|
+
` To keep your existing deck, use a local file: deck link --deck ./skill-deck.toml`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
47
55
|
console.log(`📥 Fetching deck: ${normalized}`)
|
|
48
56
|
const res = await fetch(normalized, { signal: AbortSignal.timeout(30_000) })
|
|
49
57
|
if (!res.ok) {
|
|
@@ -16,6 +16,7 @@ import { ColdPool, parseLocator } from '@lythos/cold-pool'
|
|
|
16
16
|
import { findSource } from './link.js'
|
|
17
17
|
import { parse as parseToml } from '@iarna/toml'
|
|
18
18
|
import type { SkillDeckLock } from './schema.js'
|
|
19
|
+
import { validateAlias } from './path-guard.js'
|
|
19
20
|
|
|
20
21
|
function readLock(projectDir: string): SkillDeckLock | null {
|
|
21
22
|
const lockPath = join(projectDir, 'skill-deck.lock')
|
|
@@ -66,6 +67,11 @@ export function toSymlinkSkill(target: string, cliDeckPath?: string, cliWorkdir?
|
|
|
66
67
|
process.exit(1)
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
try { validateAlias(match.alias) } catch (e: any) {
|
|
71
|
+
console.error(`❌ Invalid alias in deck.toml: ${e.message}`)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
const dest = join(WORKING_SET, match.alias)
|
|
70
76
|
const source = findSource(match.path, COLD_POOL, PROJECT_DIR)
|
|
71
77
|
|
|
@@ -122,6 +128,11 @@ export function toSnapshotSkill(target: string, cliDeckPath?: string, cliWorkdir
|
|
|
122
128
|
process.exit(1)
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
try { validateAlias(match.alias) } catch (e: any) {
|
|
132
|
+
console.error(`❌ Invalid alias in deck.toml: ${e.message}`)
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
const dest = join(WORKING_SET, match.alias)
|
|
126
137
|
const source = findSource(match.path, COLD_POOL, PROJECT_DIR)
|
|
127
138
|
|