@lythos/skill-deck 0.9.45 → 0.9.46
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 +15 -19
- package/package.json +1 -1
- package/src/COVERAGE-GAPS.md +3 -14
- package/src/cli.ts +15 -29
- package/src/remove.ts +1 -1
- package/src/{sync-freeze.test.ts → to-symlink-snapshot.test.ts} +8 -8
- package/src/{sync-freeze.ts → to-symlink-snapshot.ts} +14 -12
- package/src/prune-plan.test.ts +0 -103
- package/src/prune-plan.ts +0 -210
- package/src/prune.test.ts +0 -150
- package/src/prune.ts +0 -161
- package/src/reconcile.test.ts +0 -82
- package/src/reconcile.ts +0 -239
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.
|
|
12
|
+
bunx @lythos/skill-deck@0.9.46 <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.
|
|
59
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.
|
|
60
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.
|
|
61
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.
|
|
62
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.
|
|
63
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.
|
|
64
|
-
|
|
|
65
|
-
| Switch skill
|
|
66
|
-
|
|
|
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.46 link` |
|
|
59
|
+
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.46 validate` |
|
|
60
|
+
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.46 add owner/repo` |
|
|
61
|
+
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.46 refresh` |
|
|
62
|
+
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.46 refresh tdd` |
|
|
63
|
+
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.46 remove tdd` |
|
|
64
|
+
| Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.9.46 to-symlink tdd` |
|
|
65
|
+
| Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.9.46 to-snapshot tdd` |
|
|
66
|
+
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.46 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
|
-
| `
|
|
80
|
-
| `
|
|
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
|
|
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.
|
|
127
|
+
bunx @lythos/skill-deck@0.9.46 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.
|
|
213
|
+
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.46 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
package/src/COVERAGE-GAPS.md
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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: '
|
|
62
|
-
{ name: '
|
|
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
|
|
76
|
-
{ flag: '--yes', description: 'Skip interactive confirmation
|
|
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 '
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
console.error('❌ Missing target. Usage: deck
|
|
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
|
-
|
|
125
|
+
toSymlinkSkill(target, deckPath, workdir)
|
|
131
126
|
break
|
|
132
127
|
}
|
|
133
|
-
case '
|
|
134
|
-
const
|
|
135
|
-
if (!
|
|
136
|
-
console.error('❌ Missing target. Usage: deck
|
|
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
|
-
|
|
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/
|
|
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 {
|
|
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(), '
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
* deck to-symlink/to-snapshot — switch a skill's link mode in the working set.
|
|
4
4
|
*
|
|
5
|
-
* Per ADR-20260507190157540: snapshot = default safe (cp),
|
|
6
|
-
*
|
|
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
|
|
56
|
-
*
|
|
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
|
|
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
|
|
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 →
|
|
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
|
|
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
|
|
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}:
|
|
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:
|
|
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
|
}
|
package/src/prune-plan.test.ts
DELETED
|
@@ -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
|
-
}
|
package/src/reconcile.test.ts
DELETED
|
@@ -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
|
-
}
|