@lythos/skill-deck 0.3.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 CHANGED
@@ -1,40 +1,130 @@
1
1
  # @lythos/skill-deck
2
2
 
3
- > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks.
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
- Part of the [lythoskill](https://github.com/lythos-labs/lythoskill) meta-skill ecosystem.
5
+ ## For AI Agents
6
6
 
7
- ## What it does
7
+ This package exposes a **CLI**. Invoke via:
8
8
 
9
- Manages your agent's working set of skills. You declare which skills you want in `skill-deck.toml`; `deck link` creates symlinks from the cold pool to `.claude/skills/`. Supports deny-by-default isolation, max_cards budgeting, transient expiry, and managed directory overlap detection.
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
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
- ## Commands
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
- # Link declared skills to working set
90
+ # 1. Create a skill-deck.toml
91
+ cat > skill-deck.toml << 'EOF'
92
+ [deck]
93
+ max_cards = 10
94
+
95
+ [tool]
96
+ skills = ["lythoskill-deck"]
97
+ EOF
98
+
99
+ # 2. Link — creates symlinks in .claude/skills/
23
100
  bunx @lythos/skill-deck link
101
+ ```
24
102
 
25
- # Link with custom deck file
26
- bunx @lythos/skill-deck link --deck ./my-deck.toml
103
+ ### Key Concepts
27
104
 
28
- # Show current deck status
29
- bunx @lythos/skill-deck status
105
+ | Concept | One-liner |
106
+ |---------|-----------|
107
+ | **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
108
+ | **skill-deck.toml** | Declares desired state: "this project uses these skills." |
109
+ | **`deck link`** | Reconciler. Makes `.claude/skills/` match the declaration. |
110
+ | **Working Set** | `.claude/skills/` — symlinks only. What the agent actually scans. |
111
+ | **deny-by-default** | Undeclared skills are physically absent from the working set. |
30
112
 
31
- # Migrate from old deck format
32
- bunx @lythos/skill-deck migrate
33
- ```
113
+ ## Skill Documentation
114
+
115
+ This package is the **Starter** layer (CLI implementation).
116
+ The agent-visible **Skill** layer documentation is here:
117
+ [packages/lythoskill-deck/skill/SKILL.md](../../packages/lythoskill-deck/skill/SKILL.md)
34
118
 
35
119
  ## Architecture
36
120
 
37
- This is the **Starter** layer of the thin-skill pattern. The agent-visible **Skill** layer lives in `packages/lythoskill-deck/skill/` and is built to `skills/lythoskill-deck/`.
121
+ Part of the [lythoskill](https://github.com/lythos-labs/lythoskill) ecosystem the thin-skill pattern separates heavy logic (this npm package) from lightweight agent instructions (SKILL.md).
122
+
123
+ ```
124
+ Starter (this package) → npm publish → bunx @lythos/skill-deck ...
125
+ Skill (packages/<name>/skill/) → build → SKILL.md + thin scripts
126
+ Output (skills/<name>/) → git commit → agent-visible skill
127
+ ```
38
128
 
39
129
  ## License
40
130
 
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "skill",
8
+ "claude-code",
9
+ "agent-skills",
10
+ "llm-tooling",
11
+ "lythoskill"
12
+ ],
13
+ "author": "lythos-labs",
5
14
  "license": "MIT",
6
15
  "type": "module",
7
16
  "bin": {
@@ -14,6 +23,16 @@
14
23
  ],
15
24
  "dependencies": {
16
25
  "@iarna/toml": "^2.2.5",
26
+ "yaml": "^2.8.3",
17
27
  "zod": "^4.3.6"
18
- }
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/lythos-labs/lythoskill.git",
32
+ "directory": "packages/lythoskill-deck"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/lythos-labs/lythoskill/issues"
36
+ },
37
+ "homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-deck#readme"
19
38
  }
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
package/src/link.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { parse as parseToml } from "@iarna/toml";
11
+ import YAML from "yaml";
11
12
  import { createHash } from "crypto";
12
13
  import {
13
14
  existsSync, mkdirSync, readFileSync, readdirSync,
@@ -36,49 +37,17 @@ export function expandHome(p: string, base: string): string {
36
37
  function hashContent(content: string): string {
37
38
  return createHash("sha256").update(content).digest("hex");
38
39
  }
39
-
40
40
  // ── Front matter 提取 ───────────────────────────────────────
41
41
 
42
- function getFrontMatter(skillMdPath: string): string {
42
+ function parseSkillFrontmatter(skillMdPath: string): Record<string, any> {
43
43
  try {
44
44
  const c = readFileSync(skillMdPath, "utf-8");
45
- if (!c.startsWith("---")) return "";
46
- const parts = c.split("---");
47
- return parts.length >= 3 ? parts[1] : "";
48
- } catch { return ""; }
49
- }
50
-
51
- function extractField(fm: string, field: string): string {
52
- const m = fm.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
53
- return m ? m[1].trim() : "";
45
+ const match = c.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
46
+ if (!match) return {};
47
+ return YAML.parse(match[1]) || {};
48
+ } catch { return {}; }
54
49
  }
55
50
 
56
- function extractArrayField(fm: string, field: string): string[] {
57
- const lines = fm.split("\n");
58
- const results: string[] = [];
59
- let collecting = false;
60
- for (const line of lines) {
61
- if (line.match(new RegExp(`^${field}:\\s*$`))) {
62
- collecting = true;
63
- continue;
64
- }
65
- if (line.match(new RegExp(`^${field}:\\s*\\[`))) {
66
- const inline = line.match(/\[(.+)\]/);
67
- if (inline) return inline[1].split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
68
- collecting = true;
69
- continue;
70
- }
71
- if (collecting) {
72
- const item = line.match(/^\s+-\s+(.+)/);
73
- if (item) {
74
- results.push(item[1].trim().replace(/^["']|["']$/g, ""));
75
- } else if (line.trim() !== "" && !line.match(/^\s*#/)) {
76
- break;
77
- }
78
- }
79
- }
80
- return results;
81
- }
82
51
 
83
52
  // ── 冷池查找 ────────────────────────────────────────────────
84
53
 
@@ -253,9 +222,13 @@ for (const item of declared) {
253
222
 
254
223
  // 提取元数据
255
224
  const skillMdPath = join(item.sourcePath, "SKILL.md");
256
- const fm = getFrontMatter(skillMdPath);
257
- const niche = extractField(fm, "deck_niche");
258
- const managedDirs = extractArrayField(fm, "deck_managed_dirs");
225
+ const fm = parseSkillFrontmatter(skillMdPath);
226
+ const niche = String(fm["deck_niche"] || "");
227
+ const managedDirs = Array.isArray(fm["deck_managed_dirs"])
228
+ ? fm["deck_managed_dirs"].map(String)
229
+ : fm["deck_managed_dirs"]
230
+ ? [String(fm["deck_managed_dirs"])]
231
+ : [];
259
232
  let contentHash: string | undefined;
260
233
  try {
261
234
  contentHash = hashContent(readFileSync(skillMdPath, "utf-8"));