@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.
- package/README.md +79 -27
- package/package.json +1 -1
- package/src/add.ts +166 -0
- 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
|
|
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
|
-
##
|
|
5
|
+
## For AI Agents
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This package exposes a **CLI**. Invoke via:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```bash
|
|
10
|
+
bunx @lythos/skill-deck <command> [options]
|
|
11
|
+
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
No installation required. `bunx` auto-downloads the package.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
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
|