@lythos/skill-deck 0.9.36 → 0.9.37

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
@@ -9,7 +9,7 @@
9
9
  This package exposes a **CLI**. Invoke via:
10
10
 
11
11
  ```bash
12
- bunx @lythos/skill-deck@0.9.36 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.37 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -55,17 +55,17 @@ prompt = "Search for latest info, then generate professional document with diagr
55
55
 
56
56
  | Situation | Command |
57
57
  |-----------|---------|
58
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.36 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.36 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.36 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.36 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.36 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.36 remove tdd` |
64
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.36 prune` |
65
- | Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.36 sync tdd` |
66
- | Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.36 freeze tdd` |
67
- | Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.36 reconcile` |
68
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.36 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.37 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.37 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.37 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.37 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.37 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.37 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.37 prune` |
65
+ | Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.37 sync tdd` |
66
+ | Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.37 freeze tdd` |
67
+ | Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.37 reconcile` |
68
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.37 link --deck ./my-deck.toml --workdir /path/to/project` |
69
69
 
70
70
  ### Commands
71
71
 
@@ -128,7 +128,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
128
128
  EOF
129
129
 
130
130
  # 2. Link — creates symlinks in .claude/skills/
131
- bunx @lythos/skill-deck@0.9.36 link
131
+ bunx @lythos/skill-deck@0.9.37 link
132
132
  ```
133
133
 
134
134
  ### Key Concepts
@@ -153,11 +153,68 @@ Different agents look for skills in different directories. `skill-deck.toml` con
153
153
 
154
154
  > **If you are an agent**: verify where your platform scans for skills, then set `working_set` to that path before running `deck link`.
155
155
 
156
+ ### For OpenClaw
157
+
158
+ OpenClaw loads skills from multiple locations, in priority order:
159
+
160
+ | Priority | Location | Use case |
161
+ |----------|----------|----------|
162
+ | 1 | `<workspace>/skills` | Workspace-level override |
163
+ | 2 | `<workspace>/.agents/skills` | **Project deck (recommended)** |
164
+ | 3 | `~/.agents/skills` | Personal agent skills |
165
+ | 4 | `~/.openclaw/skills` | Global managed skills |
166
+
167
+ **Per-project deck** (most common):
168
+ ```toml
169
+ [deck]
170
+ working_set = ".agents/skills"
171
+ ```
172
+ This matches OpenClaw's "project agent skills" path. Run `deck link` from your project root.
173
+
174
+ **Global deck** (manage all OpenClaw skills centrally):
175
+ ```toml
176
+ [deck]
177
+ working_set = "~/.openclaw/skills"
178
+ ```
179
+ Create this in your home directory. One global deck can syndicate to all projects via OpenClaw's fallback chain.
180
+
181
+ ### For Hermes
182
+
183
+ Hermes keeps skills in `~/.hermes/skills/` and supports scanning additional directories via `external_dirs` in `~/.hermes/config.yaml`. This makes Hermes + deck integration clean: deck manages the working set, Hermes reads it through `external_dirs`.
184
+
185
+ **Recommended: project-level deck + external_dirs**
186
+
187
+ ```toml
188
+ # skill-deck.toml
189
+ [deck]
190
+ working_set = ".hermes/skills"
191
+ ```
192
+
193
+ ```yaml
194
+ # ~/.hermes/config.yaml
195
+ skills:
196
+ external_dirs:
197
+ - /absolute/path/to/your/project/.hermes/skills
198
+ ```
199
+
200
+ Run `deck link` from your project root. Hermes picks up the syndicated skills without touching its primary `~/.hermes/skills/` directory.
201
+
202
+ **Alternative: direct mode (not recommended)**
203
+
204
+ You can point deck directly at `~/.hermes/skills`:
205
+
206
+ ```toml
207
+ [deck]
208
+ working_set = "~/.hermes/skills"
209
+ ```
210
+
211
+ Caution: deck's deny-by-default will remove any skills not declared in your deck, including Hermes' bundled skills. Only use this if your deck explicitly declares every skill you want Hermes to see.
212
+
156
213
  ### Troubleshooting
157
214
 
158
215
  | Symptom | Cause | Fix |
159
216
  |---------|-------|-----|
160
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.36 add github.com/owner/repo/skill` or clone manually into cold pool |
217
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.37 add github.com/owner/repo/skill` or clone manually into cold pool |
161
218
  | `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 |
162
219
  | `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 |
163
220
  | `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.36",
3
+ "version": "0.9.37",
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 CHANGED
@@ -249,10 +249,11 @@ export async function addSkill(
249
249
  '# Skill Deck — generated by lythoskill-deck',
250
250
  '# Edit working_set for your agent platform (uncomment one):',
251
251
  '# working_set = ".claude/skills" # Claude Code (also read by Cursor, Copilot)',
252
- '# working_set = ".agents/skills" # Codex CLI, OpenClaw',
252
+ '# working_set = ".agents/skills" # Codex CLI, OpenClaw (project-level)',
253
253
  '# working_set = ".cursor/skills" # Cursor-native',
254
254
  '# working_set = ".github/skills" # GitHub Copilot',
255
255
  '# working_set = ".windsurf/skills" # Windsurf',
256
+ '# For OpenClaw global skills: working_set = "~/.openclaw/skills" (global deck)',
256
257
  '# After editing, run: bunx @lythos/skill-deck@latest link',
257
258
  '',
258
259
  ].join('\n')
package/src/cli.ts CHANGED
@@ -61,7 +61,7 @@ const HELP_CONFIG = {
61
61
  { name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
62
62
  { name: 'sync', description: 'Switch skill from snapshot (cp) to sync (symlink)', args: '<alias>' },
63
63
  { name: 'freeze', description: 'Switch skill from sync (symlink) to snapshot (cp), pinning current HEAD', args: '<alias>' },
64
- { name: 'reconcile', description: 'Compare lock file (desired) vs cold pool (actual), report drift', args: '[--apply]' },
64
+ { name: 'reconcile', description: 'Compare lock file vs cold pool, report drift', args: '[--apply] [--yes]' },
65
65
  { name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
66
66
  ],
67
67
  options: [
@@ -141,7 +141,7 @@ switch (command) {
141
141
  }
142
142
  case 'reconcile': {
143
143
  const apply = args.includes('--apply')
144
- reconcileDeck(deckPath, workdir, apply)
144
+ await reconcileDeck(deckPath, workdir, apply, yes)
145
145
  break
146
146
  }
147
147
  case 'prune': {
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
+ import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { reconcileDeck } from './reconcile.js'
6
+
7
+ describe('reconcileDeck', () => {
8
+ let projectDir: string
9
+ let coldPoolDir: string
10
+
11
+ beforeEach(() => {
12
+ projectDir = mkdtempSync(join(tmpdir(), 'deck-reconcile-'))
13
+ coldPoolDir = join(projectDir, 'cold-pool')
14
+ mkdirSync(coldPoolDir, { recursive: true })
15
+ })
16
+
17
+ afterEach(() => {
18
+ // Cleanup handled by OS temp dir lifecycle
19
+ })
20
+
21
+ function writeLock(skills: any[]) {
22
+ const lock = {
23
+ version: '1.0.0' as const,
24
+ generated_at: new Date().toISOString(),
25
+ deck_source: { path: join(projectDir, 'skill-deck.toml'), content_hash: 'abc' },
26
+ working_set: '.claude/skills',
27
+ cold_pool: coldPoolDir,
28
+ skills,
29
+ constraints: {
30
+ total_cards: skills.length,
31
+ max_cards: 10,
32
+ within_budget: skills.length <= 10,
33
+ transient_warnings: [],
34
+ dir_overlaps: [],
35
+ },
36
+ }
37
+ writeFileSync(join(projectDir, 'skill-deck.lock'), JSON.stringify(lock, null, 2))
38
+ }
39
+
40
+ function writeDeck() {
41
+ writeFileSync(
42
+ join(projectDir, 'skill-deck.toml'),
43
+ `[deck]\ncold_pool = "${coldPoolDir}"\nworking_set = ".claude/skills"\n\n[tool.skills.test]\npath = "github.com/owner/repo/test"\n`
44
+ )
45
+ }
46
+
47
+ it('reports no drift when lock matches cold pool', async () => {
48
+ writeDeck()
49
+ writeLock([
50
+ {
51
+ name: 'test',
52
+ alias: 'test',
53
+ deck_niche: 'test',
54
+ type: 'tool',
55
+ source: 'github.com/owner/repo/test',
56
+ dest: join(projectDir, '.claude/skills/test'),
57
+ mode: 'symlink',
58
+ linked_at: new Date().toISOString(),
59
+ deck_managed_dirs: [],
60
+ },
61
+ ])
62
+
63
+ // No cold pool repo = missing, but that's fine for this test
64
+ // We just verify it doesn't crash
65
+ await reconcileDeck(join(projectDir, 'skill-deck.toml'), projectDir, false)
66
+ })
67
+
68
+ it('shows plan-only mode by default', async () => {
69
+ writeDeck()
70
+ writeLock([])
71
+
72
+ await reconcileDeck(join(projectDir, 'skill-deck.toml'), projectDir, false)
73
+ })
74
+
75
+ it('--apply requires --yes or TTY confirmation', async () => {
76
+ writeDeck()
77
+ writeLock([])
78
+
79
+ // With --yes, apply proceeds even without TTY
80
+ await reconcileDeck(join(projectDir, 'skill-deck.toml'), projectDir, true, true)
81
+ })
82
+ })
package/src/reconcile.ts CHANGED
@@ -11,10 +11,34 @@ import { existsSync, readFileSync } from 'node:fs'
11
11
  import { resolve, dirname, join } from 'node:path'
12
12
  import { findDeckToml, expandHome } from './link.js'
13
13
  import { parse as parseToml } from '@iarna/toml'
14
- import { ColdPool, buildReconcilePlan, type ReconcileDesiredState } from '@lythos/cold-pool'
14
+ import { ColdPool, buildReconcilePlan, type ReconcileDesiredState, getRepoHeadRef } from '@lythos/cold-pool'
15
15
  import { SkillDeckLockSchema } from './schema.js'
16
+ import { addSkill } from './add.js'
17
+ import { refreshDeck } from './refresh.js'
18
+ import { pruneDeck } from './prune.js'
16
19
 
17
- export function reconcileDeck(cliDeckPath?: string, cliWorkdir?: string, apply?: boolean): void {
20
+ function isTTY(): boolean {
21
+ return process.stdin.isTTY && process.stdout.isTTY
22
+ }
23
+
24
+ async function promptYesNo(question: string): Promise<boolean> {
25
+ if (!isTTY()) return false
26
+ const { createInterface } = await import('node:readline')
27
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
28
+ return new Promise((resolve) => {
29
+ rl.question(`${question} [y/N] `, (answer) => {
30
+ rl.close()
31
+ resolve(answer.trim().toLowerCase() === 'y')
32
+ })
33
+ })
34
+ }
35
+
36
+ export async function reconcileDeck(
37
+ cliDeckPath?: string,
38
+ cliWorkdir?: string,
39
+ apply?: boolean,
40
+ yes?: boolean,
41
+ ): Promise<void> {
18
42
  const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === '--deck')
19
43
  const DECK_PATH = cliDeck
20
44
  ? resolve(cliDeck)
@@ -52,11 +76,17 @@ export function reconcileDeck(cliDeckPath?: string, cliWorkdir?: string, apply?:
52
76
  const coldPoolRaw = lockData.cold_pool || '~/.agents/skill-repos'
53
77
  const COLD_POOL = expandHome(coldPoolRaw, PROJECT_DIR)
54
78
 
79
+ // Build alias → skill info map for locating missing skills
80
+ const skillByAlias = new Map<string, { source: string; type: string; mode: string }>()
81
+ for (const s of lockData.skills) {
82
+ skillByAlias.set(s.alias, { source: s.source, type: s.type, mode: s.mode })
83
+ }
84
+
55
85
  // Build desired state from lock
56
86
  const desired: ReconcileDesiredState = {
57
87
  deckPath: DECK_PATH,
58
- skills: lockData.skills.map(s => ({
59
- locator: s.source, // source is relative to cold pool, in FQ format
88
+ skills: lockData.skills.map((s) => ({
89
+ locator: s.source,
60
90
  alias: s.alias,
61
91
  })),
62
92
  }
@@ -99,16 +129,110 @@ export function reconcileDeck(cliDeckPath?: string, cliWorkdir?: string, apply?:
99
129
  console.log(` Reason: ${entry.reason}`)
100
130
  }
101
131
 
102
- if (apply) {
103
- console.log(`\n🏗️ --apply: convergence not yet implemented.`)
104
- console.log(` For missing: use 'deck add <locator>'`)
105
- console.log(` For behind: use 'deck refresh'`)
106
- console.log(` For extra: use 'cold-pool prune'`)
107
- } else {
132
+ if (!apply) {
108
133
  console.log(`\n💡 Plan-first. Use --apply to converge, or handle individually:`)
109
134
  console.log(` deck add <locator> → restore missing`)
110
135
  console.log(` deck refresh → update behind`)
111
136
  console.log(` cold-pool prune → GC extras`)
137
+ pool.metadata.close()
138
+ return
139
+ }
140
+
141
+ // ── Apply convergence ────────────────────────────────────────────────
142
+
143
+ // Confirmation
144
+ if (!yes) {
145
+ const confirmed = await promptYesNo('\nApply these changes?')
146
+ if (!confirmed) {
147
+ console.log('❌ Aborted. No changes made.')
148
+ pool.metadata.close()
149
+ return
150
+ }
151
+ }
152
+
153
+ console.log(`\n🏗️ Applying convergence...`)
154
+
155
+ const failures: string[] = []
156
+
157
+ // 1. Missing → deck add
158
+ for (const entry of plan.missing) {
159
+ for (const alias of entry.aliases) {
160
+ const info = skillByAlias.get(alias)
161
+ if (!info) {
162
+ failures.push(`Missing skill info for alias: ${alias}`)
163
+ continue
164
+ }
165
+ try {
166
+ console.log(` ➕ Adding ${alias}...`)
167
+ await addSkill(info.source, {
168
+ deck: DECK_PATH,
169
+ workdir: PROJECT_DIR,
170
+ alias,
171
+ type: info.type,
172
+ mode: info.mode as 'symlink' | 'snapshot',
173
+ })
174
+ console.log(` ✅ Added ${alias}`)
175
+ } catch (e: any) {
176
+ failures.push(`Add ${alias}: ${e.message}`)
177
+ console.error(` ❌ Failed to add ${alias}: ${e.message}`)
178
+ }
179
+ }
180
+ }
181
+
182
+ // 2. Behind → check actual HEAD vs recorded, then refresh if different
183
+ for (const entry of plan.behind) {
184
+ try {
185
+ const recordedRef = pool.metadata.getRepoRef(entry.host, entry.owner, entry.repo)
186
+ if (!recordedRef) {
187
+ console.log(` ⏭️ Skipping ${entry.host}/${entry.owner}/${entry.repo} — no recorded HEAD`)
188
+ continue
189
+ }
190
+ const currentRef = await getRepoHeadRef(entry.repoPath)
191
+ if (currentRef === recordedRef) {
192
+ console.log(` ✅ ${entry.host}/${entry.owner}/${entry.repo} is up to date (${currentRef.slice(0, 8)})`)
193
+ continue
194
+ }
195
+ console.log(` 🔄 Refreshing ${entry.host}/${entry.owner}/${entry.repo} (${recordedRef.slice(0, 8)} → ${currentRef.slice(0, 8)})...`)
196
+ // Refresh all aliases for this repo
197
+ for (const alias of entry.aliases) {
198
+ try {
199
+ refreshDeck(DECK_PATH, PROJECT_DIR, alias)
200
+ console.log(` ✅ Refreshed ${alias}`)
201
+ } catch (e: any) {
202
+ failures.push(`Refresh ${alias}: ${e.message}`)
203
+ console.error(` ❌ Failed to refresh ${alias}: ${e.message}`)
204
+ }
205
+ }
206
+ } catch (e: any) {
207
+ failures.push(`Behind ${entry.host}/${entry.owner}/${entry.repo}: ${e.message}`)
208
+ console.error(` ❌ Failed to check/refresh ${entry.host}/${entry.owner}/${entry.repo}: ${e.message}`)
209
+ }
210
+ }
211
+
212
+ // 3. Extra → prune (global, only once)
213
+ if (plan.extra.length > 0) {
214
+ try {
215
+ console.log(` 🗑️ Pruning extras...`)
216
+ await pruneDeck(DECK_PATH, PROJECT_DIR, true)
217
+ console.log(` ✅ Prune complete`)
218
+ } catch (e: any) {
219
+ failures.push(`Prune: ${e.message}`)
220
+ console.error(` ❌ Prune failed: ${e.message}`)
221
+ }
222
+ }
223
+
224
+ // Summary
225
+ console.log(`\n📋 Convergence summary:`)
226
+ console.log(` Missing resolved: ${plan.missing.length}`)
227
+ console.log(` Behind resolved: ${plan.behind.length}`)
228
+ console.log(` Extra resolved: ${plan.extra.length}`)
229
+ if (failures.length > 0) {
230
+ console.log(` ❌ Failures: ${failures.length}`)
231
+ for (const f of failures) {
232
+ console.log(` - ${f}`)
233
+ }
234
+ } else {
235
+ console.log(` ✅ All operations successful`)
112
236
  }
113
237
 
114
238
  pool.metadata.close()