@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 CHANGED
@@ -2,14 +2,30 @@
2
2
 
3
3
  ![Coverage](https://img.shields.io/badge/coverage-82%25-brightgreen) ![CI](https://img.shields.io/badge/CI-71%20unit%20%2B%2021%20CLI%20BDD-brightgreen) ![Agent BDD](https://img.shields.io/badge/Agent%20BDD-5%20local-blue) ![Intent/Plan](https://img.shields.io/badge/arch-intent%2Fplan%2Fexecute-8A2BE2)
4
4
 
5
- > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
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.48 <command> [options]
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.48 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.48 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.48 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.48 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.48 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.48 remove tdd` |
64
- | Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.48 to-symlink tdd` |
65
- | Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.48 to-snapshot tdd` |
66
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.48 link --deck ./my-deck.toml --workdir /path/to/project` |
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>]` | Git clone skill to cold pool and append to skill-deck.toml. |
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.48 link
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.48 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.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.48",
3
+ "version": "0.9.49",
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
@@ -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(` Expected FQ form (per ADR-20260502012643244):`)
86
- console.error(` host.tld/owner/repo[/skill] — remote skill`)
87
- console.error(` localhost/<name> local-only skill`)
88
- console.error(` Bare names and shorthand 'owner/repo' are rejected.`)
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 parsed = parseLocator(locator)
103
- if (!parsed) exitInvalidLocator(locator)
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 alias = options.alias || skillName
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
- deck[skillType].skills[alias] = { path: fqPath }
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
- const localPath = join(COLD_POOL, skill)
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
- const parsed = parseToml(raw) as any;
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 { join } from "node:path";
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
+
@@ -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