@lythos/skill-deck 0.9.45 → 0.9.47

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.45 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.47 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -55,17 +55,15 @@ 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.45 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.45 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.45 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.45 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.45 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.45 remove tdd` |
64
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.45 prune` |
65
- | Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.45 sync tdd` |
66
- | Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.45 freeze tdd` |
67
- | Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.45 reconcile` |
68
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.45 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.47 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.47 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.47 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.47 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.47 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.47 remove tdd` |
64
+ | Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.47 to-symlink tdd` |
65
+ | Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.47 to-snapshot tdd` |
66
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.47 link --deck ./my-deck.toml --workdir /path/to/project` |
69
67
 
70
68
  ### Commands
71
69
 
@@ -76,10 +74,8 @@ prompt = "Search for latest info, then generate professional document with diagr
76
74
  | `add` | `<locator> [--alias <alias>] [--type <type>] [--deck <path>]` | Git clone skill to cold pool and append to skill-deck.toml. |
77
75
  | `refresh` | `[<fq\|alias>] [--deck <path>]` | Pull latest versions of declared skills from upstream git repos. Pass a name to refresh one skill. |
78
76
  | `remove` | `<fq\|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
79
- | `prune` | `[--yes] [--deck <path>]` | GC cold pool repos no longer referenced. Interactive confirm (skip with `--yes`). |
80
- | `sync` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch skill from snapshot (real dir) to sync (symlink) — live mode. |
81
- | `freeze` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch skill from sync (symlink) to snapshot (real dir) — pin current HEAD. |
82
- | `reconcile` | `[--apply] [--deck <path>] [--workdir <dir>]` | Compare lock vs cold pool, report drift (missing/behind/extra). Plan-first. |
77
+ | `to-symlink` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch a skill to symlink mode (live link, follows cold pool) |
78
+ | `to-snapshot` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch a skill to snapshot mode (pinned cp of current HEAD) |
83
79
 
84
80
  ### Options
85
81
 
@@ -96,7 +92,7 @@ prompt = "Search for latest info, then generate professional document with diagr
96
92
 
97
93
  `link` refuses to operate if `working_set` resolves to your home directory or root (`/`).
98
94
 
99
- **Snapshot mode** (`--mode snapshot` or `link --mode snapshot`): copies the source directory into the working set instead of symlinking. This is needed for agents that don't support symlinks (e.g. Codex #11314). Snapshots are pinned to the cold pool version at link time. Use `deck sync <alias>` to switch back to live symlink mode, or `deck freeze <alias>` to pin a symlink as a snapshot.
95
+ **Snapshot mode** (`--mode snapshot` or `link --mode snapshot`): copies the source directory into the working set instead of symlinking. This is needed for agents that don't support symlinks (e.g. Codex #11314). Snapshots are pinned to the cold pool version at link time. Use `deck to-symlink <alias>` to switch back to symlink mode, or `deck to-snapshot <alias>` to pin a symlink as a snapshot.
100
96
 
101
97
  ### Exit codes
102
98
 
@@ -128,7 +124,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
128
124
  EOF
129
125
 
130
126
  # 2. Link — creates symlinks in .claude/skills/
131
- bunx @lythos/skill-deck@0.9.45 link
127
+ bunx @lythos/skill-deck@0.9.47 link
132
128
  ```
133
129
 
134
130
  ### Key Concepts
@@ -214,7 +210,7 @@ Caution: deck's deny-by-default will remove any skills not declared in your deck
214
210
 
215
211
  | Symptom | Cause | Fix |
216
212
  |---------|-------|-----|
217
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.45 add github.com/owner/repo/skill` or clone manually into cold pool |
213
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.47 add github.com/owner/repo/skill` or clone manually into cold pool |
218
214
  | `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 |
219
215
  | `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 |
220
216
  | `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.45",
3
+ "version": "0.9.47",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -15,7 +15,7 @@
15
15
  - `catch {}` silent ignore(fs 边界错误,测了也是 mock,无业务意义)
16
16
  - 需要 >1GB 文件才能触发的分支(formatSize GB)
17
17
  - 依赖外部 network 的分支(git clone 真实失败、skills.sh backend)
18
- - 交互式 CLI 分支(prune confirm()
18
+ - 交互式 CLI 分支(prune/reconcile 已移到 @lythos/cold-pool CLI
19
19
  - 仅为凑分支覆盖率而进行的微重构(降低可读性)
20
20
 
21
21
  ---
@@ -68,20 +68,9 @@ link.ts 是 deck 包最大文件(~540 行), uncovered 行最多。主要
68
68
  |----|------|-----------|--------|
69
69
  | 47–50 | legacy string-array `continue`(空 name) | 有意义但 trivial | ⬜ 低优先级 |
70
70
 
71
- ## prune.ts 80.43% lines
72
-
73
- | 行 | 代码 | 未覆盖原因 | 是否追 |
74
- |----|------|-----------|--------|
75
- | 25–27 | `formatSize` GB 分支 | 需 >1GB 文件 | ❌ 不追 |
76
- | 74 | `calculateDirSize` catch | `catch {}` | ❌ 不追 |
77
- | 81–87 | `scanColdPoolRepos` catch | `catch {}` | ❌ 不追 |
78
- | 98–100 | empty cold pool | **已覆盖** (C17) | ✅ |
79
- | 118–122 | all-referenced no-op | **已覆盖** (C16) | ✅ |
80
- | 129–130 | failed > 0 exit(1) | 需构造删除失败(权限不足) | ❌ 不追 |
81
- | 167 | formatSize MB 分支 | 正常路径已覆盖 | ✅ |
82
- | 172–173 | 删除失败 catch | `catch {}` | ❌ 不追 |
83
- | 185–186 | formatSize 边界 | 正常路径已覆盖 | ✅ |
71
+ ## prune.ts / reconcile.ts — removed
84
72
 
73
+ prune 和 reconcile 命令已从 deck CLI 移除,迁移到 `@lythos/cold-pool` CLI。见 ADR-20260509144134332 FSM + ADR-202605091556237xx。
85
74
  ## refresh.ts — 88.97% lines
86
75
 
87
76
  | 行 | 代码 | 未覆盖原因 | 是否追 |
package/src/cli.ts CHANGED
@@ -6,9 +6,7 @@ import { refreshDeck } from './refresh.js'
6
6
  import { updateDeck } from './update.js'
7
7
  import { migrateSchema } from './migrate-schema.js'
8
8
  import { removeSkill } from './remove.js'
9
- import { pruneDeck } from './prune.js'
10
- import { syncSkill, freezeSkill } from './sync-freeze.js'
11
- import { reconcileDeck } from './reconcile.js'
9
+ import { toSymlinkSkill, toSnapshotSkill } from './to-symlink-snapshot.js'
12
10
  import { resolveDeckPathSync, fetchDeckUrl, isUrl } from './resolve-deck.js'
13
11
  import { formatHelp } from './help.js'
14
12
 
@@ -44,7 +42,6 @@ const alias = flagValue('--alias')
44
42
  const type = flagValue('--type')
45
43
  const format = flagValue('--format')
46
44
  const noBackup = args.includes('--no-backup')
47
- const yes = args.includes('--yes')
48
45
  const dryRun = args.includes('--dry-run')
49
46
  const remote = args.includes('--remote')
50
47
  const mode = flagValue('--mode') as 'symlink' | 'snapshot' | undefined
@@ -58,10 +55,8 @@ const HELP_CONFIG = {
58
55
  { name: 'refresh', description: 'Pull latest versions of declared skills from upstream', args: '[<fq|alias>]' },
59
56
  { name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
60
57
  { name: 'remove', description: 'Remove a skill from deck.toml and working set', args: '<fq|alias>' },
61
- { name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
62
- { name: 'sync', description: 'Switch skill from snapshot (cp) to sync (symlink)', args: '<alias>' },
63
- { name: 'freeze', description: 'Switch skill from sync (symlink) to snapshot (cp), pinning current HEAD', args: '<alias>' },
64
- { name: 'reconcile', description: 'Compare lock file vs cold pool, report drift', args: '[--apply] [--yes]' },
58
+ { name: 'to-symlink', description: 'Switch a skill to symlink mode (live link, follows cold pool)', args: '<alias>' },
59
+ { name: 'to-snapshot', description: 'Switch a skill to snapshot mode (pinned cp of current HEAD)', args: '<alias>' },
65
60
  { name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
66
61
  ],
67
62
  options: [
@@ -72,8 +67,8 @@ const HELP_CONFIG = {
72
67
 
73
68
  { flag: '--alias <name>', description: 'Explicit alias for the skill (default: basename of path)' },
74
69
  { flag: '--type <type>', description: 'Target section: innate | tool | combo (default: tool)' },
75
- { flag: '--dry-run', description: 'Show plan without executing (add, prune)' },
76
- { flag: '--yes', description: 'Skip interactive confirmation (for prune)' },
70
+ { flag: '--dry-run', description: 'Show plan without executing (add)' },
71
+ { flag: '--yes', description: 'Skip interactive confirmation' },
77
72
  { flag: '--remote', description: 'For validate: probe each FQ locator against api.github.com' },
78
73
  { flag: '--format <text|json>', description: 'For validate: output format (default: text)' },
79
74
  ],
@@ -121,31 +116,22 @@ switch (command) {
121
116
  removeSkill(removeTarget, deckPath, workdir)
122
117
  break
123
118
  }
124
- case 'sync': {
125
- const syncTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
126
- if (!syncTarget) {
127
- console.error('❌ Missing target. Usage: deck sync <alias>')
119
+ case 'to-symlink': {
120
+ const target = args[1] && !args[1].startsWith('-') ? args[1] : undefined
121
+ if (!target) {
122
+ console.error('❌ Missing target. Usage: deck to-symlink <alias>')
128
123
  process.exit(1)
129
124
  }
130
- syncSkill(syncTarget, deckPath, workdir)
125
+ toSymlinkSkill(target, deckPath, workdir)
131
126
  break
132
127
  }
133
- case 'freeze': {
134
- const freezeTarget = args[1] && !args[1].startsWith('-') ? args[1] : undefined
135
- if (!freezeTarget) {
136
- console.error('❌ Missing target. Usage: deck freeze <alias>')
128
+ case 'to-snapshot': {
129
+ const target = args[1] && !args[1].startsWith('-') ? args[1] : undefined
130
+ if (!target) {
131
+ console.error('❌ Missing target. Usage: deck to-snapshot <alias>')
137
132
  process.exit(1)
138
133
  }
139
- freezeSkill(freezeTarget, deckPath, workdir)
140
- break
141
- }
142
- case 'reconcile': {
143
- const apply = args.includes('--apply')
144
- await reconcileDeck(deckPath, workdir, apply, yes)
145
- break
146
- }
147
- case 'prune': {
148
- await pruneDeck(deckPath, workdir, yes)
134
+ toSnapshotSkill(target, deckPath, workdir)
149
135
  break
150
136
  }
151
137
  case 'migrate-schema': {
package/src/remove.ts CHANGED
@@ -104,5 +104,5 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
104
104
  console.warn(`⚠️ Metadata cleanup skipped: ${e.message}`);
105
105
  }
106
106
 
107
- console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/skill-deck prune' to GC unreferenced repos.`);
107
+ console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/cold-pool prune' to GC unreferenced repos.`);
108
108
  }
@@ -2,12 +2,12 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2
2
  import { mkdirSync, mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { syncSkill, freezeSkill } from './sync-freeze'
5
+ import { toSymlinkSkill, toSnapshotSkill } from './to-symlink-snapshot'
6
6
  import { cpSync, lstatSync } from 'node:fs'
7
7
 
8
8
  // Build a minimal project with a cold pool, deck.toml, working set, and lock
9
9
  function setupProject(opts: { mode: 'snapshot' | 'symlink' }) {
10
- const project = mkdtempSync(join(tmpdir(), 'sync-freeze-test-'))
10
+ const project = mkdtempSync(join(tmpdir(), 'to-symlink-snapshot-test-'))
11
11
  const coldPool = join(project, 'cold-pool')
12
12
  const workingSet = join(project, '.claude', 'skills')
13
13
 
@@ -67,7 +67,7 @@ path = "github.com/test-org/test-skill"
67
67
  return { project, coldPool, workingSet, skillDir, deckPath, dest }
68
68
  }
69
69
 
70
- describe('syncSkill — snapshot → symlink', () => {
70
+ describe('toSymlinkSkill — snapshot → symlink', () => {
71
71
  test('switches real dir to symlink', () => {
72
72
  const { deckPath, dest, project } = setupProject({ mode: 'snapshot' })
73
73
  const originalCwd = process.cwd
@@ -76,7 +76,7 @@ describe('syncSkill — snapshot → symlink', () => {
76
76
  try {
77
77
  process.cwd = () => project
78
78
  process.exit = (() => { throw new Error('exit') }) as any
79
- syncSkill('test-skill', deckPath, project)
79
+ toSymlinkSkill('test-skill', deckPath, project)
80
80
  } catch (e: any) {
81
81
  if (e.message !== 'exit') throw e
82
82
  } finally {
@@ -97,7 +97,7 @@ describe('syncSkill — snapshot → symlink', () => {
97
97
  try {
98
98
  process.cwd = () => project
99
99
  process.exit = (() => { throw new Error('exit') }) as any
100
- syncSkill('test-skill', deckPath, project)
100
+ toSymlinkSkill('test-skill', deckPath, project)
101
101
  } catch (e: any) {
102
102
  if (e.message !== 'exit') throw e
103
103
  } finally {
@@ -111,7 +111,7 @@ describe('syncSkill — snapshot → symlink', () => {
111
111
  })
112
112
  })
113
113
 
114
- describe('freezeSkill — symlink → snapshot', () => {
114
+ describe('toSnapshotSkill — symlink → snapshot', () => {
115
115
  test('switches symlink to real dir', () => {
116
116
  const { deckPath, dest, project } = setupProject({ mode: 'symlink' })
117
117
  const originalCwd = process.cwd
@@ -120,7 +120,7 @@ describe('freezeSkill — symlink → snapshot', () => {
120
120
  try {
121
121
  process.cwd = () => project
122
122
  process.exit = (() => { throw new Error('exit') }) as any
123
- freezeSkill('test-skill', deckPath, project)
123
+ toSnapshotSkill('test-skill', deckPath, project)
124
124
  } catch (e: any) {
125
125
  if (e.message !== 'exit') throw e
126
126
  } finally {
@@ -144,7 +144,7 @@ describe('freezeSkill — symlink → snapshot', () => {
144
144
  try {
145
145
  process.cwd = () => project
146
146
  process.exit = (() => { throw new Error('exit') }) as any
147
- freezeSkill('test-skill', deckPath, project)
147
+ toSnapshotSkill('test-skill', deckPath, project)
148
148
  } catch (e: any) {
149
149
  if (e.message !== 'exit') throw e
150
150
  } finally {
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * deck sync/freezesnapshot↔symlink intent switching per skill.
3
+ * deck to-symlink/to-snapshotswitch a skill's link mode in the working set.
4
4
  *
5
- * Per ADR-20260507190157540: snapshot = default safe (cp), sync = live (symlink).
6
- * These commands switch an individual skill between modes without re-linking all.
5
+ * Per ADR-20260507190157540: snapshot = default safe (cp, pinned), symlink = live (follows cold pool).
6
+ * Per ADR-20260509144134332: command verbs renamed from sync/freeze to to-symlink/to-snapshot
7
+ * to align with schema mode field and avoid collision with `deck link` (the reconcile primitive).
7
8
  */
8
9
 
9
10
  import { existsSync, readFileSync, writeFileSync, rmSync, symlinkSync, cpSync, lstatSync } from 'node:fs'
@@ -52,10 +53,10 @@ function getProjectAndDeck(cliDeckPath?: string, cliWorkdir?: string) {
52
53
  }
53
54
 
54
55
  /**
55
- * Switch a skill from snapshot (real dir) to sync (symlink).
56
- * The skill stays in a real directory if it's already not a symlink.
56
+ * Switch a skill to symlink mode (live link to cold pool source).
57
+ * No-op if the working set entry is already a symlink.
57
58
  */
58
- export function syncSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
59
+ export function toSymlinkSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
59
60
  const { DECK_PATH, PROJECT_DIR, deckRaw, WORKING_SET, COLD_POOL } = getProjectAndDeck(cliDeckPath, cliWorkdir)
60
61
 
61
62
  const { entries: parsedEntries } = parseDeck(deckRaw)
@@ -81,7 +82,7 @@ export function syncSkill(target: string, cliDeckPath?: string, cliWorkdir?: str
81
82
  } catch {}
82
83
 
83
84
  if (currentMode === 'symlink') {
84
- console.log(`⏭️ ${match.alias} is already in sync mode (symlink)`)
85
+ console.log(`⏭️ ${match.alias} is already in symlink mode`)
85
86
  return
86
87
  }
87
88
 
@@ -93,7 +94,7 @@ export function syncSkill(target: string, cliDeckPath?: string, cliWorkdir?: str
93
94
  // Remove snapshot, create symlink
94
95
  rmSync(dest, { recursive: true, force: true })
95
96
  symlinkSync(source.path, dest)
96
- console.log(`🔄 ${match.alias}: snapshot → sync (symlink to ${relative(PROJECT_DIR, source.path)})`)
97
+ console.log(`🔄 ${match.alias}: snapshot → symlink (target: ${relative(PROJECT_DIR, source.path)})`)
97
98
 
98
99
  // Update lock
99
100
  const lock = readLock(PROJECT_DIR)
@@ -108,9 +109,10 @@ export function syncSkill(target: string, cliDeckPath?: string, cliWorkdir?: str
108
109
  }
109
110
 
110
111
  /**
111
- * Switch a skill from sync (symlink) to snapshot (real dir, pin current HEAD).
112
+ * Switch a skill to snapshot mode (pinned copy from cold pool source).
113
+ * No-op if the working set entry is already a real directory (not a symlink).
112
114
  */
113
- export function freezeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
115
+ export function toSnapshotSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
114
116
  const { DECK_PATH, PROJECT_DIR, deckRaw, WORKING_SET, COLD_POOL } = getProjectAndDeck(cliDeckPath, cliWorkdir)
115
117
 
116
118
  const { entries: parsedEntries } = parseDeck(deckRaw)
@@ -148,14 +150,14 @@ export function freezeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
148
150
  // Remove symlink, cp snapshot
149
151
  rmSync(dest, { recursive: true, force: true })
150
152
  cpSync(source.path, dest, { recursive: true })
151
- console.log(`🧊 ${match.alias}: sync → snapshot (pinned copy from ${relative(PROJECT_DIR, source.path)})`)
153
+ console.log(`🧊 ${match.alias}: symlink → snapshot (pinned copy from ${relative(PROJECT_DIR, source.path)})`)
152
154
 
153
155
  // Record HEAD in metadata
154
156
  try {
155
157
  const loc = parseLocator(match.path)
156
158
  if (loc && !loc.isLocalhost) {
157
159
  const pool = new ColdPool(COLD_POOL)
158
- // Best-effort: record a note that this is now frozen
160
+ // Best-effort: note that this is now pinned (snapshot mode)
159
161
  // The actual HEAD recording happens via git-hash async, but we note the intent
160
162
  console.log(` 📌 Pinned. Run 'deck link' to regenerate lock with updated content_hash.`)
161
163
  }
@@ -1,103 +0,0 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import { mkdirSync, writeFileSync, rmSync } from 'node:fs'
3
- import { join } from 'node:path'
4
- import { resolvePruneConfig, scanColdPool, calculateDirSize, buildPrunePlan } from './prune-plan'
5
-
6
- const deckToml = `[deck]
7
- cold_pool = "./cold-pool"
8
-
9
- [tool.skills.skill-a]
10
- path = "github.com/foo/bar/skill-a"
11
-
12
- [tool.skills.skill-b]
13
- path = "localhost/me/skill-b"
14
- `
15
-
16
- describe('resolvePruneConfig', () => {
17
- test('explicit paths override defaults', () => {
18
- const cfg = resolvePruneConfig({
19
- deckPath: '/tmp/deck.toml',
20
- workdir: '/custom/work',
21
- coldPool: '/custom/pool',
22
- })
23
- expect(cfg.deckPath).toBe('/tmp/deck.toml')
24
- expect(cfg.workdir).toBe('/custom/work')
25
- expect(cfg.coldPool).toBe('/custom/pool')
26
- })
27
- })
28
-
29
- describe('scanColdPool', () => {
30
- test('empty for nonexistent directory', () => {
31
- expect(scanColdPool('/tmp/nonexistent-' + Date.now())).toEqual([])
32
- })
33
-
34
- test('finds flat localhost skills in cold pool', () => {
35
- const pool = join('/tmp', 'prune-test-pool-' + Date.now())
36
- mkdirSync(join(pool, 'skill-b'), { recursive: true })
37
- writeFileSync(join(pool, 'skill-b', 'SKILL.md'), '# test')
38
-
39
- const repos = scanColdPool(pool)
40
- expect(repos.some(r => r.endsWith('skill-b'))).toBe(true)
41
-
42
- rmSync(pool, { recursive: true, force: true })
43
- })
44
- })
45
-
46
- describe('calculateDirSize', () => {
47
- test('calculates total size', () => {
48
- const dir = join('/tmp', 'size-test-' + Date.now())
49
- mkdirSync(join(dir, 'sub'), { recursive: true })
50
- writeFileSync(join(dir, 'a.txt'), 'hello')
51
- writeFileSync(join(dir, 'sub', 'b.txt'), 'world')
52
-
53
- const size = calculateDirSize(dir)
54
- expect(size).toBeGreaterThanOrEqual(10) // 'hello' + 'world' = 10 bytes
55
- rmSync(dir, { recursive: true, force: true })
56
- })
57
- })
58
-
59
- describe('buildPrunePlan', () => {
60
- test('builds plan from deck config', () => {
61
- const plan = buildPrunePlan(deckToml, { coldPool: '/tmp/test-pool' })
62
- expect(plan.declared).toHaveLength(2)
63
- expect(plan.declared).toContain('github.com/foo/bar/skill-a')
64
- })
65
-
66
- test('identifies unreferenced repos as candidates', () => {
67
- const pool = join('/tmp', 'prune-plan-test-' + Date.now())
68
- // Create a declared repo
69
- mkdirSync(join(pool, 'github.com', 'foo', 'bar', 'skill-a'), { recursive: true })
70
- // Create an UNREFERENCED repo (not in deck)
71
- mkdirSync(join(pool, 'github.com', 'baz', 'qux'), { recursive: true })
72
-
73
- const plan = buildPrunePlan(deckToml, { coldPool: pool })
74
- const unreferenced = plan.candidates.map(c => c.repoRel)
75
- expect(unreferenced).toContain('github.com/baz/qux')
76
- // skill-a is declared, should NOT be a candidate
77
- expect(unreferenced).not.toContain('github.com/foo/bar/skill-a')
78
-
79
- rmSync(pool, { recursive: true, force: true })
80
- })
81
-
82
- test('empty candidates when all repos declared', () => {
83
- const pool = join('/tmp', 'prune-all-declared-' + Date.now())
84
- mkdirSync(join(pool, 'github.com', 'foo', 'bar', 'skill-a'), { recursive: true })
85
- mkdirSync(join(pool, 'localhost', 'me', 'skill-b'), { recursive: true })
86
-
87
- const plan = buildPrunePlan(deckToml, { coldPool: pool })
88
- expect(plan.candidates).toHaveLength(0)
89
-
90
- rmSync(pool, { recursive: true, force: true })
91
- })
92
-
93
- test('totalSize is sum of candidate sizes', () => {
94
- const pool = join('/tmp', 'prune-size-test-' + Date.now())
95
- mkdirSync(join(pool, 'github.com', 'unref', 'repo'), { recursive: true })
96
- writeFileSync(join(pool, 'github.com', 'unref', 'repo', 'data.txt'), 'hello world')
97
-
98
- const plan = buildPrunePlan(deckToml, { coldPool: pool })
99
- expect(plan.totalSize).toBeGreaterThanOrEqual(11)
100
-
101
- rmSync(pool, { recursive: true, force: true })
102
- })
103
- })
package/src/prune-plan.ts DELETED
@@ -1,210 +0,0 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs'
2
- import { resolve, join } from 'node:path'
3
- import { findDeckToml, expandHome } from './link'
4
- import { parseDeck } from './parse-deck'
5
-
6
- // ── Types ──────────────────────────────────────────────────────────────────
7
-
8
- export interface PruneCandidate {
9
- repoPath: string
10
- repoRel: string // relative to cold pool
11
- size: number // bytes
12
- }
13
-
14
- export interface PrunePlan {
15
- deckPath: string
16
- workdir: string
17
- coldPool: string
18
- candidates: PruneCandidate[] // unreferenced repos to delete
19
- declared: string[] // declared skill names (for audit)
20
- totalSize: number // total reclaimable bytes
21
- }
22
-
23
- // ── Config resolution ──────────────────────────────────────────────────────
24
-
25
- export function resolvePruneConfig(opts?: {
26
- deckPath?: string
27
- workdir?: string
28
- coldPool?: string
29
- }) {
30
- const deckPath = opts?.deckPath
31
- ? resolve(opts.deckPath)
32
- : (findDeckToml(process.cwd()) || resolve('skill-deck.toml'))
33
-
34
- const workdir = opts?.workdir
35
- ? resolve(opts.workdir)
36
- : join(deckPath, '..')
37
-
38
- const coldPool = opts?.coldPool
39
- ? resolve(opts.coldPool)
40
- : expandHome('~/.agents/skill-repos', workdir)
41
-
42
- return { deckPath, workdir, coldPool }
43
- }
44
-
45
- // ── Cold pool scanner (pure: reads, no delete) ─────────────────────────────
46
-
47
- /**
48
- * Scan cold pool for skill repos.
49
- *
50
- * Layout convention (per ADR-20260502012643344):
51
- * - `<coldPool>/localhost/<name>/SKILL.md` — local skill
52
- * - `<coldPool>/<host>/<owner>/<repo>/...` — remote skill
53
- *
54
- * Legacy drift detection: a top-level dir `<coldPool>/<x>/SKILL.md` (with
55
- * SKILL.md directly, not under `localhost/`) is non-canonical state from
56
- * older agents that bypassed FQ-only enforcement. We surface it here so
57
- * prune's heredoc can list it as cleanup candidate; future writes should
58
- * never produce this shape.
59
- */
60
- export function scanColdPool(coldPool: string): string[] {
61
- const repos: string[] = []
62
- if (!existsSync(coldPool)) return repos
63
-
64
- try {
65
- for (const host of readdirSync(coldPool, { withFileTypes: true })) {
66
- if (!host.isDirectory() || host.name.startsWith('.')) continue
67
- const hostPath = join(coldPool, host.name)
68
-
69
- // Localhost layout: <coldPool>/localhost/<name>/SKILL.md
70
- if (host.name === 'localhost') {
71
- for (const entry of readdirSync(hostPath, { withFileTypes: true })) {
72
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue
73
- repos.push(join(hostPath, entry.name))
74
- }
75
- continue
76
- }
77
-
78
- // Legacy drift: top-level dir with SKILL.md (not canonical)
79
- if (existsSync(join(hostPath, 'SKILL.md'))) {
80
- repos.push(hostPath)
81
- continue
82
- }
83
-
84
- // Nested: <coldPool>/<host>/<owner>/<repo>/
85
- for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
86
- if (!owner.isDirectory() || owner.name.startsWith('.')) continue
87
- const ownerPath = join(hostPath, owner.name)
88
- for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
89
- if (!repo.isDirectory() || repo.name.startsWith('.')) continue
90
- repos.push(join(ownerPath, repo.name))
91
- }
92
- }
93
- }
94
- } catch {}
95
-
96
- return repos
97
- }
98
-
99
- // ── Size calculation (pure helper) ─────────────────────────────────────────
100
-
101
- export function calculateDirSize(dir: string): number {
102
- let total = 0
103
- try {
104
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
105
- const p = join(dir, entry.name)
106
- if (entry.isDirectory()) {
107
- total += calculateDirSize(p)
108
- } else if (entry.isFile()) {
109
- total += statSync(p).size
110
- }
111
- }
112
- } catch {}
113
- return total
114
- }
115
-
116
- // ── Plan builder (pure: no deletion, no mutation) ──────────────────────────
117
-
118
- export function buildPrunePlan(
119
- deckRaw: string,
120
- opts?: { deckPath?: string; workdir?: string; coldPool?: string }
121
- ): PrunePlan {
122
- const { deckPath, workdir, coldPool: configuredColdPool } = resolvePruneConfig(opts)
123
-
124
- // Read cold_pool from deck.toml if not explicitly overridden
125
- let coldPool = configuredColdPool
126
- if (!opts?.coldPool) {
127
- const deckMatch = deckRaw.match(/cold_pool\s*=\s*"([^"]+)"/)
128
- if (deckMatch) {
129
- coldPool = expandHome(deckMatch[1], workdir)
130
- }
131
- }
132
-
133
- // Get declared skill paths from deck
134
- const { entries: declared } = parseDeck(deckRaw)
135
- const declaredPaths = new Set(declared.map(d => d.path))
136
-
137
- // Scan cold pool for all repos
138
- const allRepos = scanColdPool(coldPool)
139
-
140
- // Find unreferenced: repos not declared in deck
141
- const candidates: PruneCandidate[] = []
142
- for (const repoPath of allRepos) {
143
- // A repo is referenced if any declared skill path starts with its cold-pool-relative path
144
- const repoRel = repoPath.slice(coldPool.length + 1) // relative to cold pool
145
- const isReferenced = [...declaredPaths].some(d => d.startsWith(repoRel) || repoRel.startsWith(d))
146
-
147
- if (!isReferenced) {
148
- candidates.push({
149
- repoPath,
150
- repoRel,
151
- size: calculateDirSize(repoPath),
152
- })
153
- }
154
- }
155
-
156
- const totalSize = candidates.reduce((sum, c) => sum + c.size, 0)
157
-
158
- return {
159
- deckPath,
160
- workdir,
161
- coldPool,
162
- candidates,
163
- declared: declared.map(d => d.path),
164
- totalSize,
165
- }
166
- }
167
-
168
- // ── Execution (IO layer, injectable for testing) ───────────────────────────
169
-
170
- export interface PruneResult {
171
- repoRel: string
172
- deleted: boolean
173
- error?: string
174
- }
175
-
176
- export interface PruneIO {
177
- delete?: (path: string) => void
178
- log?: (msg: string) => void
179
- formatSize?: (bytes: number) => string
180
- }
181
-
182
- export function executePrunePlan(plan: PrunePlan, io?: PruneIO): PruneResult[] {
183
- const deleteFn = io?.delete ?? ((_path: string) => { throw new Error('delete not injected') })
184
- const log = io?.log ?? (() => {})
185
- const fmtSize = io?.formatSize ?? ((b: number) => b < 1024 ? `${b}B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / (1024 * 1024)).toFixed(1)}MB`)
186
-
187
- log(`\n🧹 Prune candidates — ${plan.candidates.length} repo(s), ${fmtSize(plan.totalSize)} total:\n`)
188
- for (const c of plan.candidates) {
189
- log(` ${c.repoRel} (${fmtSize(c.size)})`)
190
- }
191
-
192
- const results: PruneResult[] = []
193
- let deleted = 0, failed = 0
194
-
195
- for (const c of plan.candidates) {
196
- try {
197
- deleteFn(c.repoPath)
198
- log(` 🗑️ Deleted: ${c.repoRel}`)
199
- results.push({ repoRel: c.repoRel, deleted: true })
200
- deleted++
201
- } catch (err: any) {
202
- log(` ❌ Failed to delete ${c.repoRel}: ${err.message}`)
203
- results.push({ repoRel: c.repoRel, deleted: false, error: err.message })
204
- failed++
205
- }
206
- }
207
-
208
- log(`\n📦 Prune complete: ${deleted} deleted, ${failed} failed`)
209
- return results
210
- }
package/src/prune.test.ts DELETED
@@ -1,150 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * prune.test.ts — unit tests for prune.ts
4
- *
5
- * Run: bun test packages/lythoskill-deck/src/prune.test.ts
6
- */
7
-
8
- import { describe, it, expect, afterEach, spyOn } from 'bun:test'
9
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'
10
- import { join } from 'node:path'
11
- import { tmpdir } from 'node:os'
12
- import { formatSize } from './prune.ts'
13
-
14
- let cleanup: string[] = []
15
-
16
- afterEach(() => {
17
- for (const dir of cleanup) {
18
- rmSync(dir, { recursive: true, force: true })
19
- }
20
- cleanup = []
21
- })
22
-
23
- function makeTmp(): string {
24
- const dir = mkdtempSync(join(tmpdir(), 'deck-prune-'))
25
- cleanup.push(dir)
26
- return dir
27
- }
28
-
29
- function placeRepo(coldPool: string, host: string, owner: string, repo: string): string {
30
- const repoDir = join(coldPool, host, owner, repo)
31
- mkdirSync(repoDir, { recursive: true })
32
- return repoDir
33
- }
34
-
35
- function placeSkillInRepo(repoDir: string, skillName: string): string {
36
- const skillDir = join(repoDir, 'skills', skillName)
37
- mkdirSync(skillDir, { recursive: true })
38
- writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
39
- return skillDir
40
- }
41
-
42
- describe('formatSize', () => {
43
- it('formats bytes correctly at each boundary', () => {
44
- expect(formatSize(0)).toBe('0B')
45
- expect(formatSize(512)).toBe('512B')
46
- expect(formatSize(1023)).toBe('1023B')
47
- expect(formatSize(1024)).toBe('1.0KB')
48
- expect(formatSize(1536)).toBe('1.5KB')
49
- expect(formatSize(1048576)).toBe('1.0MB')
50
- expect(formatSize(1073741824)).toBe('1.0GB')
51
- })
52
- })
53
-
54
- describe('pruneDeck', () => {
55
- it('C15: prune with unreferenced repos deletes them when --yes is set', async () => {
56
- const projectDir = makeTmp()
57
- const coldPoolRel = 'cold-pool'
58
- const coldPool = join(projectDir, coldPoolRel)
59
-
60
- const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
61
- placeSkillInRepo(repoA, 'skill-a')
62
-
63
- const repoB = placeRepo(coldPool, 'github.com', 'owner', 'repo-b')
64
- placeSkillInRepo(repoB, 'skill-b')
65
-
66
- const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skills/skill-a"\n`
67
- const deckPath = join(projectDir, 'skill-deck.toml')
68
- writeFileSync(deckPath, deckContent)
69
-
70
- const { pruneDeck } = await import('./prune.ts')
71
- await pruneDeck(deckPath, projectDir, true)
72
-
73
- expect(existsSync(repoA)).toBe(true)
74
- expect(existsSync(join(repoA, 'skills', 'skill-a', 'SKILL.md'))).toBe(true)
75
-
76
- expect(existsSync(repoB)).toBe(false)
77
- })
78
-
79
- it('C16: prune with all referenced repos is a no-op', async () => {
80
- const projectDir = makeTmp()
81
- const coldPoolRel = 'cold-pool'
82
- const coldPool = join(projectDir, coldPoolRel)
83
-
84
- const repoA = placeRepo(coldPool, 'github.com', 'owner', 'repo-a')
85
- placeSkillInRepo(repoA, 'skill-a')
86
-
87
- const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a/skills/skill-a"\n`
88
- const deckPath = join(projectDir, 'skill-deck.toml')
89
- writeFileSync(deckPath, deckContent)
90
-
91
- const logs: string[] = []
92
- const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
93
- logs.push(String(msg))
94
- })
95
-
96
- const originalExit = process.exit
97
- let exitCode: number | undefined
98
- process.exit = ((code?: number) => {
99
- exitCode = code ?? 0
100
- throw new Error(`EXIT:${code}`)
101
- }) as typeof process.exit
102
-
103
- try {
104
- const { pruneDeck } = await import('./prune.ts')
105
- await pruneDeck(deckPath, projectDir, true)
106
- expect(false).toBe(true)
107
- } catch (err: any) {
108
- expect(exitCode).toBe(0)
109
- expect(logs.some(l => l.includes('Nothing to prune'))).toBe(true)
110
- } finally {
111
- process.exit = originalExit
112
- logSpy.mockRestore()
113
- }
114
- })
115
-
116
- it('C17: prune with empty cold pool reports nothing to prune', async () => {
117
- const projectDir = makeTmp()
118
- const coldPoolRel = 'cold-pool'
119
- const coldPool = join(projectDir, coldPoolRel)
120
- mkdirSync(coldPool, { recursive: true })
121
-
122
- const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n`
123
- const deckPath = join(projectDir, 'skill-deck.toml')
124
- writeFileSync(deckPath, deckContent)
125
-
126
- const logs: string[] = []
127
- const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => {
128
- logs.push(String(msg))
129
- })
130
-
131
- const originalExit = process.exit
132
- let exitCode: number | undefined
133
- process.exit = ((code?: number) => {
134
- exitCode = code ?? 0
135
- throw new Error(`EXIT:${code}`)
136
- }) as typeof process.exit
137
-
138
- try {
139
- const { pruneDeck } = await import('./prune.ts')
140
- await pruneDeck(deckPath, projectDir, true)
141
- expect(false).toBe(true)
142
- } catch (err: any) {
143
- expect(exitCode).toBe(0)
144
- expect(logs.some(l => l.includes('empty'))).toBe(true)
145
- } finally {
146
- process.exit = originalExit
147
- logSpy.mockRestore()
148
- }
149
- })
150
- })
package/src/prune.ts DELETED
@@ -1,161 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * deck-prune.ts — Cold pool garbage collection
4
- *
5
- * Scans the cold pool for repositories no longer referenced by any
6
- * skill-deck.toml declaration and offers to delete them.
7
- * Does NOT modify deck.toml or the working set.
8
- */
9
-
10
- import { existsSync, readFileSync, rmSync } from "node:fs";
11
- import { resolve } from "node:path";
12
- import { createInterface } from "node:readline";
13
- import { findDeckToml } from "./link.js";
14
- import { buildPrunePlan, executePrunePlan } from "./prune-plan.js";
15
-
16
- interface PruneCandidate {
17
- repoPath: string;
18
- repoRel: string;
19
- size: number;
20
- }
21
-
22
- export function formatSize(bytes: number): string {
23
- if (bytes < 1024) return `${bytes}B`;
24
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
25
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
26
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
27
- }
28
-
29
- function calculateDirSize(dir: string): number {
30
- let total = 0;
31
- try {
32
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
- const p = join(dir, entry.name);
34
- if (entry.isDirectory()) {
35
- total += calculateDirSize(p);
36
- } else if (entry.isFile()) {
37
- total += statSync(p).size;
38
- }
39
- }
40
- } catch {}
41
- return total;
42
- }
43
-
44
- function scanColdPoolRepos(coldPool: string): string[] {
45
- const repos: string[] = [];
46
- try {
47
- for (const host of readdirSync(coldPool, { withFileTypes: true })) {
48
- if (!host.isDirectory() || host.name.startsWith(".")) continue;
49
- const hostPath = join(coldPool, host.name);
50
-
51
- // Flat skill: cold-pool/skill-name/ (localhost style)
52
- if (existsSync(join(hostPath, "SKILL.md"))) {
53
- repos.push(hostPath);
54
- continue;
55
- }
56
-
57
- for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
58
- if (!owner.isDirectory() || owner.name.startsWith(".")) continue;
59
- const ownerPath = join(hostPath, owner.name);
60
-
61
- // Standalone repo: cold-pool/host.tld/owner/repo/
62
- if (existsSync(join(ownerPath, "SKILL.md"))) {
63
- repos.push(ownerPath);
64
- continue;
65
- }
66
-
67
- for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
68
- if (!repo.isDirectory() || repo.name.startsWith(".")) continue;
69
- repos.push(join(ownerPath, repo.name));
70
- }
71
- }
72
- }
73
- } catch {}
74
- return repos;
75
- }
76
-
77
- function isRepoReferenced(repoPath: string, declaredPaths: string[], coldPool: string, projectDir: string): boolean {
78
- for (const path of declaredPaths) {
79
- const result = findSource(path, coldPool, projectDir);
80
- if (result.path) {
81
- // Check if the resolved skill path is inside this repo
82
- const rel = relative(repoPath, result.path);
83
- if (!rel.startsWith("..") && rel !== "") {
84
- return true;
85
- }
86
- if (result.path === repoPath) {
87
- return true;
88
- }
89
- }
90
- }
91
- return false;
92
- }
93
-
94
- async function confirm(message: string): Promise<boolean> {
95
- const rl = createInterface({ input: process.stdin, output: process.stdout });
96
- return new Promise((resolve) => {
97
- rl.question(`${message} (y/N) `, (answer) => {
98
- rl.close();
99
- resolve(answer.trim().toLowerCase() === "y");
100
- });
101
- });
102
- }
103
-
104
- export async function pruneDeck(cliDeckPath?: string, cliWorkdir?: string, yes?: boolean): Promise<void> {
105
- const deckPath = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
106
- const workdir = cliWorkdir
107
-
108
- const DECK_PATH = deckPath ? resolve(deckPath) : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
109
-
110
- if (!existsSync(DECK_PATH)) {
111
- console.error(`❌ skill-deck.toml not found in ${process.cwd()}`)
112
- console.error(`\nCreate one or specify a path: bunx @lythos/skill-deck link --deck /path/to/deck.toml`)
113
- process.exit(1)
114
- }
115
-
116
- const deckRaw = readFileSync(DECK_PATH, 'utf-8')
117
-
118
- // ── Plan: pure unreferenced detection ──────────────────────────────
119
- const plan = buildPrunePlan(deckRaw, {
120
- deckPath: DECK_PATH,
121
- workdir: workdir ? resolve(workdir) : undefined,
122
- })
123
-
124
- if (!existsSync(plan.coldPool)) {
125
- console.log('📭 Cold pool does not exist. Nothing to prune.')
126
- process.exit(0)
127
- }
128
-
129
- if (plan.candidates.length === 0 && plan.declared.length === 0) {
130
- console.log('📭 Cold pool is empty. Nothing to prune.')
131
- process.exit(0)
132
- }
133
-
134
- if (plan.candidates.length === 0) {
135
- console.log('✅ All cold pool repositories are referenced. Nothing to prune.')
136
- process.exit(0)
137
- }
138
-
139
- // ── Confirm ──────────────────────────────────────────────────────
140
- let shouldDelete = false
141
- if (yes) {
142
- shouldDelete = true
143
- console.log('\n⚠️ --yes flag set: deleting without confirmation.')
144
- } else {
145
- shouldDelete = await confirm(`\nDelete ${plan.candidates.length} unreferenced repo(s)?`)
146
- }
147
-
148
- if (!shouldDelete) {
149
- console.log('❎ Prune cancelled.')
150
- process.exit(0)
151
- }
152
-
153
- // ── Execute with real IO ──────────────────────────────────────────
154
- const results = executePrunePlan(plan, {
155
- delete: (path: string) => rmSync(path, { recursive: true, force: true }),
156
- log: console.log,
157
- formatSize,
158
- })
159
-
160
- if (results.some(r => !r.deleted)) process.exit(1)
161
- }
@@ -1,82 +0,0 @@
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 DELETED
@@ -1,239 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * deck reconcile — k8s-style desired vs actual convergence.
4
- *
5
- * Per ADR-20260507021957847: reads skill-deck.lock (desired state),
6
- * compares against cold pool filesystem (actual state), reports diff.
7
- * By default plan-first (report only); --apply executes convergence.
8
- */
9
-
10
- import { existsSync, readFileSync } from 'node:fs'
11
- import { resolve, dirname, join } from 'node:path'
12
- import { findDeckToml, expandHome } from './link.js'
13
- import { parse as parseToml } from '@iarna/toml'
14
- import { ColdPool, buildReconcilePlan, type ReconcileDesiredState, getRepoHeadRef } from '@lythos/cold-pool'
15
- import { SkillDeckLockSchema } from './schema.js'
16
- import { addSkill } from './add.js'
17
- import { refreshDeck } from './refresh.js'
18
- import { pruneDeck } from './prune.js'
19
-
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> {
42
- const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === '--deck')
43
- const DECK_PATH = cliDeck
44
- ? resolve(cliDeck)
45
- : findDeckToml(process.cwd()) || resolve('skill-deck.toml')
46
-
47
- if (!existsSync(DECK_PATH)) {
48
- console.error(`❌ skill-deck.toml not found`)
49
- process.exit(1)
50
- }
51
-
52
- const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd()
53
- const LOCK_PATH = resolve(PROJECT_DIR, 'skill-deck.lock')
54
-
55
- if (!existsSync(LOCK_PATH)) {
56
- console.error(`❌ No lock file found. Run 'deck link' first.`)
57
- process.exit(1)
58
- }
59
-
60
- // Read lock
61
- let lock: any
62
- try {
63
- lock = JSON.parse(readFileSync(LOCK_PATH, 'utf-8'))
64
- } catch {
65
- console.error(`❌ Failed to parse lock file: ${LOCK_PATH}`)
66
- process.exit(1)
67
- }
68
-
69
- const parsed = SkillDeckLockSchema.safeParse(lock)
70
- if (!parsed.success) {
71
- console.error(`❌ Lock file schema mismatch. Run 'deck link' to regenerate.`)
72
- process.exit(1)
73
- }
74
-
75
- const lockData = parsed.data
76
- const coldPoolRaw = lockData.cold_pool || '~/.agents/skill-repos'
77
- const COLD_POOL = expandHome(coldPoolRaw, PROJECT_DIR)
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
-
85
- // Build desired state from lock
86
- const desired: ReconcileDesiredState = {
87
- deckPath: DECK_PATH,
88
- skills: lockData.skills.map((s) => ({
89
- locator: s.source,
90
- alias: s.alias,
91
- })),
92
- }
93
-
94
- // Run reconcile plan
95
- const pool = new ColdPool(COLD_POOL)
96
- const plan = buildReconcilePlan(pool, desired)
97
-
98
- // Report
99
- console.log(`\n📊 Reconcile Report`)
100
- console.log(` Deck: ${lockData.deck_source.path}`)
101
- console.log(` Skills declared: ${lockData.skills.length}`)
102
- console.log(` Cold pool: ${COLD_POOL}`)
103
-
104
- if (plan.missing.length === 0 && plan.behind.length === 0 && plan.extra.length === 0) {
105
- console.log(`\n✅ No drift detected — cold pool matches desired state.`)
106
- pool.metadata.close()
107
- return
108
- }
109
-
110
- console.log(`\n🔍 Drift detected:`)
111
- console.log(` ❌ Missing: ${plan.missing.length}`)
112
- console.log(` ⚠️ Behind: ${plan.behind.length}`)
113
- console.log(` 📦 Extra: ${plan.extra.length}`)
114
-
115
- for (const entry of plan.missing) {
116
- console.log(`\n ❌ Missing: ${entry.host}/${entry.owner}/${entry.repo}`)
117
- console.log(` Reason: ${entry.reason}`)
118
- console.log(` Skills: ${entry.aliases.join(', ')}`)
119
- }
120
-
121
- for (const entry of plan.behind) {
122
- console.log(`\n ⚠️ Behind: ${entry.host}/${entry.owner}/${entry.repo}`)
123
- console.log(` ${entry.reason}`)
124
- console.log(` Skills: ${entry.aliases.join(', ')}`)
125
- }
126
-
127
- for (const entry of plan.extra) {
128
- console.log(`\n 📦 Extra: ${entry.host}/${entry.owner}/${entry.repo}`)
129
- console.log(` Reason: ${entry.reason}`)
130
- }
131
-
132
- if (!apply) {
133
- console.log(`\n💡 Plan-first. Use --apply to converge, or handle individually:`)
134
- console.log(` deck add <locator> → restore missing`)
135
- console.log(` deck refresh → update behind`)
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`)
236
- }
237
-
238
- pool.metadata.close()
239
- }