@lythos/skill-deck 0.4.0 → 0.5.0

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.
Files changed (4) hide show
  1. package/README.md +79 -27
  2. package/package.json +1 -1
  3. package/src/add.ts +166 -0
  4. package/src/cli.ts +24 -7
package/README.md CHANGED
@@ -1,22 +1,90 @@
1
1
  # @lythos/skill-deck
2
2
 
3
- > Declarative skill deck governance for AI agents. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
3
+ > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
4
4
 
5
- ## Why
5
+ ## For AI Agents
6
6
 
7
- When an AI agent has access to 50+ skills, context window pollution and silent conflicts become real problems. Two skills claiming the same niche, redundant descriptions, incompatible assumptions — all invisible until the agent hallucinates.
7
+ This package exposes a **CLI**. Invoke via:
8
8
 
9
- `skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to `.claude/skills/` and **removes everything else**. Deny-by-default means undeclared skills physically do not exist in the agent's view.
9
+ ```bash
10
+ bunx @lythos/skill-deck <command> [options]
11
+ ```
10
12
 
11
- ## Install
13
+ No installation required. `bunx` auto-downloads the package.
12
14
 
13
- ```bash
14
- bun add -d @lythos/skill-deck
15
- # or use directly
16
- bunx @lythos/skill-deck <command>
15
+ ### skill-deck.toml (minimal)
16
+
17
+ ```toml
18
+ [deck]
19
+ max_cards = 10
20
+
21
+ [tool]
22
+ skills = ["lythoskill-deck"]
17
23
  ```
18
24
 
19
- ## Quick Start
25
+ ### skill-deck.toml (full reference)
26
+
27
+ ```toml
28
+ [deck]
29
+ max_cards = 10 # Hard limit on total skills
30
+ working_set = ".claude/skills" # Where symlinks are created
31
+ cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
32
+
33
+ [innate] # Always-loaded skills
34
+ skills = ["lythoskill-deck"]
35
+
36
+ [tool] # Auto-triggered skills
37
+ skills = ["skill-a", "skill-b"]
38
+
39
+ [combo] # Multi-skill bundles
40
+ skills = ["report-generation-combo"]
41
+
42
+ [transient] # Temporary skills with expiry
43
+ [transient.handoff]
44
+ path = "./skills/handoff" # Local path (not cold pool)
45
+ expires = "2026-05-01" # ISO date; warns at ≤14 days
46
+ ```
47
+
48
+ ### When to invoke
49
+
50
+ | Situation | Command |
51
+ |-----------|---------|
52
+ | Sync `.claude/skills/` with `skill-deck.toml` | `bunx @lythos/skill-deck link` |
53
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck validate` |
54
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck link --deck ./my-deck.toml --workdir /path/to/project` |
55
+
56
+ ### Commands
57
+
58
+ | Command | Args | Description |
59
+ |---------|------|-------------|
60
+ | `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
61
+ | `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
62
+
63
+ ### Options
64
+
65
+ | Flag | Description | Default |
66
+ |------|-------------|---------|
67
+ | `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
68
+ | `--workdir <dir>` | Working directory | cwd |
69
+
70
+ ### Exit codes
71
+
72
+ | Code | Meaning |
73
+ |------|---------|
74
+ | `0` | Success |
75
+ | `1` | Validation failed, deck not found, or budget exceeded |
76
+
77
+ ---
78
+
79
+ ## For Humans
80
+
81
+ ### Why
82
+
83
+ When an AI agent has access to 50+ skills, context window pollution and silent conflicts become real problems. Two skills claiming the same niche, redundant descriptions, incompatible assumptions — all invisible until the agent hallucinates.
84
+
85
+ `skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to `.claude/skills/` and **removes everything else**. Deny-by-default means undeclared skills physically do not exist in the agent's view.
86
+
87
+ ### Quick Start
20
88
 
21
89
  ```bash
22
90
  # 1. Create a skill-deck.toml
@@ -32,23 +100,7 @@ EOF
32
100
  bunx @lythos/skill-deck link
33
101
  ```
34
102
 
35
- ## Commands
36
-
37
- ```
38
- lythoskill-deck — Declarative skill deck governance — cold pool, working set, deny-by-default
39
-
40
- Usage: lythoskill-deck link | lythoskill-deck validate [deck.toml]
41
-
42
- Commands:
43
- link Sync working set with skill-deck.toml
44
- validate [deck.toml] Validate deck configuration
45
-
46
- Options:
47
- --deck <path> Specify skill-deck.toml path
48
- --workdir <dir> Specify working directory
49
- ```
50
-
51
- ## Key Concepts
103
+ ### Key Concepts
52
104
 
53
105
  | Concept | One-liner |
54
106
  |---------|-----------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/add.ts ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-add.ts — Skill acquisition command
4
+ *
5
+ * Downloads a skill to the cold pool, updates skill-deck.toml, and links.
6
+ * Supports multiple backends (git clone, skills.sh) without locking users
7
+ * into a single download method.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
11
+ import { mkdtempSync } from 'node:fs'
12
+ import { tmpdir, homedir } from 'node:os'
13
+ import { join, basename, dirname, resolve } from 'node:path'
14
+ import { execSync } from 'node:child_process'
15
+ import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
16
+ import { findDeckToml, expandHome } from './link.js'
17
+
18
+ interface ParsedLocator {
19
+ host: string
20
+ owner: string
21
+ repo: string
22
+ skill: string | null
23
+ raw: string
24
+ }
25
+
26
+ function parseLocator(input: string): ParsedLocator | null {
27
+ // Format: host.tld/owner/repo/skill or host.tld/owner/repo
28
+ // owner/repo/skill or owner/repo (shorthand for github.com)
29
+ const parts = input.split('/').filter(Boolean)
30
+ if (parts.length < 2) return null
31
+
32
+ const hasHost = parts[0].includes('.')
33
+
34
+ if (hasHost) {
35
+ if (parts.length < 3) return null
36
+ const host = parts[0]
37
+ const owner = parts[1]
38
+ const repo = parts[2]
39
+ const skill = parts.length > 3 ? parts.slice(3).join('/') : null
40
+ return { host, owner, repo, skill, raw: input }
41
+ }
42
+
43
+ const host = 'github.com'
44
+ const owner = parts[0]
45
+ const repo = parts[1]
46
+ const skill = parts.length > 2 ? parts.slice(2).join('/') : null
47
+ return { host, owner, repo, skill, raw: input }
48
+ }
49
+
50
+ function findSkillDir(repoPath: string, skill: string | null): string | null {
51
+ if (skill) {
52
+ const inSkills = join(repoPath, 'skills', skill)
53
+ if (existsSync(join(inSkills, 'SKILL.md'))) return inSkills
54
+ const direct = join(repoPath, skill)
55
+ if (existsSync(join(direct, 'SKILL.md'))) return direct
56
+ return null
57
+ }
58
+ if (existsSync(join(repoPath, 'SKILL.md'))) return repoPath
59
+ const skillsDir = join(repoPath, 'skills')
60
+ if (existsSync(skillsDir)) {
61
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
62
+ const dirs = entries.filter(e => e.isDirectory())
63
+ if (dirs.length === 1) {
64
+ const candidate = join(skillsDir, dirs[0].name)
65
+ if (existsSync(join(candidate, 'SKILL.md'))) return candidate
66
+ }
67
+ }
68
+ return null
69
+ }
70
+
71
+ function resolvePath(p: string): string {
72
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2))
73
+ return resolve(p)
74
+ }
75
+
76
+ export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string }) {
77
+ const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
78
+ const deckPath = options.deck
79
+ ? resolvePath(options.deck)
80
+ : findDeckToml(workdir) || join(workdir, 'skill-deck.toml')
81
+
82
+ const parsed = parseLocator(locator)
83
+ if (!parsed) {
84
+ console.error(`❌ Invalid locator: ${locator}`)
85
+ console.error(` Expected: github.com/owner/repo[/skill] or owner/repo[/skill]`)
86
+ process.exit(1)
87
+ }
88
+
89
+ const backend = options.via || 'git'
90
+
91
+ let coldPool = join(homedir(), '.agents', 'skill-repos')
92
+ if (existsSync(deckPath)) {
93
+ try {
94
+ const deckRaw = readFileSync(deckPath, 'utf-8')
95
+ const deck = parseToml(deckRaw) as any
96
+ coldPool = expandHome(deck.deck?.cold_pool || '~/.agents/skill-repos', workdir)
97
+ } catch { /* use default */ }
98
+ }
99
+
100
+ const targetDir = join(coldPool, parsed.host, parsed.owner, parsed.repo)
101
+
102
+ if (existsSync(targetDir)) {
103
+ console.error(`❌ Already exists in cold pool: ${targetDir}`)
104
+ console.error(` To update: rm -rf ${targetDir} and re-run`)
105
+ process.exit(1)
106
+ }
107
+
108
+ const tmpDir = mkdtempSync(join(tmpdir(), 'lythoskill-deck-add-'))
109
+ const tmpRepo = join(tmpDir, 'repo')
110
+
111
+ try {
112
+ if (backend === 'skills.sh' || backend === 'vercel') {
113
+ const skillsShLocator = `${parsed.owner}/${parsed.repo}`
114
+ console.log(`📦 Downloading via skills.sh: ${skillsShLocator}`)
115
+ execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
116
+ console.error(`⚠️ skills.sh backend: manual cold-pool placement may be needed`)
117
+ } else {
118
+ const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
119
+ console.log(`📦 Cloning: ${gitUrl}`)
120
+ execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
121
+ }
122
+
123
+ mkdirSync(dirname(targetDir), { recursive: true })
124
+ renameSync(tmpRepo, targetDir)
125
+
126
+ const skillDir = findSkillDir(targetDir, parsed.skill)
127
+ if (!skillDir) {
128
+ console.error(`❌ No SKILL.md found in downloaded repo`)
129
+ console.error(` Checked: ${targetDir}`)
130
+ process.exit(1)
131
+ }
132
+
133
+ const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
134
+ console.log(`✅ Skill ready: ${skillName}`)
135
+ console.log(` Location: ${skillDir}`)
136
+
137
+ if (existsSync(deckPath)) {
138
+ const deckRaw = readFileSync(deckPath, 'utf-8')
139
+ const deck = parseToml(deckRaw) as any
140
+ const toolSkills = deck.tool?.skills || []
141
+ if (!toolSkills.includes(skillName)) {
142
+ if (!deck.tool) deck.tool = {}
143
+ if (!deck.tool.skills) deck.tool.skills = []
144
+ deck.tool.skills.push(skillName)
145
+ writeFileSync(deckPath, stringifyToml(deck))
146
+ console.log(`📝 Added "${skillName}" to ${deckPath}`)
147
+ } else {
148
+ console.log(`📝 "${skillName}" already declared in ${deckPath}`)
149
+ }
150
+ } else {
151
+ const minimal = { deck: { max_cards: 10 }, tool: { skills: [skillName] } }
152
+ writeFileSync(deckPath, stringifyToml(minimal))
153
+ console.log(`📝 Created ${deckPath} with "${skillName}"`)
154
+ }
155
+
156
+ console.log('🔗 Running deck link...')
157
+ const { linkDeck } = await import('./link.js')
158
+ linkDeck(deckPath === join(workdir, 'skill-deck.toml') ? undefined : deckPath, workdir)
159
+
160
+ } catch (err) {
161
+ console.error(`❌ Failed to add skill: ${err}`)
162
+ process.exit(1)
163
+ } finally {
164
+ rmSync(tmpDir, { recursive: true, force: true })
165
+ }
166
+ }
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { linkDeck } from './link.js'
3
3
  import { validateDeck } from './validate.js'
4
+ import { addSkill } from './add.js'
4
5
  import { formatHelp } from './help.js'
5
6
 
6
7
  const HELP_CONFIG = {
@@ -8,19 +9,26 @@ const HELP_CONFIG = {
8
9
  description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
9
10
  commands: [
10
11
  { name: 'link', description: 'Sync working set with skill-deck.toml' },
12
+ { name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
11
13
  { name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
12
14
  ],
13
15
  options: [
14
- { flag: '--deck <path>', description: 'Specify skill-deck.toml path' },
15
- { flag: '--workdir <dir>', description: 'Specify working directory' },
16
+ { flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
17
+ { flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
18
+ { flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
16
19
  ],
17
20
  }
18
21
 
19
- const command = process.argv[2]
20
- const deckFlagIdx = process.argv.indexOf('--deck')
21
- const workdirFlagIdx = process.argv.indexOf('--workdir')
22
- const deckPath = deckFlagIdx >= 0 ? process.argv[deckFlagIdx + 1] : undefined
23
- const workdir = workdirFlagIdx >= 0 ? process.argv[workdirFlagIdx + 1] : undefined
22
+ const args = process.argv.slice(2)
23
+ const command = args[0]
24
+
25
+ const deckFlagIdx = args.indexOf('--deck')
26
+ const workdirFlagIdx = args.indexOf('--workdir')
27
+ const viaFlagIdx = args.indexOf('--via')
28
+
29
+ const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
30
+ const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
31
+ const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
24
32
 
25
33
  switch (command) {
26
34
  case '--help':
@@ -30,6 +38,15 @@ switch (command) {
30
38
  case 'link':
31
39
  linkDeck(deckPath, workdir)
32
40
  break
41
+ case 'add': {
42
+ const locator = args[1]
43
+ if (!locator) {
44
+ console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
45
+ process.exit(1)
46
+ }
47
+ await addSkill(locator, { via, deck: deckPath, workdir })
48
+ break
49
+ }
33
50
  case 'validate':
34
51
  validateDeck(deckPath, workdir)
35
52
  break